pythonforbio.
[WASM idle]
Applied Workflows09/13

Applied - Quality Control Plots

Starting sandbox…
Beginnerlesson

Applied - Quality Control Plots

In one line: the figures you make before the analysis — because they are what catch the sample swap, the batch effect and the failed library.

Uses: Seaborn · Faceting · Window Functions · SeqIO · Axes and Reductions · groupby

QC plots are the highest-return figures you will ever make, and the ones most often skipped. Every one below has, at some point, saved someone from publishing a result that was an artefact.

Per-base sequence quality

import gzip, numpy as np
from Bio import SeqIO

def quality_matrix(path, n_reads=100_000, max_len=300):
    q = np.full((n_reads, max_len), np.nan, dtype=np.float32)
    with gzip.open(path, "rt") as fh:
        for i, rec in enumerate(SeqIO.parse(fh, "fastq")):
            if i >= n_reads:
                break
            s = rec.letter_annotations["phred_quality"][:max_len]
            q[i, :len(s)] = s
    return q[:i]

q = quality_matrix("reads.fastq.gz")

pos = np.arange(q.shape[1])
med = np.nanmedian(q, axis=0)                        # axis=0 → per POSITION
q25, q75 = np.nanpercentile(q, [25, 75], axis=0)
q10, q90 = np.nanpercentile(q, [10, 90], axis=0)

fig, ax = plt.subplots(figsize=(8, 3.5))
ax.fill_between(pos, q10, q90, alpha=0.2, color="steelblue", label="10–90%")
ax.fill_between(pos, q25, q75, alpha=0.4, color="steelblue", label="25–75%")
ax.plot(pos, med, c="navy", lw=1.2, label="median")
ax.axhspan(0, 20, color="crimson", alpha=0.08)
ax.axhspan(20, 28, color="orange", alpha=0.08)
ax.set(xlabel="Position in read (bp)", ylabel="Phred quality",
       ylim=(0, 42), title="Per-base quality")
ax.legend(frameon=False, fontsize=8)

axis=0 because reads are rows and positions are columns. This is the FastQC "per base sequence quality" plot, and the expected shape is a gentle decline toward the 3' end. A cliff, or a dip in the middle, indicates a run problem.

Per-base composition

def base_composition(path, n_reads=100_000, max_len=300):
    counts = {b: np.zeros(max_len) for b in "ACGTN"}
    total = np.zeros(max_len)
    with gzip.open(path, "rt") as fh:
        for i, rec in enumerate(SeqIO.parse(fh, "fastq")):
            if i >= n_reads:
                break
            for j, b in enumerate(str(rec.seq)[:max_len]):
                counts.get(b, counts["N"])[j] += 1
                total[j] += 1
    return pd.DataFrame({b: c / np.maximum(total, 1) for b, c in counts.items()})

comp = base_composition("reads.fastq.gz")
comp.plot(figsize=(8, 3), color={"A":"#3DA853","C":"#4285F4",
                                 "G":"#F9AB00","T":"#EA4335","N":"grey"})
plt.ylabel("Fraction"); plt.xlabel("Position in read")

The four lines should be roughly flat and roughly equal. Divergence in the first ~12 bases is normal for RNA-seq — random hexamer priming bias — and is a well-known FastQC "failure" that is not a failure. Divergence elsewhere means adapter contamination or an overrepresented sequence.

Library size and detection rate

qc = pd.DataFrame({
    "sample": samples,
    "lib_size": counts.sum(axis=0),
    "n_detected": (counts > 0).sum(axis=0),
}).merge(meta, on="sample")

fig, axes = plt.subplots(1, 3, figsize=(13, 3.5), layout="constrained")

sns.barplot(data=qc, x="sample", y="lib_size", hue="condition", ax=axes[0])
axes[0].tick_params(axis="x", rotation=90, labelsize=6)
axes[0].set(ylabel="Library size (reads)")

sns.scatterplot(data=qc, x="lib_size", y="n_detected", hue="batch",
                style="condition", s=60, ax=axes[1])
axes[1].set(xlabel="Library size", ylabel="Genes detected")

sns.boxplot(data=log_cpm_long, x="sample", y="log_cpm", hue="condition",
            showfliers=False, ax=axes[2])
axes[2].tick_params(axis="x", rotation=90, labelsize=6)

The middle plot is the informative one: library size and genes detected should rise together and saturate. A sample off that curve — high depth, few genes — is a low-complexity library, usually meaning too little input material or over-amplification.

PCA coloured by every covariate

The single most valuable QC plot.

X = log_cpm[hvg_idx].T
X = X - X.mean(axis=0, keepdims=True)
U, S, Vt = np.linalg.svd(X, full_matrices=False)
ve = S**2 / (S**2).sum()

pcs = pd.DataFrame(U[:, :4] * S[:4], columns=["PC1","PC2","PC3","PC4"])
pcs["sample"] = samples
pcs = pcs.merge(meta, on="sample")

fig, axes = plt.subplots(1, 4, figsize=(16, 3.8), layout="constrained")
for ax, colour in zip(axes, ["condition", "batch", "sex", "lib_size"]):
    sns.scatterplot(data=pcs, x="PC1", y="PC2", hue=colour, s=70, ax=ax)
    ax.set(xlabel=f"PC1 ({ve[0]:.1%})", ylabel=f"PC2 ({ve[1]:.1%})", title=colour)
    ax.set_aspect("equal")

Two non-negotiables:

  • Variance explained on the axis labels. PC1 at 45% and PC1 at 4% look identical and mean completely different things.
  • set_aspect("equal"). PCA distances are only interpretable when the axes share a scale.

Read it like this: if samples separate by condition, good. If they separate by batch, you have a batch effect. If they separate by lib_size, your normalisation is inadequate. Colour by every covariate you have, including the boring ones — RIN, extraction date, technician, plate position.

# quantify it
for cov in ["condition", "batch", "sex"]:
    for pc in ["PC1", "PC2", "PC3"]:
        groups = [g[pc].values for _, g in pcs.groupby(cov)]
        if len(groups) > 1:
            f, p = stats.f_oneway(*groups)
            if p < 0.05:
                print(f"{cov} associates with {pc}: p={p:.2g}")

Sample correlation heatmap

corr = pd.DataFrame(np.corrcoef(log_cpm, rowvar=False),
                    index=samples, columns=samples)

cg = sns.clustermap(corr, cmap="rocket", vmin=0.7, vmax=1.0,
                    row_colors=col_colors, col_colors=col_colors,
                    figsize=(8, 8), cbar_kws=dict(label="Pearson r"))

Replicates should correlate ~0.95+. A sample correlating better with the other group than its own is a swap. This plot catches sample swaps, and sample swaps are more common than anyone likes to admit.

Coverage uniformity

cov = pd.read_csv("coverage.bed", sep="\t",
                  names=["chrom", "pos", "depth"], dtype={"chrom": "category"})
cov = cov.sort_values(["chrom", "pos"])

# smooth WITHIN chromosome — never across boundaries
cov["smoothed"] = (cov.groupby("chrom", observed=True)["depth"]
                      .transform(lambda s: s.rolling(1000, center=True,
                                                     min_periods=1).mean()))

fig, ax = plt.subplots(figsize=(11, 3))
sub = cov[cov["chrom"] == "chr17"]
ax.fill_between(sub["pos"], sub["smoothed"], alpha=0.6, step="mid")
ax.axhline(sub["smoothed"].median(), ls="--", c="crimson", lw=0.8)
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v/1e6:.1f}"))
ax.set(xlabel="Position on chr17 (Mb)", ylabel="Depth")

# dropout regions via run-length encoding
low = cov["smoothed"] < 0.2 * cov["smoothed"].median()
runs = (low != low.shift()).cumsum()
dropouts = (cov[low].groupby(runs)
            .agg(chrom=("chrom","first"), start=("pos","min"),
                 end=("pos","max"), n=("pos","size"))
            .query("n >= 50"))

The (flag != flag.shift()).cumsum() idiom is the run-length encoding pattern from Window Functions — it is how you turn a boolean mask into contiguous segments.

groupby("chrom").transform(...) is essential. A plain rolling mean averages the end of chr1 with the start of chr2.

Insert size (paired-end)

import pysam
bam = pysam.AlignmentFile("sample.bam", "rb")
sizes = [abs(r.template_length) for r in bam.head(500_000)
         if r.is_proper_pair and 0 < abs(r.template_length) < 1000]

fig, ax = plt.subplots(figsize=(6, 3))
sns.histplot(sizes, bins=100, ax=ax)
ax.set(xlabel="Insert size (bp)", ylabel="Read pairs")
print(f"median {np.median(sizes):.0f}, IQR {np.percentile(sizes,[25,75])}")

A single clean peak is expected. Bimodality, or a peak below the read length, indicates adapter dimers or library prep problems.

A QC dashboard

fig, axd = plt.subplot_mosaic(
    [["qual",  "qual",  "comp"],
     ["libsize", "pca", "pca"],
     ["corr",  "corr",  "corr"]],
    figsize=(13, 12), layout="constrained",
)
# ... populate each panel ...
for k, ax in axd.items():
    ax.text(-0.06, 1.06, k[0].upper(), transform=ax.transAxes,
            fontsize=11, fontweight="bold")
fig.savefig("qc_report.pdf", bbox_inches="tight", dpi=200)

Generate this automatically for every run and look at it before doing anything else. See Subplots and Layout.

What each plot catches

Plot Catches
Per-base quality run failure, cycle problems
Base composition adapter contamination, priming bias
Library size / detection low-input, over-amplified libraries
PCA by covariate batch effects, confounding, outliers
Sample correlation sample swaps, failed samples
Coverage uniformity capture dropout, GC bias, CNVs
Insert size adapter dimers, prep failures
p-value histogram model misspecification (see Applied - Differential Expression Volcano Plot)

The two bolded rows are the ones that most often change what you do next.

Common mistakes

  • Skipping QC and discovering the batch effect after analysis.
  • Only colouring PCA by the variable of interest. Colour by everything.
  • No variance explained on PCA axes.
  • PCA without equal aspect.
  • Rolling means across chromosome boundaries.
  • Ignoring an outlier because removing it weakens the result. Decide the exclusion rule before looking.
  • Not saving QC plots. They belong in the supplement.
  • Treating rendered plots as the record. Save the underlying metrics table too.

See also

Applied - Expression Matrices with NumPy · Applied - Heatmaps and Clustermaps · Applied - FASTA and FASTQ Workflows · Window Functions · Faceting · Subplots and Layout · Statistical Estimation in seaborn

scratch

No output yet — run the code to populate this drawer.