Summarize CLIP/iCLIP/eCLIP-style crosslink (xl) support around loci from an inference BED across multiple proteins, producing:
- a metaprofile plot (primary output): Gaussian-smoothed mean XL file-support signal from (-window:+window) (top 10 proteins by default)
- an optional per-locus summary table (TSV): per-locus signal shape metrics for each protein
- per-protein merged XL BEDs written under
<xldir>/merged/ - a clustered heatmap across all proteins (
binfrows x proteins columns; values = per-locus total support after logistic scaling) - a heatmap cluster assignment table for downstream cluster-specific metaprofiles
- an optional single-protein nucleotide heatmap (
binfrows x nt columns) via-i/--inspect-protein
--xldir should point to a directory with one subdirectory per protein, e.g.:
xldir/
PRPF8/
PRPF8_HepG2_1_R1.genome.xl.bed
PRPF8_HepG2_2_R1.genome.xl.bed
FUS/
...
Each protein directory must contain one or more *genome.xl.bed files in BED6 format:
chrom start end name score strand
- score (column 5) is read but not used directly in merging; merging uses site presence per file
- strand (column 6) is required (overlaps are strand-aware)
If --xldir itself contains *genome.xl.bed (no subdirs), the script treats --xldir as a single-protein directory (legacy layout).
**Recommended to use a separate control directory for each protein crosslink profile generated ** example: xl/prpf8 xl/prpf8ctrl xl/fus xl/fusctrl
The inference BED must have at least 6 columns with strand in column 6:
chrom start end name score strand ...
Only the first 6 columns are required; extra columns are ignored.
Used for bedtools slop when expanding inference loci by --window.
Created by using samtools faidx or cut -f1,2 on reference genome fasta index file (fa.fai file ext.)
python3 scripts/intersect_inference_bed.py \
-x <xldir> \
-b <inference.bed> \
--window 100 \
--gaussian-sigma 2.0 \
-i PRPF8 \
--table \
-o results/-x/--xldir: xl root directory (required)-b/--bed: inference BED (required)--window: half-window size (default 100). Output offsets run from (-window..+window).--gaussian-sigma: sigma parameter for Gaussian metaprofile smoothing (default 2.0)--panel-anchor:start(default) ormidpoint— which point of each--xldirinterval carries its score. Usemidpointwhenever the panel holds peaks rather than 1 nt crosslink sites; see Panel anchor below.--protein-nt-heatmap: write a proteins x nucleotide heatmap of mean support profiles (one row per protein, not per locus)--nt-heatmap-window: crop both nucleotide heatmaps to +/- this many nt for display only (default: full--window).--windowstill governs what is counted, so all statistics, the metaprofile, the enrichment ranking and the summary table are unchanged.--heatmap-min-support: minimum summed support across all xl groups for a locus to enter the heatmap/clustering (default 50, previously hardcoded). Scales with panel width, so raise it for wide panels — see the note under the clustered heatmap.--heatmap-scale:logistic(default) orpercentile— colour scaling for the clustered heatmap; see Heatmap colour scaling below.--heatmap-scale-percentile: percentile of non-zero values mapped to the top of the colour range under--heatmap-scale percentile(default 99.0)--protein-select:total(default),enrichment(recommended) orcentrality— how the top-K columns are chosen; see Protein selection below.--enrichment-window: half-width (nt) counted as "on the locus" byenrichmentranking (default 5)--protein-min-loci: minimum loci with signal for a protein to be eligible underenrichment/centrality(default 500)--centrality-sigma: width (nt) of the Gaussian template for centrality ranking (default 5.0)--centrality-min-total: minimum summedtotal_overlapsfor a protein to be eligible under centrality ranking (default 100)--exclude-groups: comma-separated substrings; panel columns whose group name contains one are dropped before anything is computed (e.g.--exclude-groups PARCLIP)--cluster-metaprofiles: if set, write one metaprofile plot per heatmap cluster (metaprofile_cluster_C*.png)--n-clusters: number of k-means clusters on the binarized heatmap row matrix (default 20; capped by number of filtered rows)--cluster-top-proteins: top K XL groups used for heatmap/clustering/tSNE features (default 100)--metaprofile-top-proteins: top K proteins plotted in global/per-cluster metaprofiles (default 15)-i/--inspect-protein: optional protein name for an extra per-nucleotide heatmap for that protein--skip-merge: skip per-protein merge and use direct BED/BED.GZ inputs from--xldir-s/--samplesheet: optional TSV (file,group) used with--skip-merge;fileis resolved relative to--xldir--table: if set, write the per-locus summary TSV--tsne: if set, generate tSNE frombinf_summary.tsvusing the same top-K*_total_overlapscolumns as the heatmap (requires--table)--tsne-perplexity: tSNE perplexity (default 30; clipped to valid range)--tsne-random-state: tSNE random seed (default 42)-o/--outdir: directory for plot + TSV (defaultresults/in the current working directory)
For each protein directory, all *genome.xl.bed files are merged by exact locus and strand.
Each site is tagged by source filename, then grouped so the merged score becomes:
- group key:
(chrom, start, end, strand) - aggregation:
count_distinct(file)(number of xl files containing that exact site+strand)
Output:
<xldir>/merged/<protein>_merged.bed
Chromosome names are normalized to chr* (e.g. 1 → chr1) to match typical BED naming.
When --skip-merge is used, the script does not merge protein subdirectories.
- with
-s/--samplesheet: reads a TSV withfileandgroupcolumnsfilepaths are relative to--xldirgroupbecomes the protein label in plots/tables
- without
--samplesheet: uses all BED/BED.GZ files directly in--xldir
Each inference locus is expanded by --window (bedtools slop). The script intersects merged xl sites with these windows strand-aware (bedtools intersect -s).
For each locus, it builds a vector of length (2*window+1), where each position stores the summed file-support score at that relative offset.
Offsets are strand-aligned:
- locus
+: offset (=)xl_start - locus_start - locus
-: offset (=)-(xl_start - locus_start)(so + offsets are always in the locus’ 5'→3' direction)
A panel interval's entire score lands on one offset, it is not spread across its width.
Which offset that is comes from --panel-anchor.
For the 1 nt crosslink sites this script was designed around, the two settings are
identical (start == midpoint when end == start + 1). They diverge as soon as the panel
holds peaks, and the divergence is not a harmless shift:
- a peak of width
wcentred on a+strand locus scores at-w/2 - the strand flip sends the same peak on a
-strand locus to+w/2, because a peak's genomic start is its 3' end there
So real central binding is split into a spurious +/-w/2 doublet with a hole at 0.
Measured on a panel of Clippy peaks (mean width ~11 bp) against 29,018 centred loci:
--panel-anchor |
+ strand median |
- strand median |
separation |
|---|---|---|---|
start |
-5.0 | +5.0 | 10.0 nt |
midpoint |
0.0 | 0.0 | 0.0 nt |
Use midpoint for any peak-based panel. It is a no-op for crosslink input, so it is
safe to leave on. start remains the default only so existing runs reproduce byte for byte.
Note that max_binding_offset in the summary table centres a 5 nt sliding window, so it
carries its own quantisation of a couple of nt independently of this setting.
logistic (default) centres on the matrix median. The locus x protein matrix is sparse,
so that median is ~0 and every empty cell maps to exactly 0.5 — mid-palette. The whole
range below 0.5 goes unused and the colourbar starts at 0.5, which is why a sparse run looks
uniformly flat no matter how the row filter is set.
percentile applies log1p, then scales against the given percentile of the non-zero
values and clips. Empty cells stay at 0, so the full palette carries signal. On a sparse test
matrix (65% of cells empty):
| scaling | range | empty cells render at | median non-zero cell |
|---|---|---|---|
logistic |
0.50 – 1.00 | 0.50 | 0.67 |
percentile (log1p, 99th) |
0.00 – 1.00 | 0.00 | 0.53 |
The log1p step is what makes this usable rather than merely correct. Support counts are
heavy-tailed — non-zero median 10 against a maximum of 333 on that matrix — so scaling raw
values against the 99th percentile put the median cell at 0.11, darker than the
logistic scaling it replaces. After log1p the same settings put it at 0.53.
Two things this also affects:
- the column dendrogram, built from cosine distances on the scaled matrix. Under
logisticevery column carries a large constant 0.5 component from its empty cells, which compresses the distances between them; underpercentilethe distances reflect actual co-occurrence. - not the row clusters, which come from
(matrix > 0)binarisation and are unaffected by any colour scaling.
Raising --heatmap-min-support is not an alternative fix. It culls rows rather than
rescaling colour, and because row support correlates with inference-BED reproducibility it
biases which loci survive.
--cluster-top-proteins K picks which panel columns reach the heatmap, clustering, tSNE
and the proteins x nt heatmap. --protein-select decides how they are picked.
total (default) ranks by summed total_overlaps. That is a depth-biased measure: a
deeply sequenced, peak-rich dataset scores highly whether or not its binding has anything
to do with the inference loci.
centrality ranks by Pearson r between each protein's mean profile and a Gaussian of width
--centrality-sigma centred on the locus. Pearson r is scale-free, so a shallow dataset
with sharply centred binding can outrank a deep one with a flat profile.
enrichment (recommended) is also depth-free but works per locus: for each protein,
the fraction of its signal-bearing loci whose max_binding_offset falls within
--enrichment-window. Prefer it over centrality — see below for why.
Once the panel is centred with --panel-anchor midpoint, nearly every mean profile peaks at
0, so correlating against a centred template stops discriminating on position and starts
discriminating on how tidy a dataset's baseline is. That systematically favours assays
with low background. Measured on a 297-column run:
centrality |
enrichment |
|
|---|---|---|
| assay mix of top 20 | 10 PAR-CLIP / 9 eCLIP / 1 iCLIP | 19 eCLIP / 1 iCLIP |
| vs panel composition (75% eCLIP, 23% PAR-CLIP) | PAR-CLIP 2.2x enriched | matches |
| BCLAF1 (known complex partner of the bait) | absent from top 20 | 16th |
| top-ranked columns | ORF1/L1RE1-PARCLIP (LINE-1, repeat-derived) | both HNRNPC datasets |
The two rankings shared zero of their top 20 on the same data.
--protein-min-loci is load-bearing for enrichment: the fraction is trivially 1.0 for a
protein whose signal touches a single locus. Ungated, the top of that ranking was a PAR-CLIP
column with one locus and a summed score of 5.
Validated against two decoys built from a real replicate at matched depth — one displaced 40 nt, one with positions jittered +/-80 nt:
| column | summed overlaps | rank by total |
centrality r | rank by centrality |
|---|---|---|---|---|
| genuine replicate A | 160,016 | 2 | +0.813 | 1 |
| genuine replicate B | 164,279 | 1 | +0.809 | 2 |
| genuine replicate C | 104,214 | 5 | +0.808 | 3 |
| genuine replicate D | 43,386 | 6 | +0.796 | 4 |
| decoy, jittered | 157,163 | 4 | +0.292 | 5 |
| decoy, shifted 40 nt | 158,592 | 3 | -0.003 | 6 |
Under total both decoys outrank two genuine replicates on depth alone. Under centrality
every genuine replicate outranks both decoys, including one at a quarter of their depth.
The jittered decoy retains r=+0.292 because jitter is itself centred, leaving broad central
enrichment inside the window — a broad hump is genuinely less centred, not a scoring flaw.
Proteins below --centrality-min-total are excluded before ranking, since a profile built
from a handful of overlaps can score a near perfect correlation off a single spike at 0.
Flat profiles are excluded too, Pearson r being undefined at zero variance. Selected
columns are logged with both their r and their total, so a high-r/low-signal column is
visible rather than silently shaping the heatmap.
Enabled by --protein-nt-heatmap. File: <outdir>/protein_nt_metaprofile_heatmap.png
- rows: the top K proteins (
--cluster-top-proteins), one row per protein - columns: nucleotide offsets
-window..+window - values: mean support profile, row-normalised to each protein's own maximum, so RBPs are compared by profile shape rather than by sequencing depth
- row clustering: correlation distance, average linkage — RBPs group by binding geometry relative to the inference loci; flat profiles are dropped first, since a constant row has undefined correlation
This is the plot to use for positional questions. It is one row per protein, so it stays
legible at any number of loci, unlike -i/--inspect-protein which is one row per locus
and whose average-linkage row clustering chains badly on sparse data (a 2-way cut of a
4,000-row example split 3,999 vs 1).
File: <outdir>/metaprofile.png
For each protein:
- compute
total_overlaps = sum(vector)for each locus - compute the mean support vector across all loci (average by number of input
binfregions) - smooth with a Gaussian kernel controlled by
--gaussian-sigma - rank proteins by total smoothed metaprofile signal and plot only the top K from
--metaprofile-top-proteins(default 15) - place legend on the right side of the figure
File: <outdir>/binf_support_heatmap.png
- rows:
binfloci - columns: the top K XL groups by global total signal from
--cluster-top-proteins(default 100), not the full protein list - values: per-locus total support (
total_overlaps) transformed by logistic scaling - pre-filter rows: keep loci with
sum(total_overlaps across *all* XL groups) >= --heatmap-min-support(default 50; filter uses the full table, heatmap columns are still top-K only). This threshold scales with the number of panel columns, not with anything intrinsic to a locus, so a wide panel makes it permissive — 87% of rows passed on the 297-column THRAP3 run, which saturates the heatmap. Raise it when the heatmap looks uniformly dark. - row clustering: build a binary matrix over the top-K proteins (
1if support> 0at that locus/protein, else0), then k-means (--n-clusters, default 20) on those binary rows - row order (no row dendrogram): sort by cluster id (ascending), then by total crosslink support across all proteins (descending) within each cluster
- column clustering only: cosine distance + average linkage between protein columns (computed on the scaled matrix before row reorder; row order does not change column vectors for linkage)
- heatmap values shown are still logistic-scaled continuous totals (viridis)
File: <outdir>/binf_heatmap_clusters.tsv
- one row per input
binflocus (same order as summary table) - columns:
binf_chr_start_endchrom,start,endrow_sum_support(sum oftotal_overlapsacross all XL groups)passes_heatmap_filter(True/False, threshold>=40on sum across all proteins)heatmap_cluster:NAif row fails heatmap filter; otherwise integer k-means cluster id (1..k)
Enabled by --cluster-metaprofiles.
- one metaprofile plot per heatmap row cluster
- files:
<outdir>/metaprofile_cluster_C1.png,<outdir>/metaprofile_cluster_C2.png, ... - for each cluster: mean support profile is computed only from loci assigned to that cluster
Enabled by --table --tsne.
File: <outdir>/binf_summary_tsne.png
- input features:
*_total_overlapscolumns for the same top-K proteins as the heatmap (--cluster-top-proteins) - feature transform: same logistic scaling used for the global heatmap
- one point per
binfrow - points: colored by k-means cluster id (
1..k); rows not in heatmap filter are light gray - requires
scikit-learnin the environment
Enabled by -i/--inspect-protein.
File: <outdir>/binf_<protein>_nt_support_heatmap.png
- rows:
binfloci - columns: nucleotide positions from
-window..+window - values: merged support score at each relative nucleotide offset for the selected protein
- pre-filter rows: keep loci with
sum(across nt positions) >= 10for the selected protein - hierarchical clustering on rows only; nucleotide columns stay in genomic order (
-window..+window)
Enabled by --table.
File: <outdir>/binf_summary.tsv
- Column 1:
binf_chr_start_endformatted aschr_start_end - For each protein, five columns are added:
<protein>_total_overlaps<protein>_variance<protein>_pearson_median_skew<protein>_kurtosis_excess<protein>_max_binding_offset
These metrics are computed from the per-locus vector across (-window..+window):
-
total_overlaps: (\sum) of xl scores across the full vector
-
variance: variance of xl scores across the full vector
-
pearson_median_skew: Pearson’s median skewness
[ 3 \cdot \frac{\text{mean} - \text{median}}{\text{std}} ]
(defined as 0 when
std == 0). -
kurtosis_excess: Fisher excess kurtosis; Positive values indicate leptokurtic distribution with strong tailedness while negative values are strongly platykurtic
[ \frac{\mu_4}{\sigma^4} - 3 ]
-
max_binding_offset: offset of maximal binding based on sliding 5-nt window sums
- compute all 5-nt window sums along the vector
- take the window with the maximum sum
- report the center position of that window as an offset
- Input coordinates can repeat (duplicate
chr/start/end); the script keeps one row per input BED line and handles duplicates during intersection. - Runtime is dominated by the
bedtools groupbymerge andbedtools intersectsteps for large XL datasets. -i/--inspect-proteinmust match a protein directory name under--xldir.