Motif & ChIP-Atlas enrichment
Two complementary “what’s different?” analyses: sequence motifs enriched in each AR set, and published TF datasets (ChIP-Atlas) enriched in each set.
Table of contents
Motif enrichment
The idea
If AR+F and AR−F are bound by different cofactors, their underlying DNA should carry different transcription-factor motifs. We scan a JASPAR motif library across each set and ask which motifs are enriched relative to a background — here all accessible chromatin, so we control for “open chromatin” sequence composition and find what is specific to each AR class.
Scanning motifs into a matrix
First load the genome sequence, then scan:
from genomeblocks import make_genome
from genomeblocks.motifs import scan_motifs_matrix, bootstrap_enrichment
genome = make_genome(GENOME_FA) # hg38 FASTA -> per-chromosome sequence
pool = accessible # background = ATAC union
mat_pf = scan_motifs_matrix(ARpF, genome, MOTIF_DB, r=250, workers=8)
mat_mf = scan_motifs_matrix(ARmF, genome, MOTIF_DB, r=250, workers=8)
mat_pool = scan_motifs_matrix(pool, genome, MOTIF_DB, r=250, workers=8)
scan_motifs_matrix returns a pandas.DataFrame shaped (loci × motifs):
rows are locus uids, columns are motif names, and each cell is the motif’s hit
count in that locus’s center ± r window (normalised by motif width by
default). MOTIF_DB is a JASPAR-format file (motif_format='jaspar' is the
default).
Scanning the full accessible pool can be the slow step — it is the largest set.
workersparallelises the scan; if it is still heavy, subsamplepoolto a few thousand regions for the background.
Bootstrapped log-fold-change
bootstrap_enrichment compares the per-motif mean hit rate of each group to the
reference pool, with bootstrap resampling for stability:
enr = bootstrap_enrichment({"AR+F": mat_pf, "AR-F": mat_mf},
ref=mat_pool, boot=100, sample=500, seed=0)
It returns one row per motif with:
mean_ref— mean rate in the background pool;mean_<g>,LFC_<g>— mean rate andlog2((mean_g + pseudo)/(mean_ref + pseudo))for each group;LFC— when exactly two groups are given, the differenceLFC_AR+F − LFC_AR−F, i.e. the motif’s preference for one set over the other.
boot / sample are the number of bootstrap iterations and the per-iteration
subsample size; seed makes it reproducible.
Sorting by LFC puts AR+F-preferential motifs on top and AR−F-preferential ones
at the bottom:
ranked = enr.sort_values("LFC", ascending=False)
ranked.head(10)[["Factor", "LFC", "LFC_AR+F", "LFC_AR-F"]] # top in AR+F
ranked.tail(10)[["Factor", "LFC", "LFC_AR+F", "LFC_AR-F"]] # top in AR-F
Biologically you expect forkhead (FOX) motifs to surface among the AR+F-preferential set — the sequence signature of the FOXA1 pioneering that defines those sites.
ChIP-Atlas enrichment
The idea
Motifs tell you about sequence; ChIP-Atlas tells you about measured binding.
ChIP-Atlas aggregates tens of thousands of public
ChIP-seq / ATAC experiments. genomeblocks’ Atlas indexes them so you can ask:
which published datasets does my region set overlap more than expected? Using
AR−F as the reference for AR+F (and vice versa) gives a differential answer.
Building the index
Atlas.make builds a sparse bin × track matrix: every chromosome is tiled
into bin_size (1 kb) bins, and each ChIP-Atlas BED becomes a column marking the
bins it covers. A query then reduces to one sparse mat-vec — sub-second per
query after the one-time build.
from genomeblocks import Atlas
atlas = Atlas.make(
GIGGLE_DIR, chromsizes=CHROMSIZES,
meta=GIGGLE_META,
name_pattern=r"([^.]+)", # SRX...20.bed.gz -> SRX...
meta_columns=["id", "antigen", "class", "cell_line"], # meta TSV is header-less
meta_id_col="id",
)
Two arguments are essential for the metadata to line up — and are a common first-run trip-up:
name_patternextracts the track id from each filename. ChIP-Atlas files look likeSRX23002840.20.bed.gz;r"([^.]+)"keeps just the accession (SRX23002840) so it matches the metadata id. Without it the.20suffix never joins and every metadata column comes backNaN.
meta_columns+meta_id_colhandle a header-less metadata TSV: passingmeta_columnstells the reader there is no header row and names the columns;meta_id_colpicks the one to join on. Omit these and the first data row is mistaken for the header.
Differential search
atlas.search(query, ref=...) runs a per-track Fisher 2×2 of the query against
the reference (in bin units) and returns a DataFrame sorted by giggle_score:
enr_pf = atlas.search(ARpF, ref=ARmF) # tracks enriched in AR+F over AR-F
enr_mf = atlas.search(ARmF, ref=ARpF) # tracks enriched in AR-F over AR+F
cols = ["name", "antigen", "class", "cell_line", "overlaps", "log2_odds", "giggle_score"]
enr_pf.head(15)[cols]
Columns: name (track id), overlaps (query bins hit), log2_odds and p
(the Fisher test), giggle_score (the signed significance used for ranking), and
the joined antigen / class / cell_line metadata.
Reading the result
This is the validation step. On this data the top AR+F (FOXA1-dependent) tracks
resolve to FOXA1 in prostate / LNCaP — independent experiments confirming
those AR sites are FOXA1 territory — while the top AR−F (FOXA1-independent)
tracks shift to other factors such as NR3C1, a different nuclear receptor.
The antigen/cell_line columns (now populated thanks to the metadata args)
are what make that readable.
search(query)with noreftests against a genome-wide null instead, andbootstrap(query, n)gives a shuffled-position null — use those when you want “enriched vs the genome” rather than “enriched vs the other set.”
Next: Genome browser →