Chris Parmer — home

Manhattan plot

Genomics — GWAS summary statistics

Example from the compendium of canonical charts

Genomics — Manhattan plot (GWAS summary statistics)

Python Code

"""Genomics — Manhattan plot (GWAS summary statistics)."""


import pandas as pd
import numpy as np
import plotly.graph_objects as go

URL = "https://raw.githubusercontent.com/Cloufield/GWASTutorial/main/02_Linux_basics/sumstats.txt"

# Alternating palette for chromosomes
CHR_COLORS = [VIOLET, TEAL, PINK, GREEN, PINK, VIOLET, TEAL, PINK, GREEN, PINK,
              VIOLET, TEAL, PINK, GREEN, PINK, VIOLET, TEAL, PINK, GREEN, PINK,
              VIOLET, TEAL, PINK]

# GRCh38 approximate chromosome sizes (bp) — used for consistent x-axis spacing
CHR_SIZES = {
    "1": 248956422, "2": 242193529, "3": 198295559, "4": 190214555,
    "5": 181538259, "6": 170805979, "7": 159345973, "8": 145138636,
    "9": 138394717, "10": 133797422, "11": 135086622, "12": 133275309,
    "13": 114364328, "14": 107043718, "15": 101991189, "16": 90338345,
    "17": 83257441, "18": 80373285, "19": 58617616, "20": 64444167,
    "21": 46709983, "22": 50818468,
}


def generate():
    print("fetching GWAS summary statistics …")
    try:
        df = fetch_csv(URL, sep="\t")
        print(f"  {len(df):,} rows, cols: {list(df.columns)}")
        # Expected: #CHROM, POS, P
        chrom_col = next((c for c in df.columns if "chrom" in c.upper()), df.columns[0])
        pos_col = next((c for c in df.columns if c.upper() in ("POS", "BP", "POSITION")), df.columns[1])
        p_col = next((c for c in df.columns if c.upper() in ("P", "PVAL", "P_VALUE", "PVALUE")), df.columns[2])
        df = df[[chrom_col, pos_col, p_col]].copy()
        df.columns = ["chrom", "pos", "p"]
        df["chrom"] = (df["chrom"].astype(str)
                       .str.replace("#", "")
                       .str.replace("chr", "", case=False)
                       .str.strip())
        df["pos"] = pd.to_numeric(df["pos"], errors="coerce")
        df["p"] = pd.to_numeric(df["p"], errors="coerce")
        df = df.dropna()
        # keep standard chromosomes
        valid_chroms = [str(i) for i in range(1, 23)] + ["X", "Y"]
        df = df[df["chrom"].isin(valid_chroms)].copy()
        # sort by chrom numerically
        df["chrom_n"] = pd.Categorical(
            df["chrom"],
            categories=valid_chroms,
            ordered=True
        )
        df = df.sort_values(["chrom_n", "pos"]).reset_index(drop=True)
        # Require at least 11 chromosomes for a useful plot; fall back to synthetic otherwise
        n_chroms = df["chrom"].nunique()
        print(f"  {n_chroms} chromosomes present")
        if n_chroms < 11:
            print(f"  only {n_chroms} chromosomes — insufficient coverage, using synthetic")
            raise ValueError(f"only {n_chroms} chromosomes in real data")
        used_synthetic = False
    except Exception as e:
        print(f"  fetch failed ({e}); generating synthetic GWAS data")
        used_synthetic = True
        rng = np.random.default_rng(42)
        rows = []
        for ch in range(1, 23):
            n = rng.integers(3000, 6000)
            pos = np.sort(rng.integers(1, 250_000_000, n))
            p = rng.uniform(0, 1, n)
            # add some significant hits on chrs 3, 7, 11
            if ch in (3, 7, 11):
                peak_pos = rng.integers(50_000_000, 200_000_000, 3)
                for pp in peak_pos:
                    nearby = np.abs(pos - pp) < 500_000
                    p[nearby] = rng.uniform(1e-10, 5e-8, nearby.sum())
            rows.append(pd.DataFrame({"chrom": str(ch), "pos": pos, "p": p}))
        df = pd.concat(rows, ignore_index=True)
        valid_chroms = [str(i) for i in range(1, 23)]
        df["chrom_n"] = pd.Categorical(df["chrom"], categories=valid_chroms, ordered=True)
        df = df.sort_values(["chrom_n", "pos"]).reset_index(drop=True)

    # Build cumulative position using standard chromosome sizes for even spacing
    valid_chroms_auto = [str(c) for c in df["chrom_n"].cat.categories.tolist()]
    offset_map = {}
    cumulative = 0
    for ch in valid_chroms_auto:
        offset_map[ch] = int(cumulative)
        size = int(CHR_SIZES.get(ch, 100_000_000))
        cumulative = int(cumulative) + size + 5_000_000
    df["cum_pos"] = df["pos"].astype(int) + df["chrom"].map(offset_map)

    # -log10(p)
    df["neg_log_p"] = -np.log10(np.clip(df["p"], 1e-300, 1))

    # Chromosome midpoints for x-axis ticks
    tick_pos = df.groupby("chrom_n", observed=True)["cum_pos"].mean()
    tick_labels = tick_pos.index.tolist()

    GWAS_THRESHOLD = -np.log10(5e-8)  # ~7.3

    # Subsample non-significant points to keep JSON manageable
    sig_mask = df["neg_log_p"] >= GWAS_THRESHOLD
    ns_max = 8000
    ns_df = df[~sig_mask]
    if len(ns_df) > ns_max:
        ns_df = ns_df.sample(ns_max, random_state=42)
    df_plot = pd.concat([ns_df, df[sig_mask]]).sort_values(["chrom_n", "pos"])

    # Build traces per chromosome (use subsampled df_plot for non-significant)
    traces = []
    chroms_sorted = df["chrom_n"].cat.categories.tolist()
    for i, ch in enumerate(chroms_sorted):
        sub = df_plot[df_plot["chrom_n"] == ch]
        if len(sub) == 0:
            continue
        color = CHR_COLORS[i % len(CHR_COLORS)]
        traces.append(go.Scattergl(
            x=sub["cum_pos"],
            y=sub["neg_log_p"],
            mode="markers",
            marker=dict(size=3, opacity=0.7, color=color),
            name=f"Chr {ch}",
            showlegend=False,
            hovertemplate=f"Chr {ch}<br>pos=%{{customdata}}<br>-log10(p)=%{{y:.2f}}<extra></extra>",
            customdata=sub["pos"],
        ))

    # Significance threshold line
    x_max = df["cum_pos"].max()
    traces.append(go.Scatter(
        x=[0, x_max],
        y=[GWAS_THRESHOLD, GWAS_THRESHOLD],
        mode="lines",
        line=dict(color="red", dash="dash", width=1.2),
        name="p = 5×10⁻⁸",
        hoverinfo="skip",
    ))

    # Annotate peaks above threshold
    sig = df[df["neg_log_p"] > GWAS_THRESHOLD]
    annotations = []
    if len(sig) > 0:
        # cluster peaks by chromosome, take top per chrom
        peak_per_chr = sig.loc[sig.groupby("chrom_n", observed=True)["neg_log_p"].idxmax()]
        for _, row in peak_per_chr.head(6).iterrows():
            annotations.append(dict(
                x=row["cum_pos"],
                y=row["neg_log_p"],
                text=f"Chr {row['chrom_n']}",
                showarrow=True,
                arrowhead=2,
                arrowsize=0.8,
                ax=0, ay=-25,
                font=dict(size=10),
            ))

    fig = go.Figure(traces)
    fig.update_layout(
        xaxis=dict(
            title="Chromosome",
            tickvals=[tick_pos[ch] for ch in tick_labels if ch in tick_pos],
            ticktext=[str(ch) for ch in tick_labels if ch in tick_pos],
            tickangle=0,
            showgrid=False,
        ),
        yaxis=dict(title="−log₁₀(p)", zeroline=False),
        annotations=annotations,
        legend=dict(orientation="h", y=1.05),
        margin=dict(t=40, b=60, l=70, r=40),
        height=520,
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly