MA Plot for Differential Expression — Seaborn

An MA plot (M-versus-A plot) visualizes the relationship between log fold change (M) and mean average expression (A) when comparing two experimental conditions. Each point represents a gene or feature, with significantly differentially expressed genes highlighted. This plot is a standard diagnostic tool in RNA-seq and microarray analysis for assessing differential expression results and detecting systematic expression-dependent bias.

MA Plot for Differential Expression rendered with Seaborn

Python source (Seaborn)

""" anyplot.ai
ma-differential-expression: MA Plot for Differential Expression
Library: seaborn 0.13.2 | Python 3.13.14
Quality: 88/100 | Updated: 2026-06-21
"""

import os
import sys


# Remove the script's own directory from sys.path so that sibling files
# (matplotlib.py, seaborn.py, etc.) don't shadow installed packages.
try:
    _here = os.path.realpath(os.path.dirname(__file__))
except NameError:
    _here = os.path.realpath(os.getcwd())
sys.path = [p for p in sys.path if p and os.path.realpath(p) != _here]

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from statsmodels.nonparametric.smoothers_lowess import lowess as sm_lowess


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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — canonical order, first series always #009E73
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]
ANYPLOT_AMBER = "#DDCC77"  # warning / caution (outside categorical pool)

# Semantic mapping: up=green (positive/gain), down=matte-red (loss/negative)
UP_COLOR = IMPRINT_PALETTE[0]  # #009E73 — first Imprint series, semantically "positive/up"
SIG_COLOR = IMPRINT_PALETTE[1]  # #C475FD lavender — significant but sub-threshold fold change
DOWN_COLOR = IMPRINT_PALETTE[4]  # #AE3030 matte red — semantic anchor for loss/negative
NSIG_COLOR = INK_MUTED  # theme-adaptive muted gray for background noise

LOESS_COLOR = IMPRINT_PALETTE[2]  # #4467A3 blue — trend overlay
THRESHOLD_COLOR = ANYPLOT_AMBER  # #DDCC77 — caution/threshold lines

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.15,
        "legend.facecolor": ELEVATED_BG,
        "legend.edgecolor": INK_SOFT,
    },
)

# --- Data ---
np.random.seed(42)
n_genes = 15000

mean_expression = np.random.exponential(scale=3, size=n_genes) + 1
log_fold_change = np.random.normal(0, 0.5, n_genes)

n_de = int(n_genes * 0.08)
de_indices = np.random.choice(n_genes, n_de, replace=False)
log_fold_change[de_indices] += np.random.choice([-1, 1], n_de) * np.random.uniform(1.5, 4, n_de)

p_values = np.ones(n_genes)
p_values[de_indices] = 10 ** (-np.random.uniform(2, 10, n_de))
p_values[~np.isin(np.arange(n_genes), de_indices)] = np.random.uniform(0.01, 1.0, n_genes - n_de)

significant = p_values < 0.05

status = np.where(
    ~significant,
    "Not significant",
    np.where(log_fold_change > 1, "Up-regulated", np.where(log_fold_change < -1, "Down-regulated", "Significant")),
)

# Gene names spread across expression range for spatial storytelling
gene_names = [None] * n_genes
top_gene_labels = ["BRCA1", "TP53", "MYC", "EGFR", "VEGFA", "IL6"]
sig_de_mask = significant & (np.abs(log_fold_change) > 1)
sig_de_indices = np.where(sig_de_mask)[0]
sig_de_expr = mean_expression[sig_de_indices]
sig_de_abs_lfc = np.abs(log_fold_change[sig_de_indices])

expr_min, expr_max = sig_de_expr.min(), sig_de_expr.max()
n_labels = len(top_gene_labels)
expr_edges = np.linspace(expr_min, expr_max + 0.01, n_labels + 1)
top_sig = []
for b in range(n_labels):
    in_bin = (sig_de_expr >= expr_edges[b]) & (sig_de_expr < expr_edges[b + 1])
    if not np.any(in_bin):
        continue
    bin_idx = np.where(in_bin)[0]
    best = bin_idx[np.argmax(sig_de_abs_lfc[bin_idx])]
    top_sig.append(sig_de_indices[best])

for i, idx in enumerate(top_sig[:n_labels]):
    gene_names[idx] = top_gene_labels[i]

df = pd.DataFrame(
    {
        "Mean Expression (A)": mean_expression,
        "Log₂ Fold Change (M)": log_fold_change,
        "Status": pd.Categorical(
            status, categories=["Not significant", "Significant", "Up-regulated", "Down-regulated"]
        ),
        "gene_name": gene_names,
    }
)

status_palette = {
    "Not significant": NSIG_COLOR,
    "Significant": SIG_COLOR,
    "Up-regulated": UP_COLOR,
    "Down-regulated": DOWN_COLOR,
}

# --- Precompute LOESS + bootstrap CI (seaborn confidence-band pattern) ---
x_all = df["Mean Expression (A)"].values
y_all = df["Log₂ Fold Change (M)"].values
sort_full = np.argsort(x_all)
x_sorted, y_sorted = x_all[sort_full], y_all[sort_full]

# Full-data LOESS for annotation placement
loess_full = sm_lowess(y_sorted, x_sorted, frac=0.3, return_sorted=True)

# Bootstrap CI on 2k subsample for speed (pattern mirrors seaborn's CI bands)
rng_boot = np.random.default_rng(0)
sub_idx = np.sort(rng_boot.choice(len(x_sorted), 2000, replace=False))
xs_b, ys_b = x_sorted[sub_idx], y_sorted[sub_idx]
x_grid = np.linspace(xs_b[0], xs_b[-1], 200)

n_boot = 80
boot_curves = np.empty((n_boot, len(x_grid)))
for bi in range(n_boot):
    ri = rng_boot.integers(0, len(xs_b), len(xs_b))
    xr, yr = xs_b[ri], ys_b[ri]
    s = np.argsort(xr)
    lw = sm_lowess(yr[s], xr[s], frac=0.3, return_sorted=True)
    boot_curves[bi] = np.interp(x_grid, lw[:, 0], lw[:, 1])

ci_lo = np.percentile(boot_curves, 2.5, axis=0)
ci_hi = np.percentile(boot_curves, 97.5, axis=0)

# --- Plot ---
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400)
fig.patch.set_facecolor(PAGE_BG)
ax.set_facecolor(PAGE_BG)

# LOESS 95% CI band drawn first so data points render on top
ax.fill_between(x_grid, ci_lo, ci_hi, color=LOESS_COLOR, alpha=0.12, zorder=1, label="95% CI")

# Reference lines
ax.axhline(y=0, color=INK_SOFT, linewidth=1.5, alpha=0.6, zorder=2)
ax.axhline(y=1, color=THRESHOLD_COLOR, linewidth=1.2, linestyle="--", alpha=0.85, zorder=2)
ax.axhline(y=-1, color=THRESHOLD_COLOR, linewidth=1.2, linestyle="--", alpha=0.85, zorder=2)

# Background layer: all 15k genes, small + transparent for density
sns.scatterplot(
    data=df,
    x="Mean Expression (A)",
    y="Log₂ Fold Change (M)",
    hue="Status",
    hue_order=["Not significant", "Significant", "Up-regulated", "Down-regulated"],
    palette=status_palette,
    size="Status",
    sizes={"Not significant": 6, "Significant": 14, "Up-regulated": 20, "Down-regulated": 20},
    alpha=0.3,
    edgecolor="none",
    legend="full",
    ax=ax,
)

# Emphasis layer: DE genes with subtle edge for definition
de_data = df[df["Status"].isin(["Up-regulated", "Down-regulated"])]
sns.scatterplot(
    data=de_data,
    x="Mean Expression (A)",
    y="Log₂ Fold Change (M)",
    hue="Status",
    hue_order=["Up-regulated", "Down-regulated"],
    palette={"Up-regulated": UP_COLOR, "Down-regulated": DOWN_COLOR},
    s=20,
    alpha=0.6,
    edgecolor=ELEVATED_BG,
    linewidth=0.3,
    legend=False,
    ax=ax,
)

# Threshold annotations (raised to 8pt for mobile legibility)
xlim = ax.get_xlim()
x_lbl = xlim[1] * 0.97
ax.text(x_lbl, 1.12, "2-fold ↑", fontsize=8, color=THRESHOLD_COLOR, ha="right", fontstyle="italic")
ax.text(x_lbl, -1.28, "2-fold ↓", fontsize=8, color=THRESHOLD_COLOR, ha="right", fontstyle="italic")

# LOESS smoothing curve (seaborn-distinctive lowess via regplot)
sns.regplot(
    data=df,
    x="Mean Expression (A)",
    y="Log₂ Fold Change (M)",
    lowess=True,
    scatter=False,
    line_kws={"color": LOESS_COLOR, "linewidth": 2.0, "alpha": 0.9, "label": "LOESS trend"},
    ax=ax,
)

# In-plot annotation foregrounding the key finding: flat LOESS = no expression bias
x_annot = float(np.percentile(x_all, 86))
y_annot = float(np.interp(x_annot, loess_full[:, 0], loess_full[:, 1]))
ax.text(
    x_annot,
    y_annot + 0.22,
    "No expression bias",
    fontsize=8,
    fontstyle="italic",
    color=INK_MUTED,
    ha="right",
    va="bottom",
)

# Gene labels spread across expression range with refined offsets and thinner arrowheads
labeled = df[df["gene_name"].notna()].copy()
label_positions = []
for _, row in labeled.iterrows():
    x_val = row["Mean Expression (A)"]
    y_val = row["Log₂ Fold Change (M)"]
    y_off = -24 if y_val > 0 else 24
    x_off = 20 if x_val < df["Mean Expression (A)"].median() else -20
    for px, py in label_positions:
        if abs(x_val - px) < 2 and abs(y_val - py) < 1:
            y_off = y_off + (30 if y_off > 0 else -30)
            break
    label_positions.append((x_val, y_val))
    ax.annotate(
        row["gene_name"],
        xy=(x_val, y_val),
        xytext=(x_off, y_off),
        textcoords="offset points",
        fontsize=8,
        fontweight="bold",
        color=INK,
        arrowprops={"arrowstyle": "->", "color": INK_SOFT, "lw": 0.6, "connectionstyle": "arc3,rad=0.15"},
        bbox={
            "boxstyle": "round,pad=0.2",
            "facecolor": ELEVATED_BG,
            "edgecolor": INK_SOFT,
            "alpha": 0.92,
            "linewidth": 0.4,
        },
    )

# Style
sns.despine(ax=ax)
ax.set_xlabel("Mean Expression (A)", fontsize=10, color=INK)
ax.set_ylabel("Log₂ Fold Change (M)", fontsize=10, color=INK)
ax.set_title(
    "ma-differential-expression · python · seaborn · anyplot.ai", fontsize=12, fontweight="medium", pad=10, color=INK
)
ax.tick_params(axis="both", labelsize=8, colors=INK_SOFT)
ax.yaxis.grid(True, alpha=0.15, linewidth=0.6, color=INK)

sns.move_legend(ax, "upper right", fontsize=8, framealpha=0.92, title="Gene Status", title_fontsize=8)

fig.subplots_adjust(left=0.10, right=0.97, top=0.91, bottom=0.13)
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)
plt.close()

Part of MA Plot for Differential Expression on anyplot.ai.

Other implementations