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
Pass the FASTA path straight to the scanner — the windows are read through the indexed FASTA reader, so the genome is never loaded whole — and the motif file:
from genomeblocks.motifs import scan_motifs_matrix, bootstrap_enrichment
pool = accessible # background = ATAC union
mat_pf = scan_motifs_matrix(ARpF, GENOME_FA, MOTIF_DB, r=250, workers=8)
mat_mf = scan_motifs_matrix(ARmF, GENOME_FA, MOTIF_DB, r=250, workers=8)
mat_pool = scan_motifs_matrix(pool, GENOME_FA, MOTIF_DB, r=250, workers=8)
scan_motifs_matrix returns a pandas.DataFrame shaped (loci x motifs):
rows are the loci in their order (index = uid), columns are motif names, and
each cell is the motif’s hit count in that locus’s centre ± r window
(normalised by motif width by default). MOTIF_DB is a JASPAR-format file
(format='jaspar', the raw four-row count layout, is the default; pass
format='jaspar16' for the bracketed layout, or 'meme'). The scan runs on
MOODS (lightmotif when only it is installed); backend='lightmotif' or 'biopython' give the same hits.
Scanning the full accessible pool can be the slow step — it is the largest set.
workersparallelises the scan over motifs; if it is still heavy, subsamplepoolto a few thousand regions for the background. If you would rather hold the genome in memory,gb.read_fasta(GENOME_FA)returns a{chrom: sequence}dict that every scanner accepts in place of the path.
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 x 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",
)
atlas.save("chipatlas_hg38_1kb.npz") # reuse across sessions: Atlas.load(...)
chromsizes takes a .chrom.sizes path as here, a {chrom: length} dict, a
gb.Genome.from_fasta(GENOME_FA), or a cooler. The result is a table of its
tracks (atlas.head(), atlas.to_pandas()), with the metadata columns joined.
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 2x2 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. The query and the reference
are anything as_loci takes, and ARpF.enrich(atlas, ref=ARmF) is the same
call as a method.
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.” See the Atlas guide.
Next: Genome browser →