17  Functional enrichment analysis of genomic coordinations

Functional analysis using NGS data (e.g., RNA-Seq and ChIP-Seq) can be performed by linking coding and non-coding regions to coding genes via the ChIPseeker (Yu et al. 2015) package, which can annotate genomic regions to their nearest genes, host genes, and flanking genes respectively. In addition, it provides a function, seq2gene(), that simultaneously considers host genes, promoter regions and flanking genes from intergenic regions that may be under control via cis-regulation. This function maps genomic regions to genes in a many-to-many manner and facilitates functional analysis.

17.1 Chapter overview

Aspect Genomic coordination enrichment
Questions How can genomic regions such as peaks be mapped to candidate genes before functional enrichment?
Input Annotated genomic regions, a genome/annotation configuration, and a region-to-gene mapping rule such as nearest, host, promoter, or flanking genes.
Methods Region annotation and seq2gene() many-to-many coordination, followed by standard ORA, GSEA, comparison, or visualization workflows.
Main functions annotatePeak(), seq2gene(), and downstream enrichGO() / enricher() / GSEA() after constructing gene-level inputs.
Output Region annotations, candidate gene mappings, and downstream enrichResult / gseaResult objects.
Main limitations Region-to-gene assignment is an inference, not proof of regulation. Results depend on genome build, annotation release, promoter/flanking parameters, and many-to-many mapping choices.

After mapping regions to genes, return to the shared data contract and choose an enrichment route. Preserve the genome build, annotation release, and mapping rule with the result.

17.2 Annotating peaks

annotatePeak() places every peak in its genomic context relative to transcript start sites. The example uses the peak table bundled with ChIPseeker and TxDb.Hsapiens.UCSC.hg19.knownGene, so it runs offline against a fixed annotation release rather than a downloaded one:

suppressPackageStartupMessages({
    library(ChIPseeker)
    library(GenomicRanges)
    library(TxDb.Hsapiens.UCSC.hg19.knownGene)
})

peak_file <- system.file("extdata", "sample_peaks.txt", package = "ChIPseeker")
peaks <- read.delim(peak_file, stringsAsFactors = FALSE)
peaks <- makeGRangesFromDataFrame(
    peaks,
    seqnames.field = "chr", start.field = "start", end.field = "end",
    keep.extra.columns = TRUE
)
peaks
GRanges object with 150 ranges and 6 metadata columns:
        seqnames              ranges strand |    length    summit      tags
           <Rle>           <IRanges>  <Rle> | <integer> <integer> <integer>
    [1]    chr10 105137980-105138593      * |       614       174         7
    [2]    chr10   42644416-42645383      * |       968       669        27
    [3]     chr6 162188189-162188742      * |       554       174         8
    [4]     chr4     2307246-2307622      * |       377       188         9
    [5]    chr13   51791808-51792240      * |       433       267         8
    ...      ...                 ...    ... .       ...       ...       ...
  [146]    chr12   80194469-80194868      * |       400       200         8
  [147]    chr11   26134912-26135581      * |       670       472        12
  [148]     chr9   88771969-88772469      * |       501       371         8
  [149]     chr8 146115421-146115783      * |       363       182         7
  [150]     chr6 140093330-140093770      * |       441       169         7
        X.10.log10.pvalue. fold_enrichment    FDR...
                 <numeric>       <numeric> <numeric>
    [1]              52.80           15.32        NA
    [2]              77.15            9.13      0.79
    [3]              59.88           17.82        NA
    [4]              85.25           26.62      1.03
    [5]              65.32           15.08      0.74
    ...                ...             ...       ...
  [146]              81.06           29.05      0.84
  [147]             103.04           23.58      1.57
  [148]              50.17           13.79        NA
  [149]              65.63           22.99        NA
  [150]              54.45           15.08      0.86
  -------
  seqinfo: 23 sequences from an unspecified genome; no seqlengths

The printed GRanges object confirms the chromosome style (chr10) and the six metadata columns carried over from the narrowPeak-style table. That chromosome naming must agree with the naming style of the TxDb used downstream: a GRanges holding hg19 coordinates annotated against an hg38 TxDb produces the wrong features rather than an error, so record the genome build with the result.

anno <- annotatePeak(peaks,
                     tssRegion = c(-3000, 3000),
                     TxDb = TxDb.Hsapiens.UCSC.hg19.knownGene)

annotatePeak() prints a progress banner while it works, and that banner carries the build timestamp, so it is suppressed here (output: false) and the resulting summary is shown on its own:

Annotated peaks generated by ChIPseeker
150/150  peaks were annotated
Genomic Annotation Summary:
             Feature  Frequency
8   Promoter (<=1kb) 11.3333333
9   Promoter (1-2kb)  4.0000000
10  Promoter (2-3kb)  6.0000000
4             5' UTR  0.6666667
3             3' UTR  3.3333333
1           1st Exon  6.0000000
6         Other Exon  7.3333333
2         1st Intron 20.0000000
7       Other Intron 25.3333333
5  Distal Intergenic 16.0000000

The printed summary is the distribution of annotation features relative to tssRegion. as.data.frame() exposes the per-peak call together with the gene responsible for it:

anno_table <- as.data.frame(anno)
head(anno_table[, c("seqnames", "start", "end", "annotation",
                    "geneId", "distanceToTSS")], row.names = FALSE)
  seqnames     start       end
1    chr10 105137980 105138593
2    chr10  42644416  42645383
3     chr6 162188189 162188742
4     chr4   2307246   2307622
5    chr13  51791808  51792240
6    chr19  58731678  58732653
                                                   annotation geneId
1                                                      3' UTR   6877
2 Exon (ENST00000446298.1_3/ENST00000446298.1_3, exon 1 of 1) 441666
3           Intron (ENST00000366898.6_8/5071, intron 7 of 11)   5071
4             Exon (ENST00000290974.7_12/57732, exon 8 of 13)  57732
5                                           Distal Intergenic 220108
6                                           Distal Intergenic  27300
  distanceToTSS
1         -6534
2        203076
3         69356
4        -17804
5         -4230
6         -7307

annotation is a feature label — Promoter (<=1kb), 1st Intron, 3' UTR, Distal Intergenic, and so on — and geneId is the Entrez identifier of the gene carrying that feature. distanceToTSS is signed: a negative value means the peak lies upstream of the transcript start site.

17.3 From regions to genes with seq2gene()

annotatePeak() assigns one label per peak. seq2gene() answers a different question — which genes may be regulated by each region — by collecting the genes whose promoter, gene body, or flanking window overlaps the region. The mapping is many-to-many: one region can link several genes, and several regions can converge on the same gene.

linked <- seq2gene(peaks,
                   tssRegion = c(-1000, 1000),
                   flankDistance = 3000,
                   TxDb = TxDb.Hsapiens.UCSC.hg19.knownGene)
[1] 152
[1] "ENST00000692195.6877"                   
[2] "ENST00000446298.1_3/ENST00000446298.1_3"
[3] "ENST00000290974.57732"                  
[4] "ENST00000583708.6_2/ENST00000583708.6_2"
[5] "ENST00000828487.5382"                   
[6] "ENST00000710813.4176"                   

Check what that vector actually contains before feeding it anywhere. Its documented return value is a gene vector, and here only 12 of 152 entries are bare Entrez identifiers; the rest are composite transcriptId.geneId labels inherited from the TxDb’s gene naming. Splitting them with a pattern such as sub(".*[._]", "", x) looks reasonable and is wrong — on ENST00000446298.1_3 it returns "3", and those bogus values are simply not found during ID lookup, so enrichment quietly runs on a shrunk gene set.

The robust identifier source is annotatePeak()’s geneId column, which is numeric Entrez by construction. Use seq2gene() for region-to-gene connectivity — how many genes a region could reach, and which regions share a gene — and take the identifier list from the annotation table.

region_genes <- as.character(unique(as.data.frame(anno)$geneId))
region_genes <- region_genes[grepl("^[0-9]+$", region_genes)]
length(peaks)
[1] 150
length(linked)
[1] 152
length(region_genes)
[1] 149

Read those three numbers together: 150 input peaks produce 152 region-to-gene links under the chosen window, and the annotated peaks resolve to 149 distinct candidate genes. The distinct count can be smaller than the peak count because several peaks may map to the same gene — here 1 peak(s) are extra assignments to an already-counted gene (150 peaks → 149 genes). A peak is dropped only when its window reaches no annotated gene at all and its geneId is NA; in this example 0 peaks have an NA geneId, including the 24 Distal Intergenic peaks, which annotatePeak() assigns to their nearest flanking gene. Report both the deduplication and any truly unassigned peaks before interpreting the result.

tssRegion and flankDistance are the decisions that change the gene set most: widening them recruits more genes and pulls the signal toward broadly annotated housekeeping terms. Record both, plus the genome build, with the result.

17.4 Enriching the mapped genes

Once regions have become a character vector of identifiers, nothing downstream is specific to ChIP-Seq. The vector enters the same engine and knowledge connector used anywhere else in the book:

suppressPackageStartupMessages({
    library(clusterProfiler)
    library(org.Hs.eg.db)
})

go_bp <- enrichGO(gene = region_genes,
                  OrgDb = org.Hs.eg.db,
                  ont = "BP",
                  pvalueCutoff = 0.05,
                  readable = TRUE)
nrow(as.data.frame(go_bp))
[1] 0

This returns 0 terms, and that is the honest output rather than a broken pipeline: the peak table bundled with ChIPseeker is synthetic, not a transcription-factor experiment, so there is no biological theme in it to recover. Even with the thresholds loosened to pvalueCutoff = 1, the best adjusted p-value reachable from these peaks is roughly 0.42 — nowhere near interpretable. Everything above still ran end to end, the object is a normal enrichResult, and every downstream route from the data contract (simplify(), dotplot(), compareCluster()) applies once you point this chain at your own peak set.

An empty ORA result is a diagnostic, not a dead end. Before re-checking the identifiers, look at the two decisions this chapter owns: the tssRegion / flankDistance window, and how many peaks survived to contribute a geneId. A background that is too wide, or a window that silently drops most regions, both empty the result table without raising an error.

Region-to-gene assignment is an inference. A peak inside a gene body, or in the neighbouring intergenic window, does not establish that the gene is regulated by the assayed factor. Report how many regions produced how many candidate genes before interpreting any term.

17.5 Next steps

References

Yu, Guangchuang, Li-Gen Wang, and Qing-Yu He. 2015. “ChIPseeker: An r/Bioconductor Package for ChIP Peak Annotation, Comparison and Visualization.” Bioinformatics 31 (14): 2382–83. https://doi.org/10.1093/bioinformatics/btv145.