2.1 RNA-seq: differential expression in Busia¶
Theme: Analysis
Yesterday you launched RNA-Seq-Pop on a subsampled copy of the Busia data. Today we work with the
results of the same workflow (v2.3.0, same config.yaml) run on the full dataset: 16 libraries,
~50 million read pairs each.
We start with the theory you need to trust an RNA-seq result: how reads become counts, why counts must be normalised, how DESeq2 tests for differential expression and why we correct for multiple testing. Then we explore the Busia results: quality control, sample clustering, volcano plots for both contrasts, and gene set enrichment. At the end you are assigned the candidate genes that you will follow for the rest of the week.
Learning objectives¶
At the end of this module you will be able to:
Explain how RNA-seq reads become gene counts (library preparation, kallisto pseudoalignment, counts vs TPM).
Describe how DESeq2 normalises counts and tests for differential expression, and interpret log2 fold change, adjusted p-values and shrinkage.
Check RNA-seq quality control and sample clustering, and make volcano plots from RNA-Seq-Pop output.
Interpret a hypergeometric gene set enrichment test.
Find your candidate genes in the Busia results.
Setup¶
%pip install -q --no-warn-conflicts plotly==5.24.1import tarfile
import urllib.request
from pathlib import Path
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from scipy.stats import hypergeom
import plotly.io as pio
pio.templates.default = "simple_white" # course style for every Plotly figure
RESULTS_URL = "..." # TODO(data): Zenodo record of RNA-Seq-Pop Busia results (busia-rna-seq-pop-results.tar.gz)
CANDIDATES_URL = "https://raw.githubusercontent.com/sanjaynagi/anopheles-omics-training/main/data/candidate-genes.tsv"
# Where the results live. To use your own run in a Codespace, set this to Path("/workspaces/rna-seq-pop")
RESULTS_DIR = Path("busia-results")
if not (RESULTS_DIR / "results").exists():
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
archive = RESULTS_DIR / "busia-rna-seq-pop-results.tar.gz"
urllib.request.urlretrieve(RESULTS_URL, archive)
with tarfile.open(archive) as tar:
tar.extractall(RESULTS_DIR, filter="data")
R = RESULTS_DIR / "results"
samples = pd.read_csv(RESULTS_DIR / "config" / "samples.tsv", sep="\t")
CONTRASTS = ["Kisumu_BusiaParental", "BusiaParental_BusiaSelected"]
ORDER = {"treatment": ["Kisumu", "BusiaParental", "BusiaSelected"]}
COLOURS = {"Kisumu": "#4c78a8", "BusiaParental": "#f58518", "BusiaSelected": "#e45756"}
samples# Replace with the genes you were assigned on Day 2 (see data/candidate-genes.tsv)
CANDIDATE_GENES = ["AGAP002862", "AGAP009193"] # CYP6AA1, GSTE4# Well-known insecticide resistance genes, used to label plots
KNOWN_IR = {
"AGAP008052": "SAP2", "AGAP008051": "SAP1", "AGAP008054": "SAP3",
"AGAP002865": "CYP6P3", "AGAP002867": "CYP6P4", "AGAP002862": "CYP6AA1",
"AGAP008212": "CYP6M2", "AGAP000818": "CYP9K1", "AGAP006048": "CYP4J5",
"AGAP009194": "GSTE2", "AGAP005372": "COEBE3C", "AGAP004707": "Vgsc",
}From mosquitoes to reads¶
An RNA-seq experiment measures which genes are being transcribed, and how much, at one moment. For the Busia libraries [1]:
RNA extraction. Total RNA from a pool of five 4-day-old females per library. Pooling averages out individual variation and gives enough RNA; replicates (4–6 pools per group) capture the variation between pools.
mRNA enrichment. Poly-A selection keeps mRNA and removes most ribosomal RNA.
Fragmentation and cDNA synthesis. The mRNA is broken into short pieces and reverse-transcribed into cDNA.
Adapters and PCR. Sequencing adapters (with a sample index) are ligated and the library amplified.
Sequencing. 150 bp paired-end reads on an Illumina NovaSeq 6000.
Every step can add bias (PCR duplicates, 3′ bias from degraded RNA, GC content). That is why we look at QC before trusting any result. And a key point: a gene with more mRNA gives more fragments, but so does a longer gene, and so does a library that was simply sequenced more deeply.
From reads to counts: pseudoalignment with kallisto¶
To count reads per transcript we could align every read to the genome base by base. Kallisto [2] takes a faster route. It builds an index of all k-mers (31 bp words) in the transcriptome. For each read it looks up its k-mers and finds the set of transcripts that contain all of them: the read’s compatibility class. It never works out the exact alignment position, hence pseudoalignment. An expectation–maximisation (EM) algorithm then shares reads that are compatible with several transcripts (e.g. isoforms of one gene) among them.
Kallisto reports for each transcript:
est_counts: the estimated number of reads from that transcript.tpm: transcripts per million, which corrects for transcript length and sequencing depth.
where is the count and the effective length of transcript . TPMs are good for
comparing genes within a sample. For testing differences between groups, DESeq2 wants the raw
counts, because its statistical model is built on counts. RNA-Seq-Pop sums est_counts over each
gene’s transcripts, rounds them, and passes them to DESeq2.
Kallisto also runs 100 bootstraps (-b 100), re-estimating counts on resampled reads. Sleuth
uses these to test individual isoforms (results/isoformdiff/).
# A toy example: two genes with the same count but different lengths
toy = pd.DataFrame({
"gene": ["short gene", "long gene", "other"],
"length_bp": [1_000, 4_000, 2_000],
"count": [400, 400, 200],
})
rate = toy["count"] / toy["length_bp"]
toy["TPM"] = (rate / rate.sum() * 1e6).round()
toyThe short gene has four times the TPM of the long gene, even though both have 400 reads: per unit of length, it is more highly expressed.
Quality control¶
Before any biology, check the data. RNA-Seq-Pop collects QC from fastp, kallisto, HISAT2, samtools,
mosdepth and bcftools into one MultiQC report, results/qc/multiQC.html. Download it (in Colab:
the folder icon on the left, then busia-results/results/qc/) and open it in your browser. Look at
the fastp read quality and adapter content, and the HISAT2 alignment rates.
Here we plot the key numbers directly. First, how many reads kallisto pseudoaligned to the transcriptome:
kallisto = pd.read_csv(R / "counts" / "KallistoQuantSummary.tsv", sep="\t")
kallisto = kallisto.query("sample != 'Total'").merge(samples, left_on="sample", right_on="sampleID")
fig = px.bar(
kallisto, x="sample", y="percentage", color="treatment",
color_discrete_map=COLOURS, category_orders=ORDER,
hover_data=["totalReads", "totalAligned"],
labels={"percentage": "% reads pseudoaligned"},
title="Kallisto pseudoalignment rate", template="simple_white",
)
fig.update_yaxes(range=[0, 100])
fig.show()count_stats = pd.read_csv(R / "counts" / "countStatistics.tsv", sep="\t")
count_stats = count_stats.merge(samples, left_on="Sample", right_on="sampleID")
fig = px.bar(
count_stats, x="Sample", y="total_counts", color="treatment",
color_discrete_map=COLOURS, category_orders=ORDER,
hover_data=["genes_zerocounts", "proportion_low"],
title="Reads counted to genes", template="simple_white",
)
fig.show()# Mean depth of the HISAT2 genome alignments (used for variant calling), from mosdepth
coverage = []
for sample in samples["sampleID"]:
summary = pd.read_csv(R / "qc" / "coverage" / f"{sample}.mosdepth.summary.txt", sep="\t")
summary["sampleID"] = sample
coverage.append(summary)
coverage = pd.concat(coverage).merge(samples)
coverage = coverage[coverage["chrom"].isin(["2L", "2R", "3L", "3R", "X"])]
fig = px.bar(
coverage, x="sampleID", y="mean", color="treatment", facet_col="chrom",
color_discrete_map=COLOURS, category_orders=ORDER,
labels={"mean": "mean depth"}, title="Mean genome coverage per chromosome arm",
template="simple_white", height=400,
)
fig.update_xaxes(showticklabels=False)
fig.show()Mean genome-wide depth looks low because most of the genome is not transcribed. Coverage is very high in expressed exons and zero elsewhere, which matters for variant calling (Module 2.2).
Normalisation¶
Raw counts are not comparable between samples. Two things differ:
Sequencing depth. A library with twice as many reads has roughly twice the count for every gene.
Library composition. If a few genes are hugely expressed in one sample, they use up reads and every other gene looks lower, even if its expression has not changed.
Dividing by the total count (counts per million) handles depth but not composition. DESeq2 uses the median-of-ratios method [3]:
For each gene, compute the geometric mean of its counts across all samples (a pseudo-reference sample).
For each sample, divide every gene’s count by that reference.
The sample’s size factor is the median of those ratios. Most genes are not differentially expressed, so the median is robust to the few that are.
Normalised counts = raw counts / size factor.
RNA-Seq-Pop writes both results/counts/rawcounts.tsv and results/counts/normCounts.tsv. Let’s
compute the size factors ourselves and check them against DESeq2’s.
raw = pd.read_csv(R / "counts" / "rawcounts.tsv", sep="\t", index_col="GeneID")
norm = pd.read_csv(R / "counts" / "normCounts.tsv", sep="\t", index_col="GeneID")
# 1-3: median of ratios, using genes with a non-zero count in every sample
expressed = raw[(raw > 0).all(axis=1)]
log_ref = np.log(expressed).mean(axis=1) # log geometric mean per gene
my_size_factors = np.exp(np.log(expressed).sub(log_ref, axis=0).median())
# DESeq2's size factors are implied by raw / normalised counts
well_expressed = raw.index[(raw >= 100).all(axis=1)]
deseq2_size_factors = (raw.loc[well_expressed] / norm.loc[well_expressed]).median()
pd.DataFrame({"ours": my_size_factors, "DESeq2": deseq2_size_factors}).round(3)Do the samples cluster by group?¶
Before testing genes, check that replicates look alike and groups look different. RNA-Seq-Pop plots
a PCA of variance-stabilised counts (results/counts/PCA.pdf). We do a similar PCA on
log-transformed normalised counts of the 2,000 most variable genes.
log_counts = np.log2(norm + 1)
top_genes = log_counts.var(axis=1).sort_values(ascending=False).index[:2000]
X = log_counts.loc[top_genes].T
X = X - X.mean()
U, S, Vt = np.linalg.svd(X, full_matrices=False)
pcs = pd.DataFrame(U[:, :4] * S[:4], index=X.index, columns=["PC1", "PC2", "PC3", "PC4"])
explained = S**2 / (S**2).sum()
pcs = pcs.merge(samples, left_index=True, right_on="sampleID")
fig = px.scatter(
pcs, x="PC1", y="PC2", color="treatment", text="sampleID",
color_discrete_map=COLOURS, category_orders=ORDER,
labels={"PC1": f"PC1 ({explained[0]:.0%})", "PC2": f"PC2 ({explained[1]:.0%})"},
title="PCA of normalised counts", template="simple_white", height=500,
)
fig.update_traces(textposition="top center", marker_size=12)
fig.show()corr = log_counts.loc[top_genes].corr()
fig = px.imshow(corr, color_continuous_scale="Viridis", title="Sample-to-sample correlation (log counts)",
height=550, width=600)
fig.show()In the paper, replicates of each group clustered together in both the PCA and the correlation heatmap [1]. Which group is most different? Does that fit with Kisumu being colonised in 1975 and the Busia colony in 2018?
The DESeq2 model¶
Why a negative binomial?¶
If sequencing were the only source of noise, the count for a gene would follow a Poisson distribution, whose variance equals its mean. Real biological replicates vary more than that: different pools of mosquitoes really do express genes at slightly different levels. DESeq2 [3] models counts with a negative binomial distribution:
where is the size factor times the true expression, and is the dispersion of gene . With only 4–6 replicates, each gene’s dispersion is hard to estimate, so DESeq2 shares information across genes: it fits a trend of dispersion against mean expression and shrinks each gene’s estimate towards it.
Let’s check that the Busia replicates are over-dispersed:
parental = norm[samples.loc[samples.treatment == "BusiaParental", "sampleID"]]
mv = pd.DataFrame({"mean": parental.mean(axis=1), "variance": parental.var(axis=1)})
mv = mv[mv["mean"] > 1].sample(4000, random_state=1)
fig = px.scatter(mv, x="mean", y="variance", log_x=True, log_y=True, opacity=0.3,
title="Mean vs variance across the 6 BusiaParental replicates", template="simple_white")
line = np.logspace(0, np.log10(mv["mean"].max()), 50)
fig.add_trace(go.Scatter(x=line, y=line, mode="lines", name="Poisson (variance = mean)"))
fig.show()For well-expressed genes the variance is far above the Poisson line: that extra variance is what the dispersion parameter captures.
Design, contrasts and the Wald test¶
RNA-Seq-Pop fits one model to all 16 samples with the design ~ treatment, then extracts each
contrast. For BusiaParental_BusiaSelected, the coefficient is
so positive log2FC means higher expression in the second (case) group. A Wald test divides
the log2FC by its standard error (lfcSE) to give the stat column and a p-value.
Multiple testing and the false discovery rate¶
We test ~13,000 genes. At p < 0.05, about 650 genes would be “significant” by chance even if nothing
had changed. DESeq2 reports padj, p-values adjusted with the Benjamini–Hochberg method, which
controls the false discovery rate (FDR): among genes with padj < 0.05, we expect about 5% to be
false positives. DESeq2 also applies independent filtering: genes with very low counts are given
padj = NA because they have almost no power to be significant, and dropping them makes the
correction less severe for the rest.
Log2 fold change shrinkage¶
A gene with 2 reads in one group and 10 in the other has a log2FC of ~2.3, but that estimate is
extremely noisy. DESeq2’s lfcShrink (e.g. with the apeglm method) pulls such estimates towards zero
in proportion to their uncertainty, which makes ranking genes by fold change more reliable.
RNA-Seq-Pop v2.3.0 reports unshrunken log2FC, so be careful with genes that have a low baseMean
(mean normalised count) or a large lfcSE.
The differential expression tables¶
RNA-Seq-Pop writes one table per contrast, results/genediff/{contrast}.csv, and all of them as
sheets of results/genediff/Ag_Busia_diffexp.xlsx. The columns are:
| Column | Meaning |
|---|---|
GeneID, GeneName, GeneDescription | AgamP4 gene and its annotation |
baseMean | mean normalised count across all 16 samples |
log2FoldChange, lfcSE | log2 fold change (case vs control) and its standard error |
stat, pvalue, padj | Wald statistic, p-value and BH-adjusted p-value |
FC | fold change, 2^log2FoldChange |
absolute_diff | difference in summed raw counts between the two groups |
de = {c: pd.read_csv(R / "genediff" / f"{c}.csv") for c in CONTRASTS}
de["BusiaParental_BusiaSelected"].head(10)Worked example: CYP6AA1¶
Throughout the course we show each method first on CYP6AA1 (AGAP002862), then you repeat it on
your own genes. CYP6AA1 is a cytochrome P450 in the Cyp6aa/Cyp6p cluster on chromosome arm 2R
(~28.48 Mb), a region under strong selection in East Africa. In Uganda and Kenya a “triple mutant”
haplotype has spread that combines a duplication of Cyp6aa1, the missense mutation
Cyp6p4-I236M and a ZZB transposable element insertion, and it is associated with pyrethroid
resistance [9]. This haplotype sits in the Busia 2R selective sweep you will meet on Day 3, and
Ag-vampIR markers for it predict survival on deltamethrin nets in Siaya (Day 4).
A gene duplication matters for RNA-seq. With two copies of the gene, there is more template to transcribe, so reads over a duplicated gene inflate its apparent expression: a higher count may reflect copy number rather than stronger regulation. The same duplication shows up in the genome alignments as elevated coverage over the gene. RNA-seq alone cannot tell the two apart; on Day 3 you check CNV frequencies in wild Busia mosquitoes with WGS data.
Let’s look at CYP6AA1 in both contrasts: first parental vs selected (did selection change it?), then Kisumu vs parental (is it different between the strains?).
CYP6AA1 = "AGAP002862"
pd.concat({c: de[c].query("GeneID == @CYP6AA1") for c in ["BusiaParental_BusiaSelected", "Kisumu_BusiaParental"]})[
["GeneID", "GeneName", "baseMean", "log2FoldChange", "lfcSE", "padj"]
]And the normalised counts in each sample. We define a small plotting function here and reuse it for other genes below.
def plot_genes(gene_ids):
names = {g: KNOWN_IR.get(g, g) for g in gene_ids}
long = (
norm.loc[[g for g in gene_ids if g in norm.index]]
.rename_axis("GeneID").reset_index()
.melt(id_vars="GeneID", var_name="sampleID", value_name="normalised count")
.merge(samples)
)
long["gene"] = long["GeneID"].map(names)
fig = px.strip(
long, x="treatment", y="normalised count", color="treatment", facet_col="gene",
facet_col_wrap=4, color_discrete_map=COLOURS, category_orders=ORDER,
hover_data=["sampleID"], template="simple_white", height=400,
)
fig.update_yaxes(matches=None, rangemode="tozero")
fig.update_yaxes(showticklabels=True)
return fig
plot_genes([CYP6AA1]).show()Interpret the result with care. In the AnoExpress meta-analysis of the same Busia libraries, CYP6AA1 is not significantly different between selected survivors and Kisumu (log2FC −0.29), so we cannot claim it is overexpressed relative to Kisumu from that comparison. Compare the two RNA-Seq-Pop contrasts: a gene can respond to selection within the colony while showing no difference from a lab strain, especially one like Kisumu whose own history is unusual (Module 2.2). Write down the log2FC and padj for both contrasts in your dossier.
# How many genes are significant? (the paper reports 5,416 and 5,657 at padj < 0.05)
summary = pd.DataFrame({
c: {
"tested (padj not NA)": df["padj"].notna().sum(),
"padj < 0.05": (df["padj"] < 0.05).sum(),
"padj < 0.05, up": ((df["padj"] < 0.05) & (df["log2FoldChange"] > 0)).sum(),
"padj < 0.05, down": ((df["padj"] < 0.05) & (df["log2FoldChange"] < 0)).sum(),
"padj < 0.05 and |log2FC| > 1": ((df["padj"] < 0.05) & (df["log2FoldChange"].abs() > 1)).sum(),
}
for c, df in de.items()
})
summary# A healthy p-value histogram: flat (true nulls) with a spike near 0 (real differences)
fig = px.histogram(de["BusiaParental_BusiaSelected"], x="pvalue", nbins=50,
title="p-values, BusiaParental vs BusiaSelected", template="simple_white")
fig.show()Volcano plots¶
A volcano plot shows effect size (log2FC) against evidence (−log10 padj). Genes top-left and top-right are strongly and significantly changed.
def volcano(contrast, label_genes=KNOWN_IR, fc=1, alpha=0.05):
df = de[contrast].dropna(subset=["padj"]).copy()
df["-log10 padj"] = -np.log10(df["padj"].clip(lower=1e-300))
df["class"] = np.select(
[(df.padj < alpha) & (df.log2FoldChange > fc), (df.padj < alpha) & (df.log2FoldChange < -fc)],
["up", "down"], default="not significant",
)
df["label"] = df["GeneID"].map(label_genes)
control, case = contrast.split("_")
fig = px.scatter(
df, x="log2FoldChange", y="-log10 padj", color="class",
color_discrete_map={"up": "#e45756", "down": "#4c78a8", "not significant": "lightgrey"},
hover_data=["GeneID", "GeneName", "baseMean", "lfcSE"],
title=f"{case} vs {control} (log2FC > 0 = higher in {case})",
template="simple_white", height=550, opacity=0.6,
)
labelled = df.dropna(subset=["label"])
fig.add_trace(go.Scatter(
x=labelled["log2FoldChange"], y=labelled["-log10 padj"], text=labelled["label"],
mode="markers+text", textposition="top center", marker=dict(color="black", size=8),
name="known IR genes",
))
fig.add_hline(y=-np.log10(alpha), line_dash="dash", line_color="grey")
fig.add_vline(x=fc, line_dash="dash", line_color="grey")
fig.add_vline(x=-fc, line_dash="dash", line_color="grey")
return fig
volcano("BusiaParental_BusiaSelected").show()volcano("Kisumu_BusiaParental").show()In the paper [1], all three sensory appendage protein genes (Sap1–3) were overexpressed in the selected line compared with its parent, with Sap2 up ~10.7-fold. SAP2 binds pyrethroids and was first linked to resistance in West African An. coluzzii [4]. Many cytochrome P450s, carboxylesterases and ABC transporters were also differentially expressed.
Noisy fold changes at low expression¶
Plot log2FC against mean expression (an MA plot) and colour by the standard error. The widest
spread of fold changes is at low baseMean, where the estimates are least reliable. This is what
shrinkage would correct.
df = de["BusiaParental_BusiaSelected"].query("baseMean > 0")
fig = px.scatter(df, x="baseMean", y="log2FoldChange", color="lfcSE", log_x=True, opacity=0.5,
color_continuous_scale="Viridis", range_color=[0, 2],
hover_data=["GeneID", "GeneName", "padj"],
title="MA plot, BusiaSelected vs BusiaParental", template="simple_white")
fig.show()Comparing the two contrasts¶
Some genes change in the same direction in both comparisons: higher in the Busia parental line than in Kisumu, and higher again after selection. The paper highlighted a carboxylesterase cluster on 2L (Coebe2c, Coebe3c, Coebe4c) with this pattern.
both = de["Kisumu_BusiaParental"].merge(
de["BusiaParental_BusiaSelected"], on=["GeneID", "GeneName", "GeneDescription"],
suffixes=("_KisPar", "_ParSel"),
)
both["pattern"] = np.select(
[
(both.padj_KisPar < 0.05) & (both.log2FoldChange_KisPar > 0) & (both.padj_ParSel < 0.05) & (both.log2FoldChange_ParSel > 0),
(both.padj_KisPar < 0.05) & (both.log2FoldChange_KisPar < 0) & (both.padj_ParSel < 0.05) & (both.log2FoldChange_ParSel < 0),
],
["up in both", "down in both"], default="other",
)
print(both["pattern"].value_counts())
fig = px.scatter(
both, x="log2FoldChange_KisPar", y="log2FoldChange_ParSel", color="pattern",
color_discrete_map={"up in both": "#e45756", "down in both": "#4c78a8", "other": "lightgrey"},
hover_data=["GeneID", "GeneName"], opacity=0.5, template="simple_white", height=550,
labels={"log2FoldChange_KisPar": "log2FC BusiaParental vs Kisumu",
"log2FoldChange_ParSel": "log2FC BusiaSelected vs BusiaParental"},
)
fig.show()both.query("pattern == 'up in both' and GeneName.notna() and GeneName != ''", engine="python").sort_values(
"log2FoldChange_ParSel", ascending=False
)[["GeneID", "GeneName", "log2FoldChange_KisPar", "log2FoldChange_ParSel", "padj_ParSel"]].head(20)Look at the counts¶
Always go back to the normalised counts for a gene you care about. A significant result driven by one odd replicate looks very different from a consistent shift.
plot_genes(["AGAP008051", "AGAP008052", "AGAP008054", "AGAP005372"] + CANDIDATE_GENES).show()Gene set enrichment¶
A list of 5,000 DE genes is hard to interpret one by one. Enrichment analysis asks whether genes with a particular function are over-represented among them. Functions come from Gene Ontology (GO) terms, which describe molecular functions, biological processes and cellular components.
RNA-Seq-Pop uses over-representation analysis with the hypergeometric test. For each GO term:
= genes with any GO annotation (the universe)
= genes annotated with this term
= genes in our list (RNA-Seq-Pop uses genes with
padj < 0.05and fold change > 2)= genes in our list that have this term
If the list were a random draw of genes, would follow a hypergeometric distribution. The p-value is the probability of seeing or more: . With thousands of GO terms tested, p-values are again corrected with Benjamini–Hochberg.
This differs from rank-based GSEA (as in the GSEA and fgsea tools), which uses the whole ranked list of genes rather than a significance cut-off. Over-representation depends on the thresholds you choose; rank-based methods do not, but ask a subtly different question.
A worked example: suppose 8,000 genes have GO annotations, 100 of them are “monooxygenase activity” (mostly P450s), and 400 genes are strongly upregulated after selection, 15 of which are monooxygenases.
N, K, n, k = 8000, 100, 400, 15
expected = n * K / N
p = hypergeom(M=N, n=K, N=n).sf(k - 1) # P(X >= k)
print(f"Expected by chance: {expected:.1f} genes; observed: {k}; p = {p:.2e}")RNA-Seq-Pop writes the results to results/gsea/genediff/{contrast}.de.tsv, with columns
annotation (GO ID), pval, padj and descriptions. For the course run, GO terms come from the
eggNOG-mapper annotations that ship with RNA-Seq-Pop (the paper used VectorBase’s GO file).
def plot_go(contrast, n_terms=15):
go_res = pd.read_csv(R / "gsea" / "genediff" / f"{contrast}.de.tsv", sep="\t", index_col=0)
top = go_res.dropna(subset=["padj"]).nsmallest(n_terms, "padj").copy()
top["-log10 padj"] = -np.log10(top["padj"].clip(lower=1e-300))
top["term"] = top["descriptions"].fillna(top["annotation"])
fig = px.bar(
top.iloc[::-1], x="-log10 padj", y="term", orientation="h", hover_data=["annotation", "pval"],
title=f"GO terms enriched among upregulated genes: {contrast}", template="simple_white", height=500,
)
fig.add_vline(x=-np.log10(0.05), line_dash="dash", line_color="grey")
return fig
plot_go("BusiaParental_BusiaSelected").show()plot_go("Kisumu_BusiaParental").show()RNA-Seq-Pop also runs the same test on the genes with the top 5% of Fst values
(results/gsea/fst/). We come back to Fst in Module 2.2.
Your candidate genes¶
Over the rest of the week you follow two or three genes from Busia to a field assay. The course pool
is in data/candidate-genes.tsv. Each gene is overexpressed in Busia; the pool mixes known
insecticide resistance genes (category = known_ir: P450s, GSTs, carboxylesterases) with less
studied genes (category = novel). CYP6AA1 is the focal gene that everyone works through as
the worked example. Other columns tell you whether the gene is overexpressed in
other AnoExpress experiments, lies in a Busia selective sweep, or is near an Ag-vampIR amplicon; you
will check these yourself on Days 2–4.
The busia_log2fc and busia_padj columns come from the AnoExpress meta-analysis of the same Busia
data, where the contrast is selected survivors vs Kisumu. RNA-Seq-Pop gives us the two steps of
that comparison separately. Because DESeq2 fits one model to all samples, the two log2FCs add up:
log2(Selected/Kisumu) = log2(Parental/Kisumu) + log2(Selected/Parental).
candidates = pd.read_csv(CANDIDATES_URL, sep="\t")
candidates = candidates.merge(
both[["GeneID", "log2FoldChange_KisPar", "padj_KisPar", "log2FoldChange_ParSel", "padj_ParSel"]],
left_on="gene_id", right_on="GeneID", how="left",
).drop(columns="GeneID")
candidates["log2FC_Sel_vs_Kis"] = candidates["log2FoldChange_KisPar"] + candidates["log2FoldChange_ParSel"]
candidates[["gene_id", "gene_name", "category", "busia_log2fc", "log2FC_Sel_vs_Kis",
"log2FoldChange_KisPar", "log2FoldChange_ParSel", "padj_ParSel"]].round(3)How genes are assigned. Your facilitator gives you a participant number. You get one known IR
gene and two novel genes, chosen so that the class covers the whole pool. (Self-paced learners: pick
any number.) Copy your genes into the CANDIDATE_GENES cell at the top of every notebook from now on.
PARTICIPANT_NUMBER = 1 # from your facilitator
known = candidates.query("category == 'known_ir'").reset_index(drop=True)
novel = candidates.query("category == 'novel'").reset_index(drop=True)
i = PARTICIPANT_NUMBER - 1
my_genes = [
known.loc[i % len(known), "gene_id"],
novel.loc[i % len(novel), "gene_id"],
novel.loc[(i + len(novel) // 2) % len(novel), "gene_id"],
]
print("CANDIDATE_GENES =", my_genes)
candidates.set_index("gene_id").loc[my_genes, ["gene_name", "description", "category", "contig", "start"]]plot_genes(my_genes).show()Start your gene dossier (assessment/gene-dossier-template.md) now: for each gene record its
log2FC and padj in both contrasts, and whether its counts look consistent across replicates.
Exercise 1¶
In the BusiaParental_BusiaSelected contrast, how many genes are significantly upregulated
(padj < 0.05, log2FC > 1) that are P450s (GeneName starting with CYP), carboxylesterases
(COE), GSTs (GST) or ABC transporters (ABC)?
Show answer
df = de["BusiaParental_BusiaSelected"]
up = df[(df.padj < 0.05) & (df.log2FoldChange > 1)].copy()
up["family"] = up["GeneName"].fillna("").str.extract(r"^(CYP|COE|GST|ABC)")[0]
up["family"].value_counts()The paper counted 51 P450s, 23 carboxylesterases and 20 ABC transporters that were significantly differentially expressed (either direction, no fold change cut-off) in this contrast. Your numbers will be lower because of the fold change threshold, and not all family members have a GeneName.
Exercise 2¶
Is Vgsc (AGAP004707), the pyrethroid target, differentially expressed between the parental and
selected lines? What does this tell you about the limits of an expression-only analysis?
Show answer
de["BusiaParental_BusiaSelected"].query("GeneID == 'AGAP004707'")It is not significantly differentially expressed (the paper found the same). Yet the Vgsc-995S kdr allele rose from ~25% to 100% during selection. Resistance caused by a change in protein sequence is invisible to differential expression: you need the sequence (Module 2.2).
Exercise 3¶
Check that log2FCs are additive: plot log2FC_Sel_vs_Kis (computed above) against the AnoExpress
busia_log2fc for the candidate genes. Are they similar? Why might they not match exactly?
Show answer
px.scatter(candidates, x="busia_log2fc", y="log2FC_Sel_vs_Kis", text="gene_name",
template="simple_white").show()They should be strongly correlated. Differences come from the different pipelines: AnoExpress processed the data separately (its own reference annotation and gene-level summarisation), our run uses the AgamP4 Ensembl release 63 annotation and eggNOG GO terms, and DESeq2’s dispersion estimates depend on which samples are in the model.
Exercise 4¶
For each of your candidate genes, is the change mainly between Kisumu and the parental line, or
during selection? Look at the volcano plots, the table above and plot_genes. Note your answer in
your dossier.
Show answer
Compare log2FoldChange_KisPar and log2FoldChange_ParSel for each gene. A gene that is already high in the parental line (large KisPar, small ParSel) differs between strains but did not respond to selection; a gene with a large ParSel change responded to four generations of deltamethrin selection plus exposure. Remember that the selected samples were survivors of an exposure 24 h earlier, so some changes may be induction by the insecticide rather than selection.
Quiz¶
Check your understanding. The quiz runs in Colab and in the course book.
%pip install -q jupyterquiz==2.9.6.4
from jupyterquiz import display_quiz
display_quiz("https://raw.githubusercontent.com/sanjaynagi/anopheles-omics-training/main/quizzes/2-1-rna-seq-results.json")Summary¶
Kallisto pseudoaligns reads to the transcriptome; RNA-Seq-Pop sums transcript counts per gene for DESeq2. TPMs compare genes within a sample; raw counts are used for testing.
DESeq2 normalises with size factors (median of ratios), models counts with a negative binomial with gene-wise dispersion, and tests contrasts with a Wald test. log2FC > 0 means higher in the second group of the contrast.
Use adjusted p-values (FDR); treat large fold changes of lowly expressed genes with caution, because RNA-Seq-Pop does not shrink log2FC.
In Busia, thousands of genes differ between groups; SAP genes, P450s and a carboxylesterase cluster are upregulated after selection, while Vgsc is not differentially expressed.
Over-representation analysis uses a hypergeometric test on a thresholded gene list.
A duplicated gene, such as Cyp6aa1 on the Uganda/Kenya triple-mutant haplotype, can look overexpressed simply because it has more copies.
Well done!¶
You now have your candidate genes. In Module 2.2 we look at what the sequence in the same reads tells us about them, and in Module 2.3 whether they are overexpressed in other studies.
References¶
Nagi SC, Oruni A, Weetman D, Donnelly MJ (2023). RNA-Seq-Pop: Exploiting the sequence in RNA sequencing—A Snakemake workflow reveals patterns of insecticide resistance in the malaria vector Anopheles gambiae. Molecular Ecology Resources 23:946–961. Nagi et al. (2023)
Bray NL, Pimentel H, Melsted P, Pachter L (2016). Near-optimal probabilistic RNA-seq quantification. Nature Biotechnology 34:525–527. Bray et al. (2016)
Love MI, Huber W, Anders S (2014). Moderated estimation of fold change and dispersion for RNA-seq data with DESeq2. Genome Biology 15:550. Love et al. (2014)
Ingham VA et al. (2020). A sensory appendage protein protects malaria vectors from pyrethroids. Nature 577:376–380. Ingham et al. (2019)
Zhu A, Ibrahim JG, Love MI (2019). Heavy-tailed prior distributions for sequence count data: removing the noise and preserving large differences. Bioinformatics 35:2084–2092. Zhu et al. (2018)
Benjamini Y, Hochberg Y (1995). Controlling the false discovery rate: a practical and powerful approach to multiple testing. Journal of the Royal Statistical Society B 57:289–300. Benjamini & Hochberg (1995)
Pimentel H, Bray NL, Puente S, Melsted P, Pachter L (2017). Differential analysis of RNA-seq incorporating quantification uncertainty. Nature Methods 14:687–690. Pimentel et al. (2017)
Subramanian A et al. (2005). Gene set enrichment analysis: a knowledge-based approach for interpreting genome-wide expression profiles. PNAS 102:15545–15550. Subramanian et al. (2005)
Njoroge H et al. (2022). Identification of a rapidly-spreading triple mutant for high-level metabolic insecticide resistance in Anopheles gambiae provides a real-time molecular diagnostic for antimalarial intervention deployment. Molecular Ecology 31:4307–4318. Njoroge et al. (2022)
- Nagi, S. C., Oruni, A., Weetman, D., & Donnelly, M. J. (2023). RNA‐Seq‐Pop : Exploiting the sequence in <scp>RNA</scp> sequencing—A Snakemake workflow reveals patterns of insecticide resistance in the malaria vector Anopheles gambiae. Molecular Ecology Resources, 23(4), 946–961. 10.1111/1755-0998.13759
- Bray, N. L., Pimentel, H., Melsted, P., & Pachter, L. (2016). Near-optimal probabilistic RNA-seq quantification. Nature Biotechnology, 34(5), 525–527. 10.1038/nbt.3519
- Love, M. I., Huber, W., & Anders, S. (2014). Moderated estimation of fold change and dispersion for RNA-seq data with DESeq2. Genome Biology, 15(12). 10.1186/s13059-014-0550-8
- Ingham, V. A., Anthousi, A., Douris, V., Harding, N. J., Lycett, G., Morris, M., Vontas, J., & Ranson, H. (2019). A sensory appendage protein protects malaria vectors from pyrethroids. Nature, 577(7790), 376–380. 10.1038/s41586-019-1864-1
- Zhu, A., Ibrahim, J. G., & Love, M. I. (2018). Heavy-tailed prior distributions for sequence count data: removing the noise and preserving large differences. Bioinformatics, 35(12), 2084–2092. 10.1093/bioinformatics/bty895
- Benjamini, Y., & Hochberg, Y. (1995). Controlling the False Discovery Rate: A Practical and Powerful Approach to Multiple Testing. Journal of the Royal Statistical Society Series B: Statistical Methodology, 57(1), 289–300. 10.1111/j.2517-6161.1995.tb02031.x
- Pimentel, H., Bray, N. L., Puente, S., Melsted, P., & Pachter, L. (2017). Differential analysis of RNA-seq incorporating quantification uncertainty. Nature Methods, 14(7), 687–690. 10.1038/nmeth.4324
- Subramanian, A., Tamayo, P., Mootha, V. K., Mukherjee, S., Ebert, B. L., Gillette, M. A., Paulovich, A., Pomeroy, S. L., Golub, T. R., Lander, E. S., & Mesirov, J. P. (2005). Gene set enrichment analysis: A knowledge-based approach for interpreting genome-wide expression profiles. Proceedings of the National Academy of Sciences, 102(43), 15545–15550. 10.1073/pnas.0506580102
- Njoroge, H., van’t Hof, A., Oruni, A., Pipini, D., Nagi, S. C., Lynd, A., Lucas, E. R., Tomlinson, S., Grau‐Bove, X., McDermott, D., Wat’senga, F. T., Manzambi, E. Z., Agossa, F. R., Mokuba, A., Irish, S., Kabula, B., Mbogo, C., Bargul, J., Paine, M. J. I., … Donnelly, M. J. (2022). Identification of a rapidly‐spreading triple mutant for high‐level metabolic insecticide resistance in Anopheles gambiae provides a real‐time molecular diagnostic for antimalarial intervention deployment. Molecular Ecology, 31(16), 4307–4318. 10.1111/mec.16591