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

  1. Motif enrichment
    1. The idea
    2. Scanning motifs into a matrix
    3. Bootstrapped log-fold-change
  2. ChIP-Atlas enrichment
    1. The idea
    2. Building the index
    3. Differential search
    4. Reading the result

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. workers parallelises the scan; if it is still heavy, subsample pool to 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 and log2((mean_g + pseudo)/(mean_ref + pseudo)) for each group;
  • LFC — when exactly two groups are given, the difference LFC_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. genomeblocksAtlas 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_pattern extracts the track id from each filename. ChIP-Atlas files look like SRX23002840.20.bed.gz; r"([^.]+)" keeps just the accession (SRX23002840) so it matches the metadata id. Without it the .20 suffix never joins and every metadata column comes back NaN.

meta_columns + meta_id_col handle a header-less metadata TSV: passing meta_columns tells the reader there is no header row and names the columns; meta_id_col picks the one to join on. Omit these and the first data row is mistaken for the header.

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 no ref tests against a genome-wide null instead, and bootstrap(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 →


Copyright © 2024–2026 Umut Berkay Altintas. MIT Licensed.

This site uses Just the Docs, a documentation theme for Jekyll.