Manhattan Plot for GWAS — Seaborn

A Manhattan plot visualizes genome-wide association study (GWAS) results by displaying -log10 transformed p-values across chromosomal positions. Points are arranged by genomic position along the x-axis with alternating colors for each chromosome, making it easy to identify significant associations. A horizontal threshold line indicates genome-wide significance (typically p < 5×10⁻⁸). This plot is essential for identifying genetic variants associated with traits or diseases.

Manhattan Plot for GWAS rendered with Seaborn

Python source (Seaborn)

""" anyplot.ai
manhattan-gwas: Manhattan Plot for GWAS
Library: seaborn 0.13.2 | Python 3.13.13
Quality: 94/100 | Updated: 2026-05-15
"""

import os

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# Okabe-Ito palette for alternating chromosomes
OKABE_ITO_1 = "#009E73"  # bluish green (brand)
OKABE_ITO_2 = "#C475FD"  # vermillion

# Data - Simulate GWAS data with realistic structure
np.random.seed(42)

# Define chromosomes with approximate sizes (in Mb)
chromosomes = [str(i) for i in range(1, 23)]
chrom_sizes = [250, 243, 198, 190, 182, 171, 159, 145, 138, 133, 135, 133, 114, 107, 102, 90, 83, 80, 59, 64, 47, 51]

# Generate SNPs for each chromosome
data = []
cumulative_pos = 0
chrom_centers = {}
chrom_boundaries = [0]

for chrom, size in zip(chromosomes, chrom_sizes, strict=True):
    # Number of SNPs proportional to chromosome size
    n_snps = int(size * 40)  # ~40 SNPs per Mb, total ~10k SNPs

    # Random positions along chromosome
    positions = np.sort(np.random.randint(0, size * 1_000_000, n_snps))

    # Generate p-values - mostly non-significant with some peaks
    # Use beta distribution to get realistic p-value distribution
    p_values = np.random.beta(1, 1, n_snps)

    # Add significant peaks on specific chromosomes
    if chrom == "6":  # Major peak on chr6 (like MHC region)
        peak_region = (positions > 25_000_000) & (positions < 35_000_000)
        p_values[peak_region] = 10 ** (-np.random.uniform(7, 12, peak_region.sum()))
    elif chrom == "11":  # Moderate peak
        peak_region = (positions > 60_000_000) & (positions < 70_000_000)
        p_values[peak_region] = 10 ** (-np.random.uniform(6, 9, peak_region.sum()))
    elif chrom == "2":  # Smaller peak
        peak_region = (positions > 100_000_000) & (positions < 110_000_000)
        p_values[peak_region] = 10 ** (-np.random.uniform(5.5, 8, peak_region.sum()))

    # Calculate cumulative position
    cumulative_positions = positions + cumulative_pos

    # Store center for axis label
    chrom_centers[chrom] = cumulative_pos + (size * 1_000_000) / 2

    for pos, cum_pos, pval in zip(positions, cumulative_positions, p_values, strict=True):
        data.append(
            {
                "chromosome": chrom,
                "position": pos,
                "cumulative_position": cum_pos,
                "p_value": pval,
                "neg_log_p": -np.log10(pval),
            }
        )

    cumulative_pos += size * 1_000_000
    chrom_boundaries.append(cumulative_pos)

df = pd.DataFrame(data)

# Create alternating color groups for chromosomes
df["color_group"] = df["chromosome"].apply(lambda x: OKABE_ITO_1 if int(x) % 2 == 1 else OKABE_ITO_2)

# Configure theme-adaptive styling
sns.set_theme(
    style="ticks",
    rc={
        "figure.facecolor": PAGE_BG,
        "axes.facecolor": PAGE_BG,
        "axes.edgecolor": INK_SOFT,
        "axes.labelcolor": INK,
        "text.color": INK,
        "xtick.color": INK_SOFT,
        "ytick.color": INK_SOFT,
        "grid.color": INK,
        "grid.alpha": 0.10,
        "legend.facecolor": ELEVATED_BG,
        "legend.edgecolor": INK_SOFT,
    },
)

# Plot
fig, ax = plt.subplots(figsize=(16, 9), facecolor=PAGE_BG)

# Plot non-significant SNPs with alternating colors
for color in [OKABE_ITO_1, OKABE_ITO_2]:
    subset = df[df["color_group"] == color]
    ax.scatter(
        subset["cumulative_position"], subset["neg_log_p"], c=color, s=20, alpha=0.7, edgecolor="none", rasterized=True
    )

# Highlight significant SNPs (above genome-wide threshold)
significant = df[df["neg_log_p"] > 7.3]
if len(significant) > 0:
    ax.scatter(
        significant["cumulative_position"],
        significant["neg_log_p"],
        c=OKABE_ITO_1,
        s=60,
        alpha=0.9,
        edgecolor=INK,
        linewidth=0.8,
        zorder=5,
    )

# Genome-wide significance threshold
ax.axhline(y=7.3, color=INK_SOFT, linestyle="--", linewidth=2, alpha=0.6, label="Genome-wide significance (p < 5×10⁻⁸)")

# Suggestive threshold
ax.axhline(y=5, color=INK_SOFT, linestyle=":", linewidth=1.5, alpha=0.4, label="Suggestive (p < 1×10⁻⁵)")

# Set x-axis ticks at chromosome centers
ax.set_xticks([chrom_centers[c] for c in chromosomes])
ax.set_xticklabels(chromosomes, fontsize=16)

# Styling
ax.set_xlabel("Chromosome", fontsize=20, color=INK)
ax.set_ylabel("-log₁₀(p-value)", fontsize=20, color=INK)
ax.set_title("manhattan-gwas · seaborn · anyplot.ai", fontsize=24, fontweight="medium", color=INK)
ax.tick_params(axis="both", labelsize=16)

# Set axis limits
ax.set_xlim(0, cumulative_pos)
ax.set_ylim(0, max(df["neg_log_p"]) * 1.05)

# Remove top and right spines
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color(INK_SOFT)
ax.spines["bottom"].set_color(INK_SOFT)

# Add legend
ax.legend(loc="upper right", fontsize=16, framealpha=0.95, edgecolor=INK_SOFT)

# Subtle grid on y-axis only
ax.yaxis.grid(True, alpha=0.10, linewidth=0.8)
ax.xaxis.grid(False)

plt.tight_layout()
plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG)

Part of Manhattan Plot for GWAS on anyplot.ai.

Other implementations