Pharmacological Dose-Response Curve — ggplot2

A sigmoidal dose-response curve that plots biological response against drug concentration on a logarithmic x-axis, fitted using a four-parameter logistic (4PL) model. This visualization is essential for determining drug potency metrics such as EC50 (half-maximal effective concentration) or IC50 (half-maximal inhibitory concentration), Hill slope steepness, and upper/lower response asymptotes. It enables rapid visual comparison of compound efficacy and is a standard tool in pharmacological analysis.

Pharmacological Dose-Response Curve rendered with ggplot2

Renders

R source (ggplot2)

#' anyplot.ai
#' curve-dose-response: Pharmacological Dose-Response Curve
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 94/100 | Created: 2026-06-24

library(ggplot2)
library(dplyr)
library(tibble)
library(ragg)

set.seed(42)

# Theme tokens
THEME       <- Sys.getenv("ANYPLOT_THEME", "light")
PAGE_BG     <- if (THEME == "light") "#FAF8F1" else "#1A1A17"
ELEVATED_BG <- if (THEME == "light") "#FFFDF6" else "#242420"
INK         <- if (THEME == "light") "#1A1A17" else "#F0EFE8"
INK_SOFT    <- if (THEME == "light") "#4A4A44" else "#B8B7B0"
INK_MUTED   <- if (THEME == "light") "#6B6A63" else "#A8A79F"

IMPRINT_PALETTE <- c(
    "#009E73", "#C475FD", "#4467A3", "#BD8233",
    "#AE3030", "#2ABCCD", "#954477", "#99B314"
)

# Data — concentrations in nM, 10 points per compound spanning 6 decades
conc_obs_nM <- 10^seq(-1, 5, length.out = 10)

resp_a_true <- 4 + (94 - 4) / (1 + (20  / conc_obs_nM)^1.8)
resp_b_true <- 6 + (88 - 6) / (1 + (800 / conc_obs_nM)^1.1)

df <- tibble(
    conc     = rep(conc_obs_nM, 2),
    response = c(
        pmax(0, pmin(100, resp_a_true + rnorm(10, 0, 4))),
        pmax(0, pmin(100, resp_b_true + rnorm(10, 0, 4)))
    ),
    sem      = c(runif(10, 1.5, 3.5), runif(10, 1.5, 3.5)),
    compound = rep(c("Compound A", "Compound B"), each = 10)
)

# Fit 4PL models via nonlinear least squares
fit_a <- nls(
    response ~ Bottom + (Top - Bottom) / (1 + (EC50 / conc)^Hill),
    data  = filter(df, compound == "Compound A"),
    start = list(Bottom = 4, Top = 94, EC50 = 20, Hill = 1.5)
)

fit_b <- nls(
    response ~ Bottom + (Top - Bottom) / (1 + (EC50 / conc)^Hill),
    data  = filter(df, compound == "Compound B"),
    start = list(Bottom = 6, Top = 88, EC50 = 800, Hill = 1.0)
)

# Fine concentration grid for smooth curves
conc_fine <- 10^seq(-1.5, 5.5, length.out = 400)

coef_a <- coef(fit_a)
coef_b <- coef(fit_b)
vcov_a <- vcov(fit_a)

pred_a <- predict(fit_a, newdata = data.frame(conc = conc_fine))
pred_b <- predict(fit_b, newdata = data.frame(conc = conc_fine))

# 95% confidence band for Compound A via delta method (gradient of 4PL w.r.t. params)
se_a <- sapply(conc_fine, function(x) {
    B <- coef_a["Bottom"]; Tp <- coef_a["Top"]
    E <- coef_a["EC50"];   H  <- coef_a["Hill"]
    r <- (E / x)^H; denom <- 1 + r
    g <- c(r / denom, 1 / denom,
           -(Tp - B) * H * r / (E * denom^2),
           -(Tp - B) * r * log(E / x) / denom^2)
    sqrt(as.numeric(t(g) %*% vcov_a %*% g))
})

df_curves <- tibble(
    conc     = rep(conc_fine, 2),
    response = c(pred_a, pred_b),
    compound = rep(c("Compound A", "Compound B"), each = 400)
)

df_ci_a <- tibble(
    conc = conc_fine,
    ymin = pred_a - 1.96 * se_a,
    ymax = pred_a + 1.96 * se_a
)

# EC50 and half-response values for crosshair annotations
ec50_a <- coef_a["EC50"]
ec50_b <- coef_b["EC50"]
hmid_a <- (coef_a["Bottom"] + coef_a["Top"]) / 2
hmid_b <- (coef_b["Bottom"] + coef_b["Top"]) / 2
x_left  <- 10^(-1.5)
x_right <- 10^5.5

comp_colors <- c(
    "Compound A" = IMPRINT_PALETTE[1],
    "Compound B" = IMPRINT_PALETTE[2]
)

# Plot
p <- ggplot() +
    # 95% CI ribbon — Compound A only
    geom_ribbon(
        data  = df_ci_a,
        aes(x = conc, ymin = ymin, ymax = ymax),
        fill  = IMPRINT_PALETTE[1],
        alpha = 0.18
    ) +
    # Top and bottom asymptote dashed lines (Compound A)
    geom_hline(
        yintercept = coef_a["Top"],
        linetype = "dashed", color = INK_SOFT, linewidth = 0.4
    ) +
    geom_hline(
        yintercept = coef_a["Bottom"],
        linetype = "dashed", color = INK_SOFT, linewidth = 0.4
    ) +
    # Asymptote labels at right margin
    annotate(
        "text",
        x = x_right * 0.88, y = coef_a["Top"] + 3.5,
        label = "Top", color = INK_MUTED, size = 2.6, hjust = 1, fontface = "italic"
    ) +
    annotate(
        "text",
        x = x_right * 0.88, y = coef_a["Bottom"] + 3.5,
        label = "Bottom", color = INK_MUTED, size = 2.6, hjust = 1, fontface = "italic"
    ) +
    # EC50 crosshair — Compound A
    annotate(
        "segment",
        x = ec50_a, xend = ec50_a, y = -3, yend = hmid_a,
        linetype = "dashed", color = IMPRINT_PALETTE[1], linewidth = 0.7
    ) +
    annotate(
        "segment",
        x = x_left, xend = ec50_a, y = hmid_a, yend = hmid_a,
        linetype = "dashed", color = IMPRINT_PALETTE[1], linewidth = 0.7
    ) +
    # EC50 crosshair — Compound B
    annotate(
        "segment",
        x = ec50_b, xend = ec50_b, y = -3, yend = hmid_b,
        linetype = "dashed", color = IMPRINT_PALETTE[2], linewidth = 0.7
    ) +
    annotate(
        "segment",
        x = x_left, xend = ec50_b, y = hmid_b, yend = hmid_b,
        linetype = "dashed", color = IMPRINT_PALETTE[2], linewidth = 0.7
    ) +
    # Fitted 4PL curves
    geom_line(
        data      = df_curves,
        aes(x = conc, y = response, color = compound),
        linewidth = 1.4
    ) +
    # Observed data: error bars then points (bars behind points)
    geom_errorbar(
        data      = df,
        aes(x = conc, ymin = response - sem, ymax = response + sem, color = compound),
        width     = 0.08,
        linewidth = 0.7
    ) +
    geom_point(
        data   = df,
        aes(x = conc, y = response, color = compound),
        size   = 2.5,
        shape  = 21,
        fill   = PAGE_BG,
        stroke = 1.2
    ) +
    # EC50 label annotations with background box for visual weight
    annotate(
        "label",
        x = ec50_a * 2.0, y = hmid_a - 10,
        label        = sprintf("EC50 = %.0f nM", ec50_a),
        color        = IMPRINT_PALETTE[1],
        fill         = ELEVATED_BG,
        label.size   = 0.15,
        label.padding = unit(0.12, "lines"),
        size = 3.0, hjust = 0, fontface = "italic"
    ) +
    annotate(
        "label",
        x = ec50_b * 1.8, y = hmid_b + 8,
        label        = sprintf("EC50 = %.0f nM", ec50_b),
        color        = IMPRINT_PALETTE[2],
        fill         = ELEVATED_BG,
        label.size   = 0.15,
        label.padding = unit(0.12, "lines"),
        size = 3.0, hjust = 0, fontface = "italic"
    ) +
    # Potency ratio annotation
    annotate(
        "text",
        x = 10^4, y = 30,
        label = sprintf("Potency ratio (B/A): %.0f×", ec50_b / ec50_a),
        color = INK_MUTED, size = 2.8, hjust = 0.5, fontface = "italic"
    ) +
    # Axis scales
    scale_x_log10(
        breaks = c(0.1, 1, 10, 100, 1000, 10000, 100000),
        labels = c("0.1", "1", "10", "100", "1k", "10k", "100k"),
        limits = c(x_left, x_right),
        name   = "Concentration (nM)"
    ) +
    scale_y_continuous(
        breaks = seq(0, 100, 20),
        limits = c(-5, 108),
        name   = "Response (%)"
    ) +
    scale_color_manual(values = comp_colors, name = NULL) +
    labs(title = "curve-dose-response · r · ggplot2 · anyplot.ai") +
    theme_minimal(base_size = 8) +
    theme(
        plot.background   = element_rect(fill = PAGE_BG, color = PAGE_BG),
        panel.background  = element_rect(fill = PAGE_BG, color = NA),
        panel.grid.major  = element_line(color = INK_MUTED, linewidth = 0.15),
        panel.grid.minor  = element_blank(),
        panel.border      = element_blank(),
        axis.line.x       = element_line(color = INK_SOFT, linewidth = 0.4),
        axis.line.y       = element_line(color = INK_SOFT, linewidth = 0.4),
        axis.ticks        = element_line(color = INK_SOFT, linewidth = 0.3),
        axis.title        = element_text(color = INK,      size = 10),
        axis.text         = element_text(color = INK_SOFT, size = 8),
        plot.title        = element_text(
            color = INK, size = 12, hjust = 0.5,
            margin = margin(b = 8)
        ),
        legend.background = element_rect(
            fill = ELEVATED_BG, color = INK_MUTED, linewidth = 0.25
        ),
        legend.text       = element_text(color = INK_SOFT, size = 8),
        legend.key.size        = unit(1.2, "lines"),
        legend.position        = "inside",
        legend.position.inside = c(0.13, 0.78),
        plot.margin            = margin(t = 10, r = 15, b = 10, l = 10)
    )

# Save
ggsave(
    filename = sprintf("plot-%s.png", THEME),
    plot     = p,
    device   = ragg::agg_png,
    width    = 8,
    height   = 4.5,
    units    = "in",
    dpi      = 400
)

Part of Pharmacological Dose-Response Curve on anyplot.ai.

Other implementations