Hi-Compass Advanced Tutorial

December 24, 2025 ยท View on GitHub

This tutorial provides advanced analysis workflows for cell-type-specific Hi-C data predicted by Hi-Compass.

For installation, data preprocessing, model training, and basic prediction usage, please refer to the main README.

Contents

1. Meta-cell Hi-C Prediction

We demonstrate Hi-Compass meta-cell Hi-C prediction using a single-cell ATAC-seq dataset of human peripheral blood mononuclear cells (PBMCs) from 10x Genomics.

The workflow consists of three steps:

  1. Aggregate single cells into meta-cells
  2. Generate ATAC-seq BigWig files for each meta-cell
  3. Predict Hi-C contact matrices using Hi-Compass

1.1 Prepare the Annotated scATAC-seq Object

Follow the Signac PBMC vignette to obtain an annotated Seurat/Signac object with cell type annotations.

You can also download the processed pbmc_filtered.rds file from here .

library(Seurat)
library(ggplot2)
library(dplyr)
library(glue)

pbmc_filtered <- readRDS('pbmc_filtered.rds')
DimPlot(
  object = pbmc_filtered,
  group.by = 'predicted.id',
  label = TRUE,
  repel = TRUE) + ggtitle('predicted.id')

scATAC_umap

1.2 Meta-cell Aggregation

We provide two methods for aggregating single cells into meta-cells:

This method aggregates cells based on their proximity in UMAP space, ensuring that cells within each meta-cell are transcriptionally similar.

ct_list <- unique(data.frame(pbmc_filtered$predicted.id)[,1])
split_objects <- SplitObject(pbmc_filtered, split.by = "predicted.id")

cluster_size <- 10
threshold_low <- cluster_size * 0.5
threshold_up <- cluster_size * 2

total_df <- data.frame()
for (ct in ct_list) {
    cache_object <- split_objects[[ct]]
    cell_num <- length(cache_object$predicted.id)
    
    # Skip cell types with too few cells
    if (cell_num < cluster_size * 5) {
        next
    }
    
    # K-means clustering on UMAP coordinates
    umap_axis <- cache_object@reductions$umap@cell.embeddings
    k <- cell_num %/% cluster_size + 1
    kmeans_result <- kmeans(umap_axis, centers = k) 
    
    cluster_assignments <- data.frame(
        cluster = glue("{ct}~{kmeans_result$cluster}"),
        barcode = rownames(umap_axis)
    )
    
    # Get cluster sizes
    cluster_sizes <- cluster_assignments %>%
        group_by(cluster) %>%
        summarise(count = n()) %>%
        ungroup()
    
    # Keep clusters within size thresholds
    small_clusters_df <- cluster_sizes %>%
        filter(count >= threshold_low & count <= threshold_up) %>%
        left_join(cluster_assignments, by = 'cluster') %>%
        select(-count)
    
    # Split large clusters
    large_clusters <- cluster_sizes %>%
        filter(count > threshold_up)
    
    split_large_df <- data.frame(cluster = character(), barcode = character())
    
    if (nrow(large_clusters) > 0) {
        for (i in 1:nrow(large_clusters)) {
            large_cluster_name <- large_clusters$cluster[i]
            large_cluster_count <- large_clusters$count[i]
            n_splits <- ceiling(large_cluster_count / cluster_size)
            
            large_cluster_barcodes <- cluster_assignments[
                cluster_assignments$cluster == large_cluster_name, ]
            
            if (nrow(large_cluster_barcodes) > 0 && n_splits > 0) {
                all_splits <- sample(rep(1:n_splits, length.out = nrow(large_cluster_barcodes)))
                large_cluster_barcodes$split <- all_splits
                
                for (split_num in 1:n_splits) {
                    current_split <- large_cluster_barcodes[
                        large_cluster_barcodes$split == split_num, ]
                    if (nrow(current_split) > 0) {
                        current_split$cluster <- glue("{large_cluster_name}{split_num}")
                        split_large_df <- rbind(
                            split_large_df, 
                            current_split[, c("cluster", "barcode")]
                        )
                    }
                }
            }
        }
    }
    
    # Combine results
    if (nrow(split_large_df) > 0) {
        cache_df <- rbind(small_clusters_df, split_large_df)
    } else {
        cache_df <- small_clusters_df
    }
    
    total_df <- rbind(total_df, cache_df)
}

# Save meta-cell assignments
write.table(
    total_df[, c('barcode', 'cluster')], 
    'barcode_cluster.csv',
    quote = FALSE, 
    row.names = FALSE, 
    col.names = FALSE, 
    sep = " "
)

Method B: Fixed-size Aggregation

If you prefer strict control over the number of cells per meta-cell without considering neighborhood relationships, use this simpler approach:

ct_list <- unique(data.frame(pbmc_filtered$predicted.id)[,1])

cluster_size <- 10

total_df <- data.frame()
for (ct in ct_list) {
    # Get all barcodes for this cell type
    barcodes <- colnames(pbmc_filtered)[pbmc_filtered$predicted.id == ct]
    
    # Randomize order
    barcodes <- sample(barcodes)
    
    # Calculate number of complete groups
    n_cells <- length(barcodes)
    n_groups <- n_cells %/% cluster_size
    
    if (n_groups == 0) next
    
    # Use only cells that fit into complete groups (discard remainder)
    barcodes_use <- barcodes[1:(n_groups * cluster_size)]
    
    # Assign cluster IDs
    cluster_ids <- rep(1:n_groups, each = cluster_size)
    
    cache_df <- data.frame(
        barcode = barcodes_use,
        cluster = glue("{ct}~{cluster_ids}")
    )
    
    total_df <- rbind(total_df, cache_df)
}

# Save meta-cell assignments
write.table(
    total_df[, c('barcode', 'cluster')], 
    'barcode_cluster.csv',
    quote = FALSE, 
    row.names = FALSE, 
    col.names = FALSE, 
    sep = " "
)

The output barcode_cluster.csv contains two columns: barcode and meta-cell ID.

head(total_df)

``$

\text{A} \text{tibble}: 6 \times 2

\text{cluster} \text{barcode}
<\text{glue}> <\text{chr}>
1 \text{CD14}+ \text{Monocytes}~1 \text{AACCTTTCAGATACAA}-1 2 \text{CD14}+ \text{Monocytes}~1 \text{AACTGTGAGCCGCTGT}-1 3 \text{CD14}+ \text{Monocytes}~1 \text{ACTAACGTCACCGCGA}-1 4 \text{CD14}+ \text{Monocytes}~1 \text{ATCGAGTGTTGGCTAT}-1 5 \text{CD14}+ \text{Monocytes}~1 \text{CACATGAGTGATAACA}-1 6 \text{CD14}+ \text{Monocytes}~1 \text{CCTTAATTCCATCTAT}-1 $``

1.3 Extract BAM Files for Each Meta-cell

Use Sinto to subset the Cell Ranger ATAC output BAM file by meta-cell assignments:

sinto filterbarcodes \
    -b possorted_bam.bam \
    -c barcode_cluster.csv \
    -p 16 \
    -s \
    --outdir ./meta_cell/

This command takes approximately one hour depending on computational resources.

1.4 Convert to BigWig and Calculate Depth

After extracting SAM files, convert each meta-cell's reads to BigWig format and record the sequencing depth:

#!/bin/bash

BASE_DIR="./meta_cell/"
CHROM_SIZES="/path/to/hg38.chrom.sizes"

for SAM_FILE in "$BASE_DIR"/*.sam; do
    # Get filename without path and extension
    BASENAME=$(basename "$SAM_FILE" .sam)
    
    # Create subdirectory for this meta-cell
    OUT_DIR="$BASE_DIR/$BASENAME"
    mkdir -p "$OUT_DIR"
    
    # Move SAM file to subdirectory
    mv "$SAM_FILE" "$OUT_DIR/"
    
    cd "$OUT_DIR"
    
    # 1. Count depth
    wc -l "$BASENAME.sam" > "$BASENAME.txt"
    
    # 2. SAM to BAM
    samtools view -bS "$BASENAME.sam" -o "$BASENAME.bam"
    
    # 3. Sort BAM
    samtools sort "$BASENAME.bam" -o "$BASENAME.sorted.bam"
    
    # 4. BAM to bedGraph
    bedtools genomecov -ibam "$BASENAME.sorted.bam" -bg > "$BASENAME.bedgraph"
    
    # 5. Filter to keep only chr1-22 (for human; use chr1-19 for mouse)
    awk '\$1 ~ /^chr([1-9]|1[0-9]|2[0-2])$/' "$BASENAME.bedgraph" > "$BASENAME.sorted.bedgraph"
    
    # 6. bedGraph to BigWig
    bedGraphToBigWig "$BASENAME.sorted.bedgraph" "$CHROM_SIZES" "$BASENAME.bw"
    
    # Clean up intermediate files
    rm -f "$BASENAME.sam" "$BASENAME.bam" "$BASENAME.sorted.bam" "$BASENAME.bedgraph" "$BASENAME.sorted.bedgraph"
    
    cd "$BASE_DIR"
done

echo "All processing complete!"

1.5 Predict mcHi-C

Run Hi-Compass prediction for each meta-cell:

#!/bin/bash

BASE_DIR="./meta_cell/"
MODEL_PATH="/path/to/hicompass_hg38.pth"
CTCF_PATH="/path/to/generalized_CTCF.bw"
DNA_DIR="/path/to/DNA/hg38"
CENTROMERE_BED="/path/to/centromere.bed"

for DIR in "$BASE_DIR"/*; do
    if [ -d "$DIR" ]; then
        DIR_NAME=$(basename "$DIR")
        BW_FILE="$DIR/$DIR_NAME.bw"
        DEPTH_FILE="$DIR/$DIR_NAME.txt"
        
        if [ -f "$BW_FILE" ] && [ -f "$DEPTH_FILE" ]; then
            DEPTH=$(awk '{print \$1}' "$DEPTH_FILE")
            
            echo "Processing $DIR_NAME (depth: $DEPTH)..."
            
            hicompass predicting \
                --model-path "$MODEL_PATH" \
                --atac-path "$BW_FILE" \
                --ctcf-path "$CTCF_PATH" \
                --dna-dir "$DNA_DIR" \
                --centromere-bed "$CENTROMERE_BED" \
                --depth "$DEPTH" \
                --genome hg38 \
                --output "$DIR/$DIR_NAME.cool" \
                --device cuda:0
            
            echo "Completed $DIR_NAME"
        fi
    fi
done

echo "Prediction complete!"

2. mcHi-C Dimensionality Reduction and Clustering

After predicting mcHi-C for all meta-cells, we use scanpy to perform dimensionality reduction and clustering on the predicted contact matrices. This allows us to visualize cell-type-specific chromatin organization patterns.

2.1 Extract Diagonal Features from mcHi-C

We flatten each mcHi-C matrix by extracting diagonal bands, which capture interaction frequencies at different genomic distances. By default, we use diagonals 2-20 (corresponding to 20-200 kb genomic distances at 10 kb resolution).

import os
import cooler
import numpy as np
import pandas as pd
import scanpy as sc


def extract_diagonal_features(folder_path, 
                              start_diag=2, 
                              end_diag=20, 
                              chroms=[f'chr{i}' for i in range(1, 23)]):
    """
    Extract diagonal features from mcHi-C cool files for clustering.
    
    Parameters
    ----------
    folder_path : str
        Directory containing meta-cell subdirectories with .cool files
    start_diag : int
        Starting diagonal index (default: 2, skipping the main diagonal and first off-diagonal)
    end_diag : int
        Ending diagonal index (default: 20)
    chroms : list
        List of chromosomes to include
    
    Returns
    -------
    features : np.ndarray
        Feature matrix (n_cells x n_features)
    cell_names : list
        Meta-cell identifiers
    """
    features = []
    cell_names = []
    
    for subdir in os.listdir(folder_path):
        cool_path = os.path.join(folder_path, subdir, f'{subdir}.cool')
        
        if not os.path.exists(cool_path):
            continue
        
        try:
            clr = cooler.Cooler(cool_path)
            print(f'Processing: {cool_path}')
            
            # Extract cell type from directory name
            cell_type = subdir.split('~')[0]
            
            # Extract diagonals for each chromosome
            chrom_diagonals = {}
            for chrom in chroms:
                matrix = clr.matrix(balance=False).fetch(chrom)
                chrom_diagonals[chrom] = [
                    matrix.diagonal(i) for i in range(start_diag, end_diag)
                ]
            
            # Concatenate all diagonals into a single feature vector
            feature_vector = []
            for diag_idx in range(end_diag - start_diag):
                for chrom in chroms:
                    feature_vector.extend(chrom_diagonals[chrom][diag_idx])
            
            features.append(feature_vector)
            cell_names.append(cell_type)
            
        except Exception as e:
            print(f'Error processing {cool_path}: {str(e)}')
    
    return np.array(features), cell_names

2.2 Perform Clustering with Scanpy

# Extract features
folder_path = './meta_cell/'
features, cell_names = extract_diagonal_features(folder_path)

# Create AnnData object
adata = sc.AnnData(pd.DataFrame(features).fillna(0))
adata.obs['cell_type'] = cell_names

# Standard scanpy workflow
sc.pp.scale(adata)
sc.tl.pca(adata)
sc.pp.neighbors(adata)
sc.tl.umap(adata)
sc.tl.leiden(adata, resolution=0.7)

# Visualize results
sc.pl.umap(adata, color=['cell_type',])

mcHiC_umap

2.3 Notes on Parameter Selection

ParameterRecommendationDescription
start_diag2Skip main diagonal (0) and first off-diagonal (1) which contain only zeros in Hi-Compass predictions
end_diag20-100Higher values capture longer-range interactions but increase memory usage
resolution0.1-1.0Leiden clustering resolution; adjust based on expected number of cell types

Increasing the number of diagonals (end_diag) will capture more long-range interaction patterns but requires more memory and computation time. We recommend using at least 15 diagonals to capture differential features of mcHi-C data.

3. Loop Detection

We recommend using Mustache for calling chromatin loops from Hi-Compass predicted mcHi-C data. Mustache provides two modes: single-sample loop calling and differential loop detection between two conditions.

3.1 Aggregate mcHi-C by Cell Type (Optional)

Before loop calling, you may want to aggregate all mcHi-C matrices within the same cell type to increase signal strength:

import cooler
import numpy as np
import pandas as pd


def aggregate_cool_files(cool_files, output_path):
    """
    Aggregate multiple mcHi-C matrices into a single cool file.
    
    Parameters
    ----------
    cool_files : list
        List of paths to cool files to aggregate
    output_path : str
        Output path for the aggregated cool file
    """
    if len(cool_files) == 0:
        raise ValueError('No cool files provided')
    
    # Use first file as template for bins
    clr_template = cooler.Cooler(cool_files[0])
    bins = clr_template.bins()[:]
    chroms = clr_template.chromnames
    
    print(f'Aggregating {len(cool_files)} files...')
    
    # Sum all matrices
    aggregated_pixels = []
    for chrom in chroms:
        chrom_matrix = None
        for cool_path in cool_files:
            clr = cooler.Cooler(cool_path)
            matrix = clr.matrix(balance=False, sparse=True).fetch(chrom)
            if chrom_matrix is None:
                chrom_matrix = matrix
            else:
                chrom_matrix = chrom_matrix + matrix
        
        # Convert to COO format and extract pixels
        if chrom_matrix is not None:
            coo = chrom_matrix.tocoo()
            chrom_offset = clr_template.offset(chrom)
            for i, j, v in zip(coo.row, coo.col, coo.data):
                if i <= j:  # Upper triangle only
                    aggregated_pixels.append({
                        'bin1_id': chrom_offset + i,
                        'bin2_id': chrom_offset + j,
                        'count': v
                    })
    
    # Create aggregated cool file
    pixels_df = pd.DataFrame(aggregated_pixels)
    cooler.create_cooler(output_path, bins, pixels_df, ordered=True)
    print(f'Saved: {output_path}')


# Example usage
cd14_files = [
    './meta_cell/CD14_Monocytes~1/CD14_Monocytes~1.cool',
    './meta_cell/CD14_Monocytes~2/CD14_Monocytes~2.cool',
    './meta_cell/CD14_Monocytes~3/CD14_Monocytes~3.cool',
]
aggregate_cool_files(cd14_files, './aggregated/CD14_Monocytes.cool')

3.2 Single-Sample Loop Calling

Call loops from a single mcHi-C or aggregated Hi-C matrix using the mustache command:

mustache -f ./aggregated/CD14_Monocytes.cool \
    -r 10kb \
    -o CD14_Monocytes_loops.tsv \
    -p 16 \
    -pt 0.1 \
    -st 0.8
ParameterDescription
-fInput cool/mcool/hic file
-rResolution (e.g., 10kb, 5kb)
-oOutput file
-pNumber of processors (default: 4)
-ptP-value threshold (default: 0.1)
-stSparsity threshold (default: 0.88; use lower values like 0.7-0.8 for sparse data)

3.3 Differential Loop Detection

The differential loop detection function is provided as a separate script diff_mustache.py in the Mustache repository. First clone the repository:

git clone https://github.com/ay-lab/mustache

Then run differential analysis:

python3 ./mustache/mustache/diff_mustache.py \
    -f1 ./aggregated/CD14_Monocytes.cool \
    -f2 ./aggregated/CD4_T_cells.cool \
    -r 10000 \
    -pt 0.1 \
    -pt2 0.1 \
    -st 0.8 \
    -o CD14_vs_CD4
ParameterDescription
-f1First condition cool/hic file
-f2Second condition cool/hic file
-rResolution in bp (e.g., 10000)
-ptP-value threshold for loop detection
-pt2P-value threshold for differential loops
-stSparsity threshold
-oOutput prefix

This command outputs 4 files:

FileDescription
{output}.loop1Loops detected in condition 1
{output}.loop2Loops detected in condition 2
{output}.diffloop1Differential loops enriched in condition 1 (weakened/disappeared in condition 2)
{output}.diffloop2Differential loops enriched in condition 2 (weakened/disappeared in condition 1)

3.4 Output Format

Mustache outputs a tab-separated file with the following columns:

ColumnDescription
BIN1_CHRChromosome of first anchor
BIN1_STARTStart position of first anchor
BIN1_ENDEnd position of first anchor
BIN2_CHROMOSOMEChromosome of second anchor
BIN2_STARTStart position of second anchor
BIN2_ENDEnd position of second anchor
FDRFalse discovery rate
DETECTION_SCALEScale at which loop was detected

4. Promoter-Enhancer Annotation

By matching transcription start sites (TSS) to one anchor of detected loops, we can identify potential promoter-enhancer interactions. The opposite anchor of a promoter-associated loop is considered a putative enhancer region.

4.1 Prepare TSS Reference

Download the annotation file from GENCODE:

# For human (hg38)
wget https://ftp.ebi.ac.uk/pub/databases/gencode/Gencode_human/release_38/gencode.v38.annotation.gtf.gz

# For mouse (mm10)
wget https://ftp.ebi.ac.uk/pub/databases/gencode/Gencode_mouse/release_M10/gencode.vM10.annotation.gtf.gz

Extract TSS information using Python:

import gzip
import re


def extract_tss_from_gtf(gtf_file, output_file):
    """
    Extract TSS information from GENCODE GTF file.
    """
    # Regex pattern to extract attributes
    attr_pattern = re.compile(r'(\w+) "([^"]+)"')
    
    with gzip.open(gtf_file, 'rt') if gtf_file.endswith('.gz') else open(gtf_file, 'r') as f_in, \
         open(output_file, 'w') as f_out:
        
        for line in f_in:
            if line.startswith('#'):
                continue
            
            fields = line.strip().split('\t')
            if len(fields) < 9 or fields[2] != 'transcript':
                continue
            
            chrom = fields[0]
            start = int(fields[3])
            end = int(fields[4])
            strand = fields[6]
            
            # Parse attributes
            attrs = dict(attr_pattern.findall(fields[8]))
            
            # Get TSS position (0-based for BED)
            if strand == '+':
                tss_start = start - 1
                tss_end = start
            else:
                tss_start = end - 1
                tss_end = end
            
            # Extract fields with defaults
            transcript_id = attrs.get('transcript_id', '')
            gene_id = attrs.get('gene_id', '')
            gene_name = attrs.get('gene_name', '')
            gene_type = attrs.get('gene_type', '')
            transcript_type = attrs.get('transcript_type', '')
            
            # Output: chr, start, end, transcript_id, gene_id, strand, gene_name, gene_type, transcript_type
            f_out.write(f"{chrom}\t{tss_start}\t{tss_end}\t{transcript_id}\t{gene_id}\t{strand}\t{gene_name}\t{gene_type}\t{transcript_type}\n")
    
    print(f"TSS file saved to {output_file}")


# For human
extract_tss_from_gtf('gencode.v38.annotation.gtf.gz', 'gencode.v38.transcript.tss.bed')

# For mouse
extract_tss_from_gtf('gencode.vM10.annotation.gtf.gz', 'gencode.vM10.transcript.tss.bed')

The output BED file contains the following columns:

ColumnDescription
1Chromosome
2TSS start (0-based)
3TSS end
4Transcript ID
5Gene ID
6Strand
7Gene name
8Gene type
9Transcript type

4.2 Annotate Loops with Promoters

import pandas as pd
from pybedtools import BedTool


def annotate_promoter_loops(loops_file, tss_file, promoter_window=2000, anchor_extension=0, resolution=10000):
    """
    Annotate loops with promoter information.
    
    Parameters
    ----------
    loops_file : str
        Path to Mustache output file (.tsv or .loops)
    tss_file : str
        Path to TSS BED file (from 4.1)
    promoter_window : int
        Window size around TSS to define promoter region (default: 2000bp)
    anchor_extension : int
        Number of bins to extend loop anchors for intersection (default: 0)
    resolution : int
        Resolution of Hi-C data (default: 10000)
    
    Returns
    -------
    pd.DataFrame
        Annotated loops with promoter information
    """
    # Load TSS file
    tss = pd.read_csv(tss_file, sep='\t', header=None,
                      names=['chrom', 'start', 'end', 'transcript_id', 'score', 'strand',
                             'transcript_name', 'transcript_type', 'gene_type', 
                             'gene_id', 'gene_name', 'gene_type_original'])
    
    # Create promoter regions
    promoter = tss.copy()
    promoter['start'] = (tss['start'] - promoter_window).clip(lower=0)
    promoter['end'] = tss['end'] + promoter_window
    promoter_bed = BedTool.from_dataframe(
        promoter[['chrom', 'start', 'end', 'transcript_id', 'gene_id', 'strand', 'gene_name', 'gene_type']]
    )
    
    # Load loops
    loops = pd.read_csv(loops_file, sep='\t')
    
    # Extend anchors if specified
    extension = anchor_extension * resolution
    
    # Create BED for anchor 1
    anchor1 = loops[['BIN1_CHR', 'BIN1_START', 'BIN1_END']].copy()
    anchor1.columns = ['chrom', 'start', 'end']
    anchor1['start'] = anchor1['start'] - extension
    anchor1['end'] = anchor1['end'] + extension
    anchor1['loop_idx'] = loops.index
    anchor1_bed = BedTool.from_dataframe(anchor1)
    
    # Create BED for anchor 2
    anchor2 = loops[['BIN2_CHROMOSOME', 'BIN2_START', 'BIN2_END']].copy()
    anchor2.columns = ['chrom', 'start', 'end']
    anchor2['start'] = anchor2['start'] - extension
    anchor2['end'] = anchor2['end'] + extension
    anchor2['loop_idx'] = loops.index
    anchor2_bed = BedTool.from_dataframe(anchor2)
    
    # Intersect anchors with promoters
    anchor1_promoter = anchor1_bed.intersect(promoter_bed, wa=True, wb=True)
    anchor2_promoter = anchor2_bed.intersect(promoter_bed, wa=True, wb=True)
    
    def parse_intersection(intersect_result, anchor_name):
        if len(intersect_result) == 0:
            return pd.DataFrame()
        
        df = intersect_result.to_dataframe(
            names=['anchor_chrom', 'anchor_start', 'anchor_end', 'loop_idx',
                   'promoter_chrom', 'promoter_start', 'promoter_end',
                   'transcript_id', 'gene_id', 'strand', 'gene_name', 'gene_type']
        )
        df['promoter_anchor'] = anchor_name
        return df
    
    anchor1_df = parse_intersection(anchor1_promoter, 'anchor1')
    anchor2_df = parse_intersection(anchor2_promoter, 'anchor2')
    
    # Combine results
    promoter_loops = pd.concat([anchor1_df, anchor2_df], ignore_index=True)
    
    if len(promoter_loops) == 0:
        print("No promoter-associated loops found.")
        return pd.DataFrame()
    
    # Merge with original loop information
    promoter_loops = promoter_loops.merge(
        loops.reset_index().rename(columns={'index': 'loop_idx'}),
        on='loop_idx',
        how='left'
    )
    
    return promoter_loops


# Example usage
promoter_loops = annotate_promoter_loops(
    loops_file='CD14_Monocytes_loops.tsv',
    tss_file='gencode.v38.transcript.tss.bed',
    promoter_window=2000,
    anchor_extension=1,
    resolution=10000
)

# Filter for protein-coding genes only
promoter_loops_pcg = promoter_loops[promoter_loops['gene_type'] == 'protein_coding']

4.3 Identify Putative Enhancers

For each promoter-associated loop, the non-promoter anchor represents a putative enhancer:

def extract_putative_enhancers(promoter_loops):
    """
    Extract putative enhancer regions from promoter-associated loops.
    
    The enhancer is defined as the loop anchor that does NOT overlap with a promoter.
    """
    enhancers = []
    
    for _, row in promoter_loops.iterrows():
        if row['promoter_anchor'] == 'anchor1':
            # Anchor 1 is promoter, anchor 2 is putative enhancer
            enhancers.append({
                'enhancer_chrom': row['BIN2_CHROMOSOME'],
                'enhancer_start': row['BIN2_START'],
                'enhancer_end': row['BIN2_END'],
                'promoter_chrom': row['BIN1_CHR'],
                'promoter_start': row['BIN1_START'],
                'promoter_end': row['BIN1_END'],
                'gene_name': row['gene_name'],
                'gene_id': row['gene_id'],
                'gene_type': row['gene_type'],
                'transcript_id': row['transcript_id'],
                'FDR': row['FDR']
            })
        else:
            # Anchor 2 is promoter, anchor 1 is putative enhancer
            enhancers.append({
                'enhancer_chrom': row['BIN1_CHR'],
                'enhancer_start': row['BIN1_START'],
                'enhancer_end': row['BIN1_END'],
                'promoter_chrom': row['BIN2_CHROMOSOME'],
                'promoter_start': row['BIN2_START'],
                'promoter_end': row['BIN2_END'],
                'gene_name': row['gene_name'],
                'gene_id': row['gene_id'],
                'gene_type': row['gene_type'],
                'transcript_id': row['transcript_id'],
                'FDR': row['FDR']
            })
    
    return pd.DataFrame(enhancers)


# Extract enhancers
enhancers = extract_putative_enhancers(promoter_loops)

# Filter for protein-coding genes
enhancers_pcg = enhancers[enhancers['gene_type'] == 'protein_coding']

4.4 Filter by Gene Expression (Optional)

Users can further filter promoter-enhancer links using RNA-seq expression data.

5. GWAS Variant Annotation

By identifying loops where one anchor overlaps a GWAS-associated variant and the other anchor overlaps a gene promoter, we can link disease-associated variants to their potential target genes through 3D chromatin interactions.

5.1 Download and Prepare GWAS Catalog

Download the GWAS Catalog from NHGRI-EBI:

wget https://www.ebi.ac.uk/gwas/api/search/downloads/full -O gwas_catalog.tsv

5.2 Process GWAS SNPs

import pandas as pd
import numpy as np
from pybedtools import BedTool


def load_gwas_snps(gwas_file, trait):
    """
    Load and process GWAS SNPs for a specific trait.
    
    Parameters
    ----------
    gwas_file : str
        Path to GWAS catalog file
    trait : str
        Trait name to filter (e.g., 'coronary artery disease')
    
    Returns
    -------
    pd.DataFrame
        Processed SNP dataframe with genomic coordinates
    BedTool
        BED format of SNP positions
    """
    gwas = pd.read_csv(gwas_file, sep='\t', header=0, low_memory=False)
    
    # Filter by trait
    snp_df = gwas[gwas['MAPPED_TRAIT'] == trait][[
        'DISEASE/TRAIT', 'REGION', 'CHR_ID', 'CHR_POS',
        'REPORTED GENE(S)', 'MAPPED_GENE', 'SNPS'
    ]].copy()
    
    # Handle SNPs with missing region info (extract from SNPS column)
    has_region = snp_df['REGION'].notna()
    
    missing_region = snp_df[~has_region].copy()
    if len(missing_region) > 0:
        missing_region['CHR_ID'] = missing_region['SNPS'].str.split(':').str[0].str.replace('chr', '')
        missing_region['CHR_POS'] = missing_region['SNPS'].str.split(':').str[-1]
        snp_df.loc[~has_region, 'CHR_ID'] = missing_region['CHR_ID']
        snp_df.loc[~has_region, 'CHR_POS'] = missing_region['CHR_POS']
    
    # Filter for standard chromosomes (1-22)
    snp_df = snp_df[snp_df['CHR_ID'].isin([str(i) for i in range(1, 23)])]
    snp_df = snp_df.reset_index(drop=True)
    
    # Clean CHR_POS (handle complex formats)
    snp_df['CHR_POS'] = snp_df['CHR_POS'].astype(str)
    snp_df['CHR_POS'] = snp_df['CHR_POS'].str.split('rs').str[-1].str.split('_').str[0]
    
    # Create BED coordinates
    snp_df['index_snp'] = snp_df.index
    snp_df['chrom'] = 'chr' + snp_df['CHR_ID'].astype(str)
    snp_df['start'] = snp_df['CHR_POS'].apply(lambda x: int(float(x)) if pd.notna(x) else np.nan)
    snp_df['end'] = snp_df['start'] + 1  # SNPs are single positions
    
    # Remove invalid positions
    snp_df = snp_df.dropna(subset=['start'])
    snp_df['start'] = snp_df['start'].astype(int)
    snp_df['end'] = snp_df['end'].astype(int)
    
    # Create BedTool
    snp_bed = BedTool.from_dataframe(snp_df[['chrom', 'start', 'end', 'index_snp']])
    
    print(f"Loaded {len(snp_df)} SNPs for trait: {trait}")
    
    return snp_df, snp_bed


# Example usage
gwas_file = 'gwas_catalog.tsv'
trait = 'coronary artery disease'

snp_df, snp_bed = load_gwas_snps(gwas_file, trait)

5.3 Create Promoter Reference

def create_promoter_bed(tss_file, promoter_window=2000):
    """
    Create promoter regions from TSS file.
    
    Parameters
    ----------
    tss_file : str
        Path to TSS BED file (from Section 4.1)
    promoter_window : int
        Window size around TSS (default: 2000bp)
    
    Returns
    -------
    BedTool
        Promoter regions in BED format
    """
    # Columns: chr, start, end, transcript_id, score, strand, 
    #          transcript_name, transcript_type, gene_type, gene_id, gene_name, gene_type_original
    tss = pd.read_csv(tss_file, sep='\t', header=None,
                      names=['chrom', 'start', 'end', 'transcript_id', 'score', 'strand',
                             'transcript_name', 'transcript_type', 'gene_type',
                             'gene_id', 'gene_name', 'gene_type_original'])
    
    promoter = tss.copy()
    promoter['start'] = (tss['start'] - promoter_window).clip(lower=0)
    promoter['end'] = tss['end'] + promoter_window
    
    promoter_bed = BedTool.from_dataframe(promoter)
    
    return promoter_bed


promoter_bed = create_promoter_bed('gencode.v38.transcript.tss.bed')
def safe_to_dataframe(bedtool, names):
    """Safely convert BedTool to DataFrame, handling empty results."""
    if len(bedtool) == 0:
        return pd.DataFrame(columns=names)
    return bedtool.to_dataframe(names=names)


def process_loops(loops, resolution=10000, anchor_extension=1):
    """
    Convert loop anchors to BED format with optional extension.
    
    Parameters
    ----------
    loops : pd.DataFrame
        Mustache loop output
    resolution : int
        Hi-C resolution
    anchor_extension : int
        Number of bins to extend anchors
    
    Returns
    -------
    tuple
        (anchor1_bed, anchor2_bed)
    """
    extension = int(anchor_extension * resolution)
    
    # Anchor 1
    anchor1 = loops[['BIN1_CHR', 'BIN1_START', 'BIN1_END']].copy()
    anchor1.columns = ['chrom', 'start', 'end']
    anchor1['start'] = anchor1['start'] - extension
    anchor1['end'] = anchor1['end'] + extension
    anchor1['loop_idx'] = loops.index.astype(str)
    anchor1_bed = BedTool.from_dataframe(anchor1)
    
    # Anchor 2
    anchor2 = loops[['BIN2_CHROMOSOME', 'BIN2_START', 'BIN2_END']].copy()
    anchor2.columns = ['chrom', 'start', 'end']
    anchor2['start'] = anchor2['start'] - extension
    anchor2['end'] = anchor2['end'] + extension
    anchor2['loop_idx'] = loops.index.astype(str)
    anchor2_bed = BedTool.from_dataframe(anchor2)
    
    return anchor1_bed, anchor2_bed


def annotate_gwas_loops(loops_file, snp_bed, snp_df, promoter_bed, 
                        resolution=10000, anchor_extension=1):
    """
    Find loops connecting GWAS SNPs to gene promoters.
    
    A loop is annotated if:
    - One anchor overlaps a GWAS SNP
    - The other anchor overlaps a gene promoter
    
    Parameters
    ----------
    loops_file : str
        Path to Mustache loop output
    snp_bed : BedTool
        GWAS SNP positions
    snp_df : pd.DataFrame
        GWAS SNP metadata
    promoter_bed : BedTool
        Promoter regions
    resolution : int
        Hi-C resolution
    anchor_extension : int
        Number of bins to extend anchors
    
    Returns
    -------
    pd.DataFrame
        SNP-promoter links via chromatin loops
    """
    # Load loops
    loops = pd.read_csv(loops_file, sep='\t')
    
    # Get loop anchors
    anchor1_bed, anchor2_bed = process_loops(loops, resolution, anchor_extension)
    
    # Find anchor-SNP overlaps
    anchor1_snp = anchor1_bed.intersect(snp_bed, wa=True, wb=True)
    anchor1_snp_df = safe_to_dataframe(
        anchor1_snp,
        names=['chrom', 'anchor1_start', 'anchor1_end', 'loop_idx',
               'snp_chrom', 'snp_start', 'snp_end', 'index_snp']
    )
    
    anchor2_snp = anchor2_bed.intersect(snp_bed, wa=True, wb=True)
    anchor2_snp_df = safe_to_dataframe(
        anchor2_snp,
        names=['chrom', 'anchor2_start', 'anchor2_end', 'loop_idx',
               'snp_chrom', 'snp_start', 'snp_end', 'index_snp']
    )
    
    results = []
    
    # Case 1: Anchor1 has SNP -> check if Anchor2 has promoter
    if not anchor1_snp_df.empty:
        # Get anchor2 of loops where anchor1 has SNP
        loops_with_snp = anchor1_snp_df['loop_idx'].unique()
        anchor2_subset = anchor2_bed.to_dataframe(names=['chrom', 'start', 'end', 'loop_idx'])
        anchor2_subset = anchor2_subset[anchor2_subset['loop_idx'].isin(loops_with_snp)]
        
        if not anchor2_subset.empty:
            anchor2_subset_bed = BedTool.from_dataframe(anchor2_subset)
            anchor2_promoter = anchor2_subset_bed.intersect(promoter_bed, wa=True, wb=True)
            anchor2_promoter_df = safe_to_dataframe(
                anchor2_promoter,
                names=['chrom', 'anchor2_start', 'anchor2_end', 'loop_idx',
                       'promoter_chrom', 'promoter_start', 'promoter_end',
                       'transcript_id', 'score', 'strand', 'transcript_name',
                       'transcript_type', 'gene_type', 'gene_id', 'gene_name', 'gene_type_original']
            )
            
            if not anchor2_promoter_df.empty:
                # Merge SNP info with promoter info
                merged = anchor1_snp_df.merge(
                    anchor2_promoter_df[['loop_idx', 'promoter_start', 'promoter_end', 
                                         'gene_name', 'gene_id', 'gene_type']],
                    on='loop_idx'
                )
                merged['link_type'] = 'anchor1_SNP/anchor2_promoter'
                results.append(merged)
    
    # Case 2: Anchor2 has SNP -> check if Anchor1 has promoter
    if not anchor2_snp_df.empty:
        # Get anchor1 of loops where anchor2 has SNP
        loops_with_snp = anchor2_snp_df['loop_idx'].unique()
        anchor1_subset = anchor1_bed.to_dataframe(names=['chrom', 'start', 'end', 'loop_idx'])
        anchor1_subset = anchor1_subset[anchor1_subset['loop_idx'].isin(loops_with_snp)]
        
        if not anchor1_subset.empty:
            anchor1_subset_bed = BedTool.from_dataframe(anchor1_subset)
            anchor1_promoter = anchor1_subset_bed.intersect(promoter_bed, wa=True, wb=True)
            anchor1_promoter_df = safe_to_dataframe(
                anchor1_promoter,
                names=['chrom', 'anchor1_start', 'anchor1_end', 'loop_idx',
                       'promoter_chrom', 'promoter_start', 'promoter_end',
                       'transcript_id', 'score', 'strand', 'transcript_name',
                       'transcript_type', 'gene_type', 'gene_id', 'gene_name', 'gene_type_original']
            )
            
            if not anchor1_promoter_df.empty:
                # Merge SNP info with promoter info
                merged = anchor2_snp_df.merge(
                    anchor1_promoter_df[['loop_idx', 'promoter_start', 'promoter_end',
                                         'gene_name', 'gene_id', 'gene_type']],
                    on='loop_idx'
                )
                merged['link_type'] = 'anchor2_SNP/anchor1_promoter'
                results.append(merged)
    
    if not results:
        print("No GWAS-promoter loops found")
        return pd.DataFrame()
    
    # Combine results
    combined = pd.concat(results, ignore_index=True)
    
    # Filter for protein-coding genes
    combined = combined[combined['gene_type'] == 'protein_coding']
    
    # Add GWAS metadata
    combined['index_snp'] = combined['index_snp'].astype(int)
    combined = combined.merge(
        snp_df[['index_snp', 'DISEASE/TRAIT', 'MAPPED_GENE', 'SNPS']],
        on='index_snp',
        how='left'
    )
    
    # Remove duplicates
    combined = combined.drop_duplicates(subset=['gene_name', 'index_snp', 'loop_idx'])
    
    return combined


# Example usage
gwas_loops = annotate_gwas_loops(
    loops_file='CD14_Monocytes_loops.tsv',
    snp_bed=snp_bed,
    snp_df=snp_df,
    promoter_bed=promoter_bed,
    resolution=10000,
    anchor_extension=1
)

# Save results
gwas_loops.to_csv('CD14_Monocytes_gwas_target_genes.tsv', sep='\t', index=False)

print(f"Found {len(gwas_loops)} GWAS-gene links")
print(f"Involving {gwas_loops['index_snp'].nunique()} unique SNPs")
print(f"Linked to {gwas_loops['gene_name'].nunique()} unique genes")

5.5 Multi-Cell-Type Analysis

Compare GWAS-gene links across multiple cell types to identify cell-type-specific regulatory mechanisms:

def analyze_gwas_across_celltypes(cell_types, loops_dir, snp_bed, snp_df, promoter_bed,
                                   resolution=10000, anchor_extension=1):
    """
    Analyze GWAS-gene links across multiple cell types.
    
    Parameters
    ----------
    cell_types : list
        List of cell type names
    loops_dir : str
        Directory containing loop files (expects {cell_type}.loops or {cell_type}.tsv)
    snp_bed : BedTool
        GWAS SNP positions
    snp_df : pd.DataFrame
        GWAS SNP metadata
    promoter_bed : BedTool
        Promoter regions
    
    Returns
    -------
    dict
        Dictionary of results per cell type
    pd.DataFrame
        Summary of SNP frequency across cell types
    """
    results = {}
    
    for cell_type in cell_types:
        # Try different file naming conventions
        for suffix in ['.loops', '.loop1', '_loops.tsv', '.tsv']:
            loops_file = f"{loops_dir}/{cell_type}{suffix}"
            if os.path.exists(loops_file):
                break
        else:
            print(f"Warning: No loop file found for {cell_type}")
            continue
        
        print(f"Processing {cell_type}...")
        
        gwas_loops = annotate_gwas_loops(
            loops_file=loops_file,
            snp_bed=snp_bed,
            snp_df=snp_df,
            promoter_bed=promoter_bed,
            resolution=resolution,
            anchor_extension=anchor_extension
        )
        
        results[cell_type] = gwas_loops
        
        if not gwas_loops.empty:
            print(f"  {len(gwas_loops)} links, {gwas_loops['gene_name'].nunique()} genes")
        else:
            print(f"  No links found")
    
    # Calculate SNP frequency across cell types
    snp_freq = calculate_snp_frequency(results)
    
    return results, snp_freq


def calculate_snp_frequency(results_dict):
    """
    Calculate how many cell types each SNP is linked to a gene.
    
    Parameters
    ----------
    results_dict : dict
        Output from analyze_gwas_across_celltypes()
    
    Returns
    -------
    pd.DataFrame
        SNP frequency table
    """
    all_snps = []
    
    for cell_type, df in results_dict.items():
        if df.empty:
            continue
        snps = df[['index_snp', 'gene_name']].drop_duplicates()
        snps['cell_type'] = cell_type
        all_snps.append(snps)
    
    if not all_snps:
        return pd.DataFrame(columns=['index_snp', 'n_cell_types', 'cell_types', 'genes'])
    
    combined = pd.concat(all_snps, ignore_index=True)
    
    freq = combined.groupby('index_snp').agg(
        n_cell_types=('cell_type', 'nunique'),
        cell_types=('cell_type', lambda x: ','.join(sorted(set(x)))),
        genes=('gene_name', lambda x: ','.join(sorted(set(x))))
    ).reset_index()
    
    freq = freq.sort_values('n_cell_types', ascending=False)
    
    return freq


# Example usage
import os

cell_types = ['CD14_Monocytes', 'CD4_T_cells', 'B_cells', 'NK_cells']
loops_dir = './aggregated/'

results, snp_freq = analyze_gwas_across_celltypes(
    cell_types=cell_types,
    loops_dir=loops_dir,
    snp_bed=snp_bed,
    snp_df=snp_df,
    promoter_bed=promoter_bed
)

# Save frequency table
snp_freq.to_csv(f'{trait.replace(" ", "_")}_snp_frequency.tsv', sep='\t', index=False)

# Find cell-type-specific links (only in one cell type)
specific_snps = snp_freq[snp_freq['n_cell_types'] == 1]
print(f"Cell-type-specific SNP-gene links: {len(specific_snps)}")

# Find shared links (in all cell types)
shared_snps = snp_freq[snp_freq['n_cell_types'] == len(cell_types)]
print(f"Shared across all cell types: {len(shared_snps)}")

5.6 Output Summary

The GWAS annotation produces the following outputs:

FileDescription
{cell_type}_gwas_target_genes.tsvSNP-gene links for each cell type
{trait}_snp_frequency.tsvSummary of SNPs across cell types

Key columns in the output:

ColumnDescription
index_snpSNP index in the filtered GWAS catalog
snp_startGenomic position of the SNP
gene_nameTarget gene linked via chromatin loop
gene_idEnsembl gene ID
DISEASE/TRAITAssociated disease/trait from GWAS catalog
MAPPED_GENEGene mapped by GWAS (for comparison)
link_typeWhich anchor contains SNP vs promoter
loop_idxIndex of the connecting loop