Assigning a direction to CpG sites

The code below is evaluated only when the package’s data layers are available – bundled, via options(cpgdirection.data_dir = ), or through the cpgdirectionData package. The text and code are complete either way.

The problem

An epigenome-wide study gives you CpG sites. It rarely gives you matched RNA, so for most sites you do not know whether higher methylation goes with higher or lower expression of the nearby gene. That sign is not fixed: methylation is not uniformly repressive, and pooled estimates across studies can average to zero simply because opposing signs cancel.

This package assigns the sign, and tells you how much to trust it.

One call

library(cpgdirection)
#> cpgdirection 2.99.5
#>   cpg_expression_direction(my_cpgs)   all methods; see best_direction
#>   cpgd_selftest()                     verify the installation
#>   Predictions are conditional on the CpG being an eQTM; see ?cpg_expression_direction
my_cpgs <- c("Zcg00335286_TC21_MC2R", "cg02079741_TC21_POMC", "cg00000029")
res <- cpg_expression_direction(my_cpgs, universal = FALSE, verbose = FALSE)
#> see ?cpgdirectionData and browseVignettes('cpgdirectionData') for documentation
#> Error while performing HEAD request.
#>    Proceeding without cache information.
#> loading from cache
#> see ?cpgdirectionData and browseVignettes('cpgdirectionData') for documentation
#> downloading 1 resources
#> retrieving 1 resource
#> loading from cache
#> see ?cpgdirectionData and browseVignettes('cpgdirectionData') for documentation
#> downloading 1 resources
#> retrieving 1 resource
#> loading from cache
#> see ?cpgdirectionData and browseVignettes('cpgdirectionData') for documentation
#> loading from cache
#> Error while performing HEAD request.
#>    Proceeding without cache information.
res[, c("cpg_id", "best_direction", "best_evidence"), with = FALSE]
#> 
#> cpgdirection: 3 CpGs, all methods
#> 
#>   best_direction came from:
#>     catalogue_consensus     1
#>     catalogue_single        1
#>     no_evidence             1
#> 
#>   direction assigned : 2 of 3 (67%)
#>     +1 0    -1 2
#> 
#>   1 CpGs have no direction. To fill them from a tissue you name
#>   in advance (on biology, before seeing the answers):
#>     cpg_expression_direction(my_cpgs, target_tissue = "solid_tissue")
#> 
#>   Read best_evidence alongside best_direction: measured is worth ~1.00,
#>   distance_only ~0.62. All of it is conditional on the CpG being an eQTM.
#> 
#>       cpg_id best_direction       best_evidence
#> 1 cg00000029             -1    catalogue_single
#> 2 cg00335286             NA         no_evidence
#> 3 cg02079741             -1 catalogue_consensus

my_cpgs can be a character vector, a file path or a data frame column. Identifiers carrying array suffixes or analysis prefixes are reduced to their canonical form automatically, so Zcg00335286_TC21_MC2R is understood, and the gene embedded in the name is used as the target. (universal = FALSE skips the distance-only fallback layer here, which needs the Bioconductor gene annotation packages on first use.)

The two columns that matter

column meaning
best_direction +1, -1, or NA where the evidence does not support a call
best_evidence which layer supplied it

Always read them together. A direction worth 0.96 and a direction worth 0.62 are both a +1. best_expected_accuracy carries the validated figure.

best_evidence accuracy what it means
measured ~1.00 a catalogue measured this exact pair
smr_high 0.96 Mendelian randomisation, several instruments agreeing
catalogue_consensus 0.77–0.87 genomic-context models, tissues agreeing
smr_moderate 0.85 Mendelian randomisation, one instrument
catalogue_single 0.55–0.87 one tissue only
smr_weak 0.70 instruments disagree; a warning, not evidence
distance_only 0.60–0.65 distance to the TSS, nothing else
no_evidence — nothing available; a correct answer, not a failure

Every target gene, one row per pair

A CpG is not obliged to regulate one gene. cpg_gene_pairs() discovers every supported target automatically – bare identifiers suffice – and resolves direction separately for every CpG x gene pair, excluding probes flagged as unreliable (cross-hybridizing or degenerately mapped) by default:

p <- cpg_gene_pairs(c("cg26261055", "cg06495038"), verbose = FALSE)
#> see ?cpgdirectionData and browseVignettes('cpgdirectionData') for documentation
#> loading from cache
#> Building the gene TSS annotation from TxDb (once per machine)...
#>   24 genes were dropped because they have exons located on both strands of the
#>   same reference sequence or on more than one reference sequence, so cannot be
#>   represented by a single genomic range.
#>   Use 'single.strand.genes.only=FALSE' to get all the genes in a GRangesList
#>   object, or use suppressMessages() to suppress this message.
#> 'select()' returned 1:1 mapping between keys and columns
#> wrote /tmp/RtmpHvxjA0/file15657c774c4b.csv (28617 genes)
#> cached 28617 genes at /github/home/.cache/R/cpgdirection/gene_tss_hg19.csv.gz
#> see ?cpgdirectionData and browseVignettes('cpgdirectionData') for documentation
#> downloading 1 resources
#> retrieving 1 resource
#> loading from cache
#> Error while performing HEAD request.
#>    Proceeding without cache information.
p[, c("cpg_id", "target_gene", "mapping_primary", "best_direction",
      "best_evidence", "usable"), with = FALSE]
#> 
#> cpgdirection automatic CpG-gene discovery
#> 
#>   submitted inputs:                     2
#>   unique canonical CpGs:                2
#> 
#>   target discovery (candidate pairs per source):
#>     EPIC-v2 manifest targets:           2
#>     tissue lookup targets:              2
#>     measured eQTM targets:              0
#>     SMR targets:                        1
#> 
#>   CpGs with >=1 target:                 2
#>   CpGs with >1 target/co-effect:        2
#>   total unique CpG-gene pairs:          0
#> 
#>   pair-level direction:
#>     usable:                             3
#>     abstain/no direction:               2
#> 
#>   best_evidence:
#>     distance_only                   2
#>     distance_tissue_conflict        2
#>     smr_moderate                    1
#> 
#>   probe QC (mask / position verification):
#>     excluded as unreliable:             0 CpGs
#> 
#>   Mapping provenance (mapping_sources) says how a gene entered the
#>   candidate set; best_evidence says what the direction rests on. They
#>   are different facts and are kept apart. A CpG can carry opposite
#>   directions for different genes; read pairs, not CpGs.
#> 
#>       cpg_id target_gene mapping_primary best_direction
#> 1 cg26261055       CRHBP EPICv2_manifest             -1
#> 2 cg26261055       S100Z   tissue_lookup             NA
#> 3 cg06495038       CRHBP EPICv2_manifest             -1
#> 4 cg06495038       F2RL1             SMR             -1
#> 5 cg06495038       S100Z   tissue_lookup             NA
#>              best_evidence usable
#> 1            distance_only   TRUE
#> 2 distance_tissue_conflict  FALSE
#> 3            distance_only   TRUE
#> 4             smr_moderate   TRUE
#> 5 distance_tissue_conflict  FALSE

mapping_sources records how each gene entered the candidate set (manifest annotation, catalogue, measured eQTM, SMR); best_evidence records what the direction rests on. They are different facts and are kept apart.

Abstention is deliberate

Where the evidence is insufficient the package declines rather than guessing. That is why accuracy is high on the sites it does call. If you need a complete column, name the tissue your question is about:

res <- cpg_expression_direction(my_cpgs, target_tissue = "solid_tissue")

Declare that tissue on biological grounds before looking at the answers. Choosing the tissue that gives the result you wanted is selecting on the outcome.

Checking a result that looks one-sided

cpgd_direction_balance(res)
#> 
#> cpgdirection: balance of directions in blood
#>   predicted in the shipped table : 26.2% +1
#>   measured (ground truth)        : 39.5% +1
#> 
#>   The model predicts +1 less often than it truly occurs (26.2% vs 39.5%).
#>   It shrinks towards the majority class, so a +1 is conservative and a
#>   -1 carries less weight than its count suggests.
#> 
#>   your run: 2 catalogue-derived calls
#>     +1 0    -1 2    (0.0% +1)
#>     exact binomial vs the 26.2% base rate: p = 1
#>     -> consistent with the base rate; no anomaly to explain.

Inverse associations are the majority everywhere, so a mostly--1 result is expected. This tests whether yours is more one-sided than the base rate explains.

The limitation that governs everything

Every accuracy figure is conditional on the CpG being an eQTM at all. The catalogues behind the models contain only significant associations, so nothing here tells you whether a CpG affects expression. Feed it a site with no real effect and you will get a confident direction for something that is not there.

Session info

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
#>  [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
#>  [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
#>  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
#>  [9] LC_ADDRESS=C               LC_TELEPHONE=C            
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       
#> 
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] cpgdirection_2.99.5     cpgdirectionData_0.99.1 ExperimentHub_3.3.2    
#> [4] AnnotationHub_4.3.2     BiocFileCache_3.3.0     dbplyr_2.6.0           
#> [7] BiocGenerics_0.59.12    generics_0.1.4          rmarkdown_2.32         
#> 
#> loaded via a namespace (and not attached):
#>  [1] KEGGREST_1.53.6                         
#>  [2] httr2_1.3.0                             
#>  [3] rjson_0.2.23                            
#>  [4] xfun_0.61                               
#>  [5] bslib_0.12.0                            
#>  [6] Biobase_2.73.2                          
#>  [7] lattice_0.23-1                          
#>  [8] vctrs_0.7.3                             
#>  [9] tools_4.6.1                             
#> [10] bitops_1.1-0                            
#> [11] stats4_4.6.1                            
#> [12] curl_8.0.0                              
#> [13] parallel_4.6.1                          
#> [14] tibble_3.3.1                            
#> [15] AnnotationDbi_1.75.2                    
#> [16] RSQLite_3.53.3                          
#> [17] blob_1.3.0                              
#> [18] pkgconfig_2.0.3                         
#> [19] BiocBaseUtils_1.15.1                    
#> [20] Matrix_1.7-6                            
#> [21] data.table_1.18.6.1                     
#> [22] S4Vectors_0.51.10                       
#> [23] cigarillo_1.3.1                         
#> [24] lifecycle_1.0.5                         
#> [25] compiler_4.6.1                          
#> [26] Rsamtools_2.29.0                        
#> [27] Biostrings_2.81.9                       
#> [28] Seqinfo_1.3.2                           
#> [29] codetools_0.2-20                        
#> [30] htmltools_0.5.9                         
#> [31] sys_3.4.3                               
#> [32] buildtools_1.0.0                        
#> [33] sass_0.4.10                             
#> [34] RCurl_1.98-1.20                         
#> [35] yaml_2.3.12                             
#> [36] TxDb.Hsapiens.UCSC.hg19.knownGene_3.22.1
#> [37] pillar_1.11.1                           
#> [38] crayon_1.5.3                            
#> [39] jquerylib_0.1.4                         
#> [40] BiocParallel_1.47.0                     
#> [41] cachem_1.1.0                            
#> [42] org.Hs.eg.db_3.23.1                     
#> [43] tidyselect_1.2.1                        
#> [44] digest_0.6.39                           
#> [45] purrr_1.2.2                             
#> [46] dplyr_1.2.1                             
#> [47] BiocVersion_3.24.0                      
#> [48] restfulr_0.0.17                         
#> [49] maketools_1.3.2                         
#> [50] fastmap_1.2.0                           
#> [51] grid_4.6.1                              
#> [52] cli_3.6.6                               
#> [53] magrittr_2.0.5                          
#> [54] GenomicFeatures_1.65.0                  
#> [55] XML_3.99-0.25                           
#> [56] withr_3.0.3                             
#> [57] rappdirs_0.3.4                          
#> [58] filelock_1.0.3                          
#> [59] bit64_4.8.6                             
#> [60] XVector_0.53.0                          
#> [61] httr_1.4.9                              
#> [62] bit_4.6.0                               
#> [63] otel_0.2.0                              
#> [64] png_0.1-9                               
#> [65] memoise_2.0.1                           
#> [66] evaluate_1.0.5                          
#> [67] knitr_1.52                              
#> [68] GenomicRanges_1.65.4                    
#> [69] IRanges_2.47.5                          
#> [70] BiocIO_1.23.3                           
#> [71] rtracklayer_1.73.0                      
#> [72] rlang_1.3.0                             
#> [73] glue_1.8.1                              
#> [74] DBI_1.3.0                               
#> [75] BiocManager_1.30.27                     
#> [76] jsonlite_2.0.0                          
#> [77] R6_2.6.1                                
#> [78] GenomicAlignments_1.49.2