Epigenomic domains are often summarized by signal enrichment, yet signal magnitude is only one aspect of domain organization. A focal, high-amplitude H3K27ac region and a broad, moderately enriched region may carry similar integrated signal while having different signal geometries. Likewise, broad H3K4me3 or H3K27me3 domains can change in measured breadth without a proportional change in signal. A single ranking statistic can obscure these patterns. Their biological meaning requires mark- and experiment-specific validation.
epiPortrait represents these measurements separately. It
distinguishes integrated signal magnitude from
replicate-specific native domain breadth, retains
within-domain signal dispersion as a complementary structural
descriptor, and aggregates evidence only after each biological replicate
has been evaluated independently.
The resulting domain portrait is not intended to replace peak calling or differential-enrichment statistics. It provides a downstream phenotype layer for asking whether a domain is predominantly intensity-extreme, breadth-extreme, jointly extreme, or undergoing a transition between these states.
The design of epiPortrait rests on five principles:
| # | Principle |
|---|---|
| 1 | Shared coordinates are for comparison, not Breadth truth. The consensus candidate-domain universe is a comparison frame; it never defines a sample’s breadth. |
| 2 | Breadth comes from replicate-specific native geometry. Each replicate’s own peak/domain calls carry the breadth evidence. |
| 3 | Intensity and Breadth are classified separately. They are not merged into one rank, but they may remain correlated because integrated signal depends partly on interval length. |
| 4 | Replicate evidence is retained before group
aggregation. Per-replicate calls are aggregated with explicit
support rules; Uncertain is an abstention when evidence is
insufficient. |
| 5 | Biological interpretation depends on the histone mark. The calculations are shared across marks, but the labels do not imply the same biology for active and repressive marks. |
Across conditions, a domain’s architecture state can remodel in distinct directions:
| Ref | Target | Interpretation |
|---|---|---|
| Typical | Intensity-Super | acquisition of a relative intensity-extreme state |
| Typical | Breadth-Super | acquisition of a relative broad state |
| Typical | Dual-Super | acquisition of both relative states |
| Intensity-Super | Dual-Super | additional broad-state call |
| Breadth-Super | Dual-Super | additional intensity-state call |
| Breadth-Super | Typical | loss of the relative broad-state call |
The default cutoff_scope = "relative" transition
expresses a relative rank-state change, not absolute biochemical
gain/loss (see Module 4).
epiPortrait distinguishes the shared coordinate
frame (the consensus candidate-domain universe, one interval
per domain across all samples) from the sample-specific native
peak geometry (each replicate’s own peak/domain calls). The
Breadth-Super call is derived from native peak geometry, not from the
width of the shared consensus interval.
| Tool / family | Primary question | Where epiPortrait differs |
|---|---|---|
| MACS2 / SICER / epic2 | Where are enriched peaks/domains? | epiPortrait starts after domain calling |
| DiffBind / csaw | Where does enrichment differ statistically? | epiPortrait describes domain phenotype and remodeling mode |
| ROSE | Which stitched enhancers are signal-extreme? | epiPortrait separates Intensity from native Breadth and adds replicate support |
| ChIPseeker | Where is a peak relative to genes/features? | epiPortrait uses annotation after quantitative phenotyping |
| deepTools | How do signal tracks look across regions/samples? | epiPortrait converts track measurements into domain-level phenotypes |
epiPortrait complements these tools as a replicate-aware
quantitative domain-phenotyping layer (Intensity ×
native Breadth decomposition, plus within-domain dispersion), rather
than a peak caller or count-based differential method. The primary use
case is histone-mark ChIP-seq or CUT&Tag; continuous-track workflows
also apply to ATAC-seq-derived domains.
Because the input is normalized continuous signal (BigWig) rather
than raw alignments, epiPortrait does not
perform read alignment, duplicate removal, or count-based differential
binding. It is a downstream domain architecture analysis tool.
if (!require("BiocManager", quietly = TRUE)) install.packages("BiocManager")
BiocManager::install(c(
"GenomicRanges", "SummarizedExperiment", "GenomicFeatures",
"org.Hs.eg.db", "org.Mm.eg.db"
))
if (!requireNamespace("remotes", quietly = TRUE)) install.packages("remotes")
remotes::install_github("zying106/epiPortrait", build_vignettes = TRUE)epiPortrait ships a simulated
SummarizedExperiment (example_se) with 500
domains across 6 samples (3 Control, 3 Treatment). Its assays contain
Intensity, SignalDispersion, NativeMaxPeakWidth, NativeOccupiedWidth,
and NativePeakCount. The canonical Breadth call is derived from
replicate-specific native peak widths rather than from a single
domain-level assay.
## class: RangedSummarizedExperiment
## dim: 500 6
## metadata(4): native_peaks native_peak_available feature_definitions
## simulation_provenance
## assays(5): Intensity SignalDispersion NativeMaxPeakWidth
## NativeOccupiedWidth NativePeakCount
## rownames(500): epiDomain_000001 epiDomain_000002 ... epiDomain_000499
## epiDomain_000500
## rowData names(2): Simulation_Scenario IntervalWidth
## colnames(6): Control_1 Control_2 ... Treatment_2 Treatment_3
## colData names(5): SampleID Condition BioReplicate bw_path peak_path
# Normalization is off by default: input BigWigs should already be
# quantitatively comparable (CPM/RPGC/spike-in). Post-hoc TotalSignal
# rescaling would assume conserved total signal and may remove real global
# biological shifts.
se <- normalize_portrait(example_se, method = "None")
# Main analysis for a multi-condition study: per_group mode, default elbow
# inflection and default log convention.
se <- call_super_domains(se, feature = "Intensity",
mode = "per_group", group_var = "Condition",
verbose = FALSE)
table(rowData(se)$Intensity_Call__Control)##
## Intensity_Super_Element Intensity_Typical
## 50 450
The rest of this vignette runs on a tiny synthetic BigWig dataset
shipped in inst/extdata (4 samples: 2 Control, 2 Treatment;
3 domains on chr21), which exercises the real BigWig I/O and
portrait-matrix construction path. Subsequent inflection-based calling
is demonstrated on the 500-domain example_se, because three
domains do not provide an informative ranked distribution for cutoff
estimation.
Two different “peak files” are involved — do not confuse them.
consensus_peaks, one shared set for all samples) defines
which regions you analyze. It comes from your peak caller’s
bed/narrowPeak output (MACS2, SICER, epic2, …), the same upstream input
ROSE consumes. Every analysis needs this — including
single-sample Intensity-only.
consensus_peaks is that
sample’s own peak file.get_consensus_peaks().GRanges instead.peak_path,
one file per sample) are each sample’s own peak calls, used to
measure native enrichment width per sample. Only the
Breadth axis needs this; Intensity-only analyses
use the candidate universe alone and omit peak_path.Bottom line: peak files are always required (to build the candidate universe) — the only difference is how many and what for. Intensity-only uses them to define the domains; Breadth additionally uses each sample’s own peaks for width measurement. There is no analysis mode that runs from BigWig alone.
if (use_bw) {
peak_list <- list(
rep1 = rtracklayer::import(system.file("extdata", "peaks.bed", package = "epiPortrait")),
rep2 = rtracklayer::import(system.file("extdata", "peaks.bed", package = "epiPortrait"))
)
consensus_peaks <- get_consensus_peaks(peak_list, min_reps = 1)
} else {
consensus_peaks <- rowRanges(example_se)
}
consensus_peaks## GRanges object with 3 ranges and 1 metadata column:
## seqnames ranges strand | Support_Reps
## <Rle> <IRanges> <Rle> | <numeric>
## [1] chr21 43999000-44002000 * | 2
## [2] chr21 44019000-44023000 * | 2
## [3] chr21 44029000-44044000 * | 2
## -------
## seqinfo: 1 sequence from an unspecified genome; no seqlengths
if (use_bw) {
# For super-enhancer analysis, 12.5 kb is the ROSE-standard stitching distance
macro_domains <- stitch_epi_peaks(consensus_peaks, stitch_distance = 12500)
} else {
macro_domains <- consensus_peaks
}
length(macro_domains)## [1] 2
if (use_bw) {
extdata <- system.file("extdata", package = "epiPortrait")
samples <- data.frame(
SampleID = c("C1", "C2", "T1", "T2"),
Condition = c("Control", "Control", "Treatment", "Treatment"),
Replicate = c(1, 2, 1, 2),
bw_path = file.path(extdata, c("C1.bw", "C2.bw", "T1.bw", "T2.bw")),
peak_path = file.path(extdata, c("C1_peaks.bed", "C2_peaks.bed",
"T1_peaks.bed", "T2_peaks.bed"))
)
se <- build_portrait_matrix(
sample_sheet = samples,
consensus_peaks = macro_domains,
workers = 1
)
} else {
se <- example_se
}
se## class: RangedSummarizedExperiment
## dim: 2 4
## metadata(6): signal_contract domain_provenance ...
## native_peak_available provenance
## assays(5): Intensity SignalDispersion NativeMaxPeakWidth
## NativeOccupiedWidth NativePeakCount
## rownames(2): epiDomain_000001 epiDomain_000002
## rowData names(2): Constituent_Peaks IntervalWidth
## colnames(4): C1 C2 T1 T2
## colData names(6): SampleID Condition ... peak_path NegativeFraction
The output SummarizedExperiment contains the core assays
Intensity and SignalDispersion, plus — when
per-sample native peak files are provided via peak_path —
the sample-specific breadth geometry (NativeMaxPeakWidth,
NativeOccupiedWidth, NativePeakCount). The
static genomic interval length is stored in
rowData(se)$IntervalWidth (not an assay — it is identical
across samples).
peak_path is optional. Without it,
native breadth geometry and canonical Breadth-Super calling are
unavailable; Intensity-Super remains available, and
SignalDispersion can still be explored as a secondary
architecture descriptor. When peak_path is supplied, the
imported native peaks are retained in
metadata(se)$native_peaks so that peak-level breadth calls
can be inspected.
For your own data, replace the sample sheet paths with your real
normalized BigWigs (and native peak files), and
consensus_peaks with your consensus peak set.
You can extend the portrait with arbitrary per-domain statistics. Example pattern for real BigWig inputs:
| Method | Use case |
|---|---|
None (default) |
Recommended — input BigWigs already quantitatively comparable (CPM/RPGC/spike-in) |
TotalSignal |
Rescale to mean domain-set total signal; assumes conserved total signal and may remove genuine global biological shifts |
Quantile |
Force identical distributions across samples (limma); not recommended for between-condition comparison |
TMM |
Count-based scaling (force_TMM = TRUE for
continuous signal); advanced |
Row-wise Z-score is intentionally not an analysis normalization method in epiPortrait: it would destroy the cross-domain magnitude ranking that Super calling relies on. Plotting / PCA layers perform display-only scaling internally.
call_super_domains() ranks domains by a feature and
separates super-domains from typical domains via a hockey-stick
inflection point.
se <- call_super_domains(example_se, feature = "Intensity",
method = "elbow", verbose = FALSE)
table(rowData(se)$Intensity_Domain_Type)##
## Intensity_Super_Element Intensity_Typical
## 50 450
Two heuristics are available via the method
argument:
"elbow" (default): the point with the
maximum perpendicular distance to the first-last ranked line — classic
knee detection."tangent": a ROSE-inspired
tangent-optimization inflection (Whyte et al.,
2013). It slides a line whose slope is (max - min)/n
and minimizes the number of points below it. Note that this is a
geometric variant of ROSE’s calculate_cutoff() step, not a
bit-exact reproduction; it is only an inflection step, not the full ROSE
pipeline.se_elbow <- call_super_domains(example_se, feature = "Intensity",
method = "elbow", verbose = FALSE)
se_tangent <- call_super_domains(example_se, feature = "Intensity",
method = "tangent", verbose = FALSE)
table(rowData(se_tangent)$Intensity_Domain_Type)##
## Intensity_Super_Element Intensity_Typical
## 50 450
Explicit top-fraction cutoff
(quantile_cutoff). Both axes offer an explicit
top-fraction alternative that bypasses inflection detection: for
Intensity it is the given quantile of the ranked domain
values; for Breadth it is the same quantile of each
replicate’s genome-wide eligible native PeakWidth distribution, so the
top 100*(1-quantile_cutoff)% widest native peaks are Broad
(e.g. 0.95 = top 5% — a common broad-domain practice). This
option does not assess cutoff quality. It never
automatically falls back to, or from, the data-driven inflection path,
so the no-call semantics of the default path are unchanged.
By default Intensity and SignalDispersion
use the package’s automatic log10(x + 1) ranking transform
(log_transform = NULL, auto per feature).
Breadth uses the native peak-width calling pipeline
described below, and the static IntervalWidth is not
auto-log-transformed. Set log_transform = FALSE to rank on
the raw scale, or log_transform = TRUE to force it.
Optional H3K27ac benchmark against ROSE. If you plan
a head-to-head comparison with the original ROSE method (Whyte et al.,
2013), use method = "tangent" with
log_transform = FALSE (raw signal, ROSE-style scale).
Because the tangent implementation is a geometric variant rather than a
bit-exact port, validate any ROSE-reproduction numbers against the
reference calculate_cutoff() before drawing quantitative
conclusions. This is an optional benchmark setting, not the package’s
primary workflow. The log-transformed default is retained as the
epiPortrait convention because it stabilises the skewed intensity
distribution for the elbow heuristic and downstream analysis.
All Super labels are relative to the candidate-domain
universe and selected cutoff. They should not be interpreted as
functional super-enhancers without independent evidence. Intensity is
the area under the signal track, so it can increase with signal
amplitude, interval length, or both.
mode = "global_consensus" (default; alias
"consensus"): calls each sample independently, computes
replicate support per condition group, and requires the support rule to
be met in EVERY group (persistent consensus).mode = "per_group" (recommended for multi-condition
studies): each replicate is called independently using its own ranked
feature distribution; replicate-level calls are then aggregated within
each condition using the selected support rule. One call column per
group. The per-domain x per-replicate call matrix is available via
get_replicate_calls(). Group-mean ranks are stored for
visualization only and never determine the group call.mode = "per_sample": calls each sample
independently.se_pg <- call_super_domains(example_se, feature = "Intensity",
mode = "per_group", group_var = "Condition",
verbose = FALSE)
grep("Intensity_Call__", colnames(SummarizedExperiment::rowData(se_pg)), value = TRUE)## [1] "Intensity_Call__Control" "Intensity_Call__Treatment"
Breadth is the peak-level breadth feature. It does
not rank a domain x sample assay: instead each
replicate’s genome-wide eligible native peak width distribution is cut
by an elbow/inflection, broad peaks are mapped to the shared domains by
unique assignment, and the evidence is aggregated across replicates:
se_b <- call_super_domains(example_se, feature = "Breadth",
mode = "per_group", group_var = "Condition",
verbose = FALSE)
table(rowData(se_b)$Breadth_Call__Control)##
## Breadth_Super_Element Breadth_Typical
## 54 440
Key parameters for feature = "Breadth":
min_peak_overlap_fraction (default 0.5): a
native peak is assigned to at most one shared domain (the one with
maximum overlap) only if at least this fraction of its extent lies
inside it; peaks below the threshold are Unmapped, and tied
maxima are Ambiguous — preventing a broad peak from
double-counting into neighbouring domains.valid_chroms: optional vector of allowed chromosomes
for the genome-wide eligible native peak set.The peak-level call (PeakWidth / PeakBroadCall / PeakRank) is stored
in metadata(se)$breadth_peak_calls, and the
unique-assignment provenance in
metadata(se)$breadth_peak_mapping.
IntervalWidth (alias Width) remains
available for static region-level broadness ranking only — it is
constant across samples and never defines a sample-specific breadth.
Combine Intensity and Breadth calls into the four-class taxonomy
(plus Uncertain for domains where a call was not
reliable):
se <- call_super_domains(example_se, feature = "Intensity",
mode = "per_group", group_var = "Condition",
verbose = FALSE)
se <- call_super_domains(se, feature = "Breadth",
mode = "per_group", group_var = "Condition", verbose = FALSE)
se <- combine_superdomain_calls(se, group_var = "Condition")
table(rowData(se)$Combined_Class__Control)##
## Breadth-Super Dual-Super Intensity-Super Typical Uncertain
## 39 15 34 406 6
compare_superdomains() compares super-domain status
between conditions using mode = "per_group" calls (which
are replicate-aware within each group):
se_t <- call_super_domains(example_se, feature = "Intensity",
mode = "per_group", group_var = "Condition",
verbose = FALSE)
se_t <- compare_superdomains(se_t, group_var = "Condition",
ref_group = "Control", target_group = "Treatment",
feature = "Intensity")
table(rowData(se_t)$Intensity_Relative_Transition)##
## Persistent_Super Persistent_Typical
## 50 450
Transition classes depend on cutoff_scope:
"relative" (default): each group is
called with its own cutoff. The output classes
Relative_Prominence_Up /
Relative_Prominence_Down express a relative
rank-state change and must not be interpreted as
absolute signal gain/loss — a domain whose signal is unchanged can still
switch class if the global background of the treatment shifts the group
cutoff."reference" /
"pooled": a common cutoff is applied to
both groups, producing common-scale state Gain /
Loss labels. These labels still do not establish an
absolute biochemical change and require quantitatively comparable
BigWigs.se_t2 <- compare_superdomains(se_t, group_var = "Condition",
ref_group = "Control", target_group = "Treatment",
feature = "Intensity", cutoff_scope = "reference")
table(rowData(se_t2)$Intensity_Transition__reference)##
## Gain Loss Persistent_Super Persistent_Typical
## 10 12 50 428
Uncertain is assigned when a per-group call was not
reliable (no-call).
se <- annotate_epi_domains(se, genome = "hg38")
# rowData: primary_genomic_context, nearest_tss_gene_id,
# nearest_tss_gene_symbol, nearest_tss_distance_bp,
# promoter_overlap_gene_count, gene_body_overlap_gene_count,
# fully_contained_gene_count
head(SummarizedExperiment::rowData(se)$primary_genomic_context)## [1] "Intergenic" "Intergenic" "Intergenic" "Intergenic" "Intergenic"
## [6] "Intergenic"
# long-format links: metadata(se)$domain_gene_links
candidates <- get_domain_genes(se)
head(candidates)## domain_id gene_id gene_symbol
## 1 epiDomain_000053 102724200 LOC102724200
## 2 epiDomain_000057 105379487 LINC01670
## 3 epiDomain_000076 105379499 LOC105379499
## 4 epiDomain_000098 105372730 LINC01666
## 5 epiDomain_000115 105372733 LOC105372733
## 6 epiDomain_000160 112268283 LOC112268283
## relation_types bedpe_supported
## 1 gene_body_overlap;nearest_tss;promoter_overlap FALSE
## 2 gene_body_overlap;nearest_tss;promoter_overlap FALSE
## 3 nearest_tss;promoter_overlap FALSE
## 4 gene_body_overlap;nearest_tss;promoter_overlap FALSE
## 5 nearest_tss;promoter_overlap FALSE
## 6 nearest_tss;promoter_overlap FALSE
## bedpe_support_count bedpe_contact_score nearest_tss_distance_bp
## 1 0 0 2695
## 2 0 0 2603
## 3 0 0 2096
## 4 0 0 946
## 5 0 0 476
## 6 0 0 2464
## expression_value expression_status expression_rank candidate_priority
## 1 NA <NA> NA 1
## 2 NA <NA> NA 2
## 3 NA <NA> NA 3
## 4 NA <NA> NA 4
## 5 NA <NA> NA 5
## 6 NA <NA> NA 6
se <- call_super_domains(example_se, feature = "Intensity",
method = "tangent", log_transform = FALSE, verbose = FALSE)
plot_hockey_stick(se, feature = "Intensity", label_genes = FALSE)Quantitative per-domain view across conditions: each feature is
faceted with biological replicates as points and group means as open
points; a dashed line connects the group means so the across-condition
direction of change is read directly. When the object carries combined
architecture classes (combine_superdomain_calls()), the
domain’s class in each condition is annotated under the axis, linking
the raw values to its architectural identity:
The plotting functions summarize Intensity versus native breadth, replicate support, and condition-level state transitions:
se_viz <- call_super_domains(example_se, feature = "Intensity",
mode = "per_group", group_var = "Condition",
verbose = FALSE)
se_viz <- call_super_domains(se_viz, feature = "Breadth",
mode = "per_group", group_var = "Condition",
verbose = FALSE)
se_viz <- combine_superdomain_calls(se_viz, group_var = "Condition")
se_viz <- compare_superdomain_classes(se_viz,
ref_group = "Control",
target_group = "Treatment")
plot_domain_landscape(se_viz, group = "Control") # no domain labels by default# label the representative domain of each super class (opt-in)
plot_domain_landscape(se_viz, group = "Control", label_n = 5)# per-group hockey with a descriptive median replicate cutoff
plot_hockey_stick(se_viz, feature = "Intensity", group = "Control",
label_genes = FALSE)The landscape places each domain in the Intensity by native-breadth
plane and colours it by architecture class (pass
label_n > 0 to name the most representative domain of
each super class). The transition matrix expresses a
relative architecture-state change (per-group cutoffs)
— it must not be read as absolute gain/loss. Because non-super domains
usually dominate the total, the class-composition plot defaults to a
focused view of the super classes (with their share of all domains
annotated above each bar); pass focus = "all" for the full
composition.
Save the SummarizedExperiment to preserve the assays,
calls, and provenance. Flat tables and plots can then be regenerated
without repeating BigWig I/O:
f <- tempfile(fileext = ".rds")
saveRDS(se_viz, f)
se2 <- readRDS(f)
# flat tables for Excel / Python / reviewers
outdir <- tempfile("epiPortrait_results_")
dir.create(outdir)
export_epiportrait_results(se2, outdir = outdir)
# a few representative columns (the full table is written to
# epiPortrait_results/domain_results.tsv)
head(get_domain_results(se2, group_var = "Condition")[, c(
"Domain_ID", "chr", "start", "end", "IntervalWidth",
"Intensity_Call__Control", "Breadth_Call__Control",
"Combined_Class__Control", "Combined_Class__Treatment")])## Domain_ID chr start end IntervalWidth
## epiDomain_000001 epiDomain_000001 chr21 1000000 1000999 1000
## epiDomain_000002 epiDomain_000002 chr21 1080000 1080999 1000
## epiDomain_000003 epiDomain_000003 chr21 1160000 1179999 20000
## epiDomain_000004 epiDomain_000004 chr21 1240000 1240999 1000
## epiDomain_000005 epiDomain_000005 chr21 1320000 1320999 1000
## epiDomain_000006 epiDomain_000006 chr21 1400000 1400999 1000
## Intensity_Call__Control Breadth_Call__Control
## epiDomain_000001 Intensity_Typical Breadth_Typical
## epiDomain_000002 Intensity_Typical Breadth_Typical
## epiDomain_000003 Intensity_Typical Breadth_Typical
## epiDomain_000004 Intensity_Typical Breadth_Typical
## epiDomain_000005 Intensity_Typical Breadth_Typical
## epiDomain_000006 Intensity_Typical Breadth_Typical
## Combined_Class__Control Combined_Class__Treatment
## epiDomain_000001 Typical Typical
## epiDomain_000002 Typical Typical
## epiDomain_000003 Typical Typical
## epiDomain_000004 Typical Typical
## epiDomain_000005 Typical Typical
## epiDomain_000006 Typical Typical
prov <- get_call_provenance(se2, "Intensity")
prov[c("method", "log_transform_used", "cutoff", "call_status")]## $method
## [1] "elbow"
##
## $log_transform_used
## [1] TRUE
##
## $cutoff
## [1] NA
##
## $call_status
## [1] NA
Where compare_superdomains() reports relative
state transitions between discrete phenotypes,
analyze_differential_domains() adds a statistical
P-value for a continuous signal change between condition
groups, computed directly on the integrated BigWig intensity with
limma:
se_diff <- analyze_differential_domains(
se_viz, feature = "Intensity",
ref_group = "Control", target_group = "Treatment")
table(rowData(se_diff)$Intensity_DiffStatus) # Gain / Loss / NS##
## NS
## 500
Paired, blocked, and multi-factor designs are supported through
design and a valid design coefficient or contrast:
se_diff <- analyze_differential_domains(
se_diff, feature = "Intensity",
# Patient and Condition must be columns of colData(se_diff)
design = ~ Patient + Condition,
contrast = "ConditionTreatment")The volcano plot can label the top-ranked domains:
Scope caveat. The test uses log-transformed integrated continuous signal, not raw fragment counts. The simple two-group design requires at least two samples per group to estimate residual variance. For count-based inference, obtain per-domain counts and use DiffBind, csaw, edgeR, or DESeq2.
BEDPE contact evidence can be attached two ways. The default is
contact-only: any record linking a domain anchor to a gene promoter
counts toward that domain-gene pair. In many HiChIP pipelines
(e.g. FitHiChIP TMM output), however, the loop set is shared
across conditions and only the per-record score
(contact frequency) varies with the perturbation. Collapsing scores to
binary contact flags then discards the quantitative dynamics. Passing
bedpe_score_col keeps the strength. This small
self-contained example builds a synthetic
SummarizedExperiment over two tiny hg38-like genes, with
two BEDPE records (scores 12.3 and 8.9) bridging domain 1 to the gene-2
promoter:
gids <- c("gene1", "gene2")
gmod <- GRanges(rep("chr1", 4),
IRanges(c(1000, 1000, 20000, 20000),
c(3000, 3000, 26000, 26000)),
strand = rep(c("+", "-"), each = 2))
mcols(gmod)$type <- rep(c("gene", "transcript"), 2)
mcols(gmod)$ID <- c(rbind(gids, paste0(gids, "_tx1")))
mcols(gmod)$Parent <- c(rbind(rep(NA, 2), gids))
txdb <- txdbmaker::makeTxDbFromGRanges(gmod)
se_small <- SummarizedExperiment(
assays = list(Intensity = matrix(1, 3, 2)),
rowRanges = GRanges("chr1", IRanges(c(1500, 21000, 35000), c(5000, 30000, 90000))))
rownames(se_small) <- paste0("epiDomain_", 1:3)
colnames(se_small) <- c("C1", "T1")
bedpe_df <- data.frame(
chrom1 = "chr1", start1 = 1499, end1 = 2500, # inside epiDomain_1
chrom2 = "chr1", start2 = 21999, end2 = 23000, # gene2 promoter anchor
score = c(12.3, 8.9), # two records -> summed, not maxed
stringsAsFactors = FALSE)
se_bp <- annotate_epi_domains(se_small, genome = txdb,
bedpe = bedpe_df, bedpe_score_col = "score")
rowData(se_bp)$bedpe_contact_score # per-domain summed score## [1] 21.2 21.2 0.0
## domain_id gene_id gene_symbol relation_types bedpe_supported
## 1 epiDomain_1 gene2 <NA> bedpe_promoter_contact TRUE
## 2 epiDomain_2 gene1 <NA> bedpe_promoter_contact TRUE
## bedpe_support_count bedpe_contact_score nearest_tss_distance_bp
## 1 2 21.2 NA
## 2 2 21.2 NA
## expression_value expression_status expression_rank candidate_priority
## 1 NA <NA> NA 1
## 2 NA <NA> NA 2
bedpe_score_col accepts a numeric column index (>=
7) or a column name.0; evidence without a
usable score (or a run without bedpe_score_col) →
NA, so “no 3D evidence” and “unscored 3D evidence” stay
distinguishable.get_domain_genes(rank_by = "bedpe_score") ranks
candidate genes by descending contact strength, with evidence tier and
expression as tie-breakers. This ranking does not establish a causal
target gene.compare_superdomain_classes() requires
per-group call columns. In time-course designs with one sample
per condition (e.g. 0 min / 30 min / 4 h / 16 h / 72 h), call in
per_sample mode and use
transition_matrix_per_sample() to tabulate pairwise
transitions. With the simulated example_se the “samples”
are Control_1..3 / Treatment_1..3; a single reference sample
demonstrates the ref-to-target form:
se_ps <- call_super_domains(example_se, feature = "Intensity",
mode = "per_sample", verbose = FALSE)
# ref = one sample (time point); all others are targets
# a real time course would pass e.g. ref = "0m", timepoints = c("0m","30m","4h","16h","72h")
se_ps <- transition_matrix_per_sample(se_ps, feature = "Intensity",
ref = "Control_1",
timepoints = colnames(example_se),
verbose = FALSE)
names(S4Vectors::metadata(se_ps)$transitions) # one entry per (ref, target) pair## [1] "Control_1_vs_Control_2" "Control_1_vs_Control_3"
## [3] "Control_1_vs_Treatment_1" "Control_1_vs_Treatment_2"
## [5] "Control_1_vs_Treatment_3"
## [1] "Persistent_Intensity_Typical" "Persistent_Intensity_Typical"
## [3] "Persistent_Intensity_Typical" "Persistent_Intensity_Typical"
## [5] "Persistent_Intensity_Typical" "Persistent_Intensity_Typical"
Labels match compare_superdomain_classes() semantics:
Persistent_<state>,
<from>_to_<to>, and Uncertain when
either time point has a no-call / unreliable call. Omit ref
to compute all ordered time-point pairs. With one sample per time point
these transitions are descriptive; they do not estimate biological
variability.
The same calculations can be applied across marks, but the biological meaning of the resulting labels changes with the mark.
Broad H3K4me3 domains have been associated with cell-identity genes
and transcriptional consistency (Benayoun et
al., 2014). With epiPortrait, one can test whether
a broad promoter is also intensity-extreme and whether a breadth change
accompanies an intensity change:
Breadth captures broad promoter domains;Intensity captures high-magnitude promoters;Dual-Super identifies promoters that are both broad and
high-magnitude.epiPortrait does not claim to discover
broad H3K4me3; it decomposes this known breadth biology into separable
quantitative remodeling modes.
A high-ranking enhancer domain can have concentrated signal, a broad
native enriched span, or both. Intensity-Super,
Breadth-Super, and Dual-Super express these
enhancer domain phenotypes. For an optional head-to-head comparison with
the ROSE super-enhancer pipeline, use method = "tangent"
with log_transform = FALSE (a geometric variant, not a
bit-exact ROSE reproduction).
For broad repressive marks, “Super” is an internal class label; the
mark-appropriate surface terminology is Intensity-Extreme,
Extended-Domain, and Dual-Extreme. Use the
upstream broad-domain caller (e.g. epic2 / SICER) as the native peak
source, and keep the preset’s stitch_distance = 0 sentinel
(no re-stitching of the caller-defined domains).
Organize your analysis around the biological question rather than the mark:
| Pattern | Design | What epiPortrait reports |
|---|---|---|
| Disease-state remodeling | Normal → Cancer | class transitions and width remodeling |
| Drug / perturbation response | Vehicle → Drug, WT → KO | per-condition domain states and transitions |
| Therapy resistance | Sensitive → Resistant | acquisition / loss of super states |
| Differentiation / lineage | State A → State B | state reorganization along the trajectory |
| Cohort recurrence | Independent patients / cell-line models | cross-sample recurrence of a domain phenotype |
For cohort-style datasets, independent patients provide
population-level recurrence evidence, not repeated measurements of one
biological sample. Report the support denominator and choose the
threshold to match the recurrence question. With one sample per
condition, use mode = "per_sample" rather than making a
group-level support claim.
A SummarizedExperiment object represents one mark. When
an analysis combines H3K27ac Intensity-Super enhancer domains with
H3K4me3 Breadth-Super promoter domains, the objects need not share a
domain universe. Stitching and promoter filtering may differ by mark.
integrate_cross_mark() projects calls from one object onto
overlapping domains in the other:
# se_h3k27ac carries Intensity_Call__<tp>; se_h3k4me3 carries Breadth_Call__<tp>
se_h3k4me3 <- integrate_cross_mark(
se_h3k4me3, se_h3k27ac, # left: promoter domains annotated
call_fmt_a = "Breadth_Call__%s", # by the enhancer-mark calls
call_fmt_b = "Intensity_Call__%s",
mark_a = "Breadth", mark_b = "Intensity",
timepoints = c("0m", "30m", "4h", "16h", "72h"),
aggregate_ov = "any_super") # Super if any overlapping b-domain is Super
rowData(se_h3k4me3)$Intensity_Call__4h # <mark_b>_Call__<tp> added
rowData(se_h3k4me3)$Intensity_NOverlaps__4h # # overlapping H3K27ac domains (0 = outside)aggregate_ov: "any_super" (any overlapping
B domain is super) or "majority" (strict > half of the
non-NA overlapping calls are super).min_overlap_bp: require at least this many overlapping
base pairs.NA for the overlaid
call, flagging the two-universe “boundary” regions
(e.g. promoter-proximal enhancers that the enhancer-mark pipeline’s
ROSE-style promoter filter deliberately excludes).| Claim | Requirement / caveat |
|---|---|
| Intensity (between-condition magnitude) | BigWigs must be quantitatively comparable (CPM/RPGC/spike-in) |
| Breadth | Depends on the upstream native peak/domain caller and boundary quality |
| Dual | A conjunction of the two axes, not a new independent measurement |
| SignalDispersion | Describes spatial signal organization, not physical chromatin conformation |
| Annotation | Nearest / overlapping genes are candidates, not causal targets |
| Cohort support | Independent patients are recurrence evidence, not conventional experimental replicates |
| Transition | Default "relative" scope is a rank-state
change, not absolute biochemical gain/loss |
epiPortrait expects normalized BigWig
files that share the same genome build, bin size, and normalization
strategy (e.g., RPGC-, CPM-, or spike-in-normalized tracks from the same
pipeline). Mixing BigWigs from different sources is risky for
cross-sample comparisons; within-sample ranking and super-domain calling
remain interpretable. Positions with no coverage in a
BigWig are treated as zero signal during Intensity integration, so
domains with coverage gaps are downward-biased; prefer tracks with
complete, consistent coverage across the analyzed region set and check
the ZeroFraction / NegativeFraction QC columns
from check_signal_compatibility().
CPM and RPGC normalize library scale but do not correct a true global change in histone-mark occupancy. Use spike-in controls when absolute global changes are part of the biological claim, or restrict interpretation to relative within-dataset ranks. Native breadth also depends on sequencing depth, peak caller, and caller parameters; keep these choices constant across samples.
get_mark_preset() is a configuration helper: it returns
mark-appropriate defaults (for broad repressive marks,
stitch_distance = 0, i.e. no extra stitching of the already
caller-defined domains, and repressive display terminology) but the core
pipeline is unchanged. A stitch_distance of 0
is a sentinel meaning “do not re-stitch”: only call
stitch_epi_peaks() when the preset returns a positive
distance.
preset <- get_mark_preset("H3K27me3")
# upstream broad-domain caller output imported as GRanges (e.g. epic2/SICER)
domains <- rtracklayer::import("H3K27me3_domains.bed")
# stitch_distance = 0 is a sentinel: keep the upstream caller's geometry
if (preset$stitch_distance > 0) {
domains <- stitch_epi_peaks(domains, stitch_distance = preset$stitch_distance)
}
se <- build_portrait_matrix(sample_sheet, consensus_peaks = domains)
se <- call_super_domains(se, feature = "Intensity", mode = "per_group")
se <- call_super_domains(se, feature = "Breadth", mode = "per_group")
se <- combine_superdomain_calls(se, group_var = "Condition")
# continuous width remodeling (descriptive, not a significance test)
se <- compute_width_transition(se, ref_group = "Control", target_group = "Treatment")
# repressive display terminology (Extended-Domain / Dual-Extreme)
plot_domain_landscape(se, group = "Control", mark = "H3K27me3")The internal classes remain Intensity-Super /
Breadth-Super / Dual-Super; the
mark argument only changes surface labels.
method = "elbow" (default) for the main
analysis; use method = "tangent" with
log_transform = FALSE only for an optional H3K27ac /
ROSE-style benchmark (a geometric variant, not a bit-exact ROSE
reproduction).n_bootstrap (e.g., 200) for a cutoff
stability interval (a resampling-stability diagnostic,
not a classical CI) and success rate. For Intensity the
ranked set is the candidate domains; for Breadth it is each
replicate’s eligible native PeakWidth distribution (a per-replicate
interval is stored in
get_call_provenance(se, "Breadth")$replicates[[s]]).
Bootstrap resampling is performed on the raw scale with the same
transform scale as the main analysis. Its information content shrinks
with the size of the ranked set; at genome-wide scale, check
per-replicate cutoff consistency (and, for Breadth, peak-set
perturbation) instead.mode = "per_group" for
the main analysis, then compare_superdomains(). Use
mode = "global_consensus" (alias "consensus")
only when the goal is to identify domains reproducibly super in
every condition; consensus support is computed per
condition group (condition-aware).support_rule deliberately:
"majority" (default, needs > n/2 replicates) is stricter
than "fraction" 0.5 for n=2 (needs 2/2 vs 1/2).se_boot <- call_super_domains(example_se, feature = "Intensity",
n_bootstrap = 25, seed = 1)
metadata(se_boot)$superdomain_calls$Intensity$cutoff_stability_interval## NULL
| Problem | Likely cause | Fix |
|---|---|---|
Feature 'X' not found |
Feature name typo | Use assayNames(se) /
colnames(rowData(se)) |
Breadth requires native peaks |
feature = "Breadth" without
peak_path in the sample sheet |
Re-run build_portrait_matrix() with
per-sample native peak files |
| Negative-signal error | BigWig contains negative values | Prefer a compatible non-negative track. Use
negative_policy = "clip_zero" only when negative values are
known technical artefacts, and report that choice. |
Constant feature values |
All domains identical for a feature (e.g.,
IntervalWidth in a single-sample set) |
Use a dynamic feature like Intensity |
| Super count unexpectedly high | log-transformed scale + tangent | Try log_transform = FALSE |
No reliable inflection (no_call) |
Curve lacks a clear break | Inspect the hockey-stick plot. If a fixed top fraction
is scientifically justified, set quantile_cutoff and report
it; this option does not assess cutoff quality. The default data-driven
path retains no_call and never switches automatically. |
## 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] stats4 stats graphics grDevices utils datasets methods
## [8] base
##
## other attached packages:
## [1] epiPortrait_0.99.3 SummarizedExperiment_1.43.0
## [3] Biobase_2.73.2 GenomicRanges_1.65.4
## [5] Seqinfo_1.3.2 IRanges_2.47.5
## [7] S4Vectors_0.51.10 BiocGenerics_0.59.12
## [9] generics_0.1.4 MatrixGenerics_1.25.0
## [11] matrixStats_1.5.0 BiocStyle_2.41.0
##
## loaded via a namespace (and not attached):
## [1] DBI_1.3.0
## [2] bitops_1.1-0
## [3] httr2_1.3.0
## [4] biomaRt_2.69.4
## [5] rlang_1.3.0
## [6] magrittr_2.0.5
## [7] otel_0.2.0
## [8] compiler_4.6.1
## [9] RSQLite_3.53.3
## [10] GenomicFeatures_1.65.0
## [11] png_0.1-9
## [12] vctrs_0.7.3
## [13] txdbmaker_1.9.0
## [14] stringr_1.6.0
## [15] pkgconfig_2.0.3
## [16] crayon_1.5.3
## [17] fastmap_1.2.0
## [18] dbplyr_2.6.0
## [19] XVector_0.53.0
## [20] labeling_0.4.3
## [21] Rsamtools_2.29.0
## [22] rmarkdown_2.32
## [23] UCSC.utils_1.9.0
## [24] bit_4.6.0
## [25] xfun_0.61
## [26] cachem_1.1.0
## [27] cigarillo_1.3.1
## [28] GenomeInfoDb_1.49.1
## [29] jsonlite_2.0.0
## [30] progress_1.2.3
## [31] blob_1.3.0
## [32] DelayedArray_0.39.8
## [33] BiocParallel_1.47.0
## [34] parallel_4.6.1
## [35] prettyunits_1.2.0
## [36] R6_2.6.1
## [37] bslib_0.12.0
## [38] stringi_1.8.9
## [39] RColorBrewer_1.1-3
## [40] limma_3.99.0
## [41] rtracklayer_1.73.0
## [42] jquerylib_0.1.4
## [43] Rcpp_1.1.2
## [44] knitr_1.52
## [45] BiocBaseUtils_1.15.1
## [46] Matrix_1.7-6
## [47] splines_4.6.1
## [48] tidyselect_1.2.1
## [49] abind_1.4-8
## [50] yaml_2.3.12
## [51] codetools_0.2-20
## [52] curl_8.0.0
## [53] lattice_0.23-1
## [54] tibble_3.3.1
## [55] withr_3.0.3
## [56] KEGGREST_1.53.6
## [57] S7_0.2.2
## [58] evaluate_1.0.5
## [59] BiocFileCache_3.3.0
## [60] Biostrings_2.81.9
## [61] pillar_1.11.1
## [62] BiocManager_1.30.27
## [63] filelock_1.0.3
## [64] RCurl_1.98-1.20
## [65] hms_1.1.4
## [66] ggplot2_4.0.3
## [67] scales_1.4.0
## [68] glue_1.8.1
## [69] maketools_1.3.2
## [70] tools_4.6.1
## [71] BiocIO_1.23.3
## [72] sys_3.4.3
## [73] TxDb.Hsapiens.UCSC.hg38.knownGene_3.22.0
## [74] GenomicAlignments_1.49.2
## [75] buildtools_1.0.0
## [76] XML_3.99-0.25
## [77] grid_4.6.1
## [78] AnnotationDbi_1.75.2
## [79] restfulr_0.0.17
## [80] cli_3.6.6
## [81] S4Arrays_1.13.2
## [82] dplyr_1.2.1
## [83] gtable_0.3.6
## [84] sass_0.4.10
## [85] digest_0.6.39
## [86] SparseArray_1.13.4
## [87] ggrepel_0.9.8
## [88] org.Hs.eg.db_3.23.1
## [89] rjson_0.2.23
## [90] farver_2.1.2
## [91] memoise_2.0.1
## [92] htmltools_0.5.9
## [93] lifecycle_1.0.5
## [94] httr_1.4.9
## [95] statmod_1.5.2
## [96] bit64_4.8.6