Pharmacological Dose-Response Curve — Seaborn

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 Seaborn

Python source (Seaborn)

""" anyplot.ai
curve-dose-response: Pharmacological Dose-Response Curve
Library: seaborn 0.13.2 | Python 3.13.14
Quality: 93/100 | Updated: 2026-06-24
"""

import os

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
from scipy.optimize import curve_fit


# Theme tokens — Imprint palette (see prompts/default-style-guide.md)
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"

IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]
COLOR_A = IMPRINT_PALETTE[0]  # brand green — Imatinib (first series, always #009E73)
COLOR_B = IMPRINT_PALETTE[1]  # lavender — Erlotinib
palette_dict = {"Imatinib": COLOR_A, "Erlotinib": COLOR_B}

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 — synthetic pharmacological dose-response for two kinase inhibitors
np.random.seed(42)

concentrations = np.logspace(-9, -4, 8)

bottom_a, top_a, ec50_a, hill_a = 5.0, 95.0, 3e-7, 1.2
bottom_b, top_b, ec50_b, hill_b = 10.0, 80.0, 5e-6, 0.9

# 4PL logistic: response = bottom + (top - bottom) / (1 + (ec50/conc)^hill)
logistic4pl = lambda conc, bottom, top, ec50, hill: bottom + (top - bottom) / (1 + (ec50 / conc) ** hill)

response_a = logistic4pl(concentrations, bottom_a, top_a, ec50_a, hill_a) + np.random.normal(0, 2, len(concentrations))
response_b = logistic4pl(concentrations, bottom_b, top_b, ec50_b, hill_b) + np.random.normal(0, 2, len(concentrations))
sem_a = np.random.uniform(2, 5, len(concentrations))
sem_b = np.random.uniform(2, 5, len(concentrations))

df = pd.concat(
    [
        pd.DataFrame({"concentration": concentrations, "response": response_a, "sem": sem_a, "compound": "Imatinib"}),
        pd.DataFrame({"concentration": concentrations, "response": response_b, "sem": sem_b, "compound": "Erlotinib"}),
    ],
    ignore_index=True,
)

# Fit 4PL models via scipy curve_fit
fit_params = {}
fit_cov = {}
for compound, p0 in [("Imatinib", [5, 95, 3e-7, 1.2]), ("Erlotinib", [10, 80, 5e-6, 0.9])]:
    mask = df["compound"] == compound
    popt, pcov = curve_fit(
        logistic4pl, df.loc[mask, "concentration"].values, df.loc[mask, "response"].values, p0=p0, maxfev=10000
    )
    fit_params[compound] = popt
    fit_cov[compound] = pcov

x_fit = np.logspace(-9.5, -3.5, 300)

# Parametric bootstrap 95% CI for Imatinib
n_boot = 300
param_samples = np.random.multivariate_normal(fit_params["Imatinib"], fit_cov["Imatinib"], size=n_boot)
boot_curves = np.array([logistic4pl(x_fit, *s) for s in param_samples])
ci_lo = np.percentile(boot_curves, 2.5, axis=0)
ci_hi = np.percentile(boot_curves, 97.5, axis=0)

# Long-format DataFrame for fitted curves (enables sns.lineplot hue+style mapping)
df_fit = pd.concat(
    [
        pd.DataFrame(
            {"concentration": x_fit, "response": logistic4pl(x_fit, *fit_params["Imatinib"]), "compound": "Imatinib"}
        ),
        pd.DataFrame(
            {"concentration": x_fit, "response": logistic4pl(x_fit, *fit_params["Erlotinib"]), "compound": "Erlotinib"}
        ),
    ],
    ignore_index=True,
)

# Plot — landscape 3200×1800 px (figsize=(8, 4.5) × dpi=400, no bbox_inches='tight')
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)
ax.set_facecolor(PAGE_BG)

# CI band — Imatinib only (spec: at least one fitted curve)
ax.fill_between(x_fit, ci_lo, ci_hi, color=COLOR_A, alpha=0.15, zorder=1)

# Fitted curves via sns.lineplot (idiomatic seaborn: hue+style over long-format DataFrame)
sns.lineplot(
    data=df_fit,
    x="concentration",
    y="response",
    hue="compound",
    hue_order=["Imatinib", "Erlotinib"],
    style="compound",
    dashes=False,
    palette=palette_dict,
    linewidth=2.5,
    zorder=4,
    ax=ax,
    legend=False,
)

# Data points (seaborn scatterplot for idiomatic hue+style mapping)
sns.scatterplot(
    data=df,
    x="concentration",
    y="response",
    hue="compound",
    style="compound",
    markers={"Imatinib": "o", "Erlotinib": "s"},
    palette=palette_dict,
    s=120,
    edgecolor=PAGE_BG,
    linewidth=0.8,
    zorder=5,
    ax=ax,
    legend=False,
)

# Error bars (seaborn scatterplot doesn't support yerr natively)
for compound in ["Imatinib", "Erlotinib"]:
    sub = df[df["compound"] == compound]
    ax.errorbar(
        sub["concentration"],
        sub["response"],
        yerr=sub["sem"],
        fmt="none",
        ecolor=palette_dict[compound],
        capsize=4,
        capthick=1.5,
        elinewidth=1.5,
        alpha=0.7,
        zorder=4,
    )

# EC50 dashed reference lines
for compound in ["Imatinib", "Erlotinib"]:
    popt = fit_params[compound]
    ec50_val = popt[2]
    half_resp = popt[0] + (popt[1] - popt[0]) / 2
    ax.hlines(
        half_resp,
        x_fit[0],
        ec50_val,
        colors=palette_dict[compound],
        linestyles="dashed",
        linewidth=1.2,
        alpha=0.55,
        zorder=3,
    )
    ax.vlines(
        ec50_val, -5, half_resp, colors=palette_dict[compound], linestyles="dashed", linewidth=1.2, alpha=0.55, zorder=3
    )

# EC50 value annotations near crosshair intersections
for compound in ["Imatinib", "Erlotinib"]:
    popt = fit_params[compound]
    ec50_val = popt[2]
    half_resp = popt[0] + (popt[1] - popt[0]) / 2
    if ec50_val < 1e-6:
        ec50_label = f"EC50 = {ec50_val * 1e9:.0f} nM"
    else:
        ec50_label = f"EC50 = {ec50_val * 1e6:.1f} μM"
    ax.text(
        ec50_val * 2.5, half_resp + 4, ec50_label, fontsize=7.5, color=palette_dict[compound], va="bottom", ha="left"
    )

# Asymptote reference lines (top and bottom plateaus)
for compound, dash in [("Imatinib", (5, 5)), ("Erlotinib", (2, 4))]:
    popt = fit_params[compound]
    ax.axhline(popt[0], color=palette_dict[compound], linestyle=(0, dash), linewidth=0.8, alpha=0.35, zorder=1)
    ax.axhline(popt[1], color=palette_dict[compound], linestyle=(0, dash), linewidth=0.8, alpha=0.35, zorder=1)

# Style
ax.set_xscale("log")
ax.set_xlabel("Concentration (M)", fontsize=10, color=INK)
ax.set_ylabel("Response (%)", fontsize=10, color=INK)
ax.set_title("curve-dose-response · python · seaborn · anyplot.ai", fontsize=12, fontweight="medium", color=INK)
ax.tick_params(axis="both", labelsize=8, colors=INK_SOFT)
sns.despine(ax=ax)
ax.yaxis.grid(True, alpha=0.15, linewidth=0.8)
ax.set_ylim(-5, 110)

# Custom legend (explicit handles for line + marker + CI patch)
legend_handles = [
    Line2D([0], [0], color=COLOR_A, linewidth=2.5, label="Imatinib (fit)"),
    Line2D([0], [0], color=COLOR_B, linewidth=2.5, label="Erlotinib (fit)"),
    Line2D(
        [0],
        [0],
        marker="o",
        color="none",
        markerfacecolor=COLOR_A,
        markersize=8,
        markeredgecolor=PAGE_BG,
        label="Imatinib (data)",
    ),
    Line2D(
        [0],
        [0],
        marker="s",
        color="none",
        markerfacecolor=COLOR_B,
        markersize=8,
        markeredgecolor=PAGE_BG,
        label="Erlotinib (data)",
    ),
    Patch(facecolor=COLOR_A, alpha=0.25, label="95% CI (Imatinib)"),
]
ax.legend(handles=legend_handles, fontsize=8, frameon=True, loc="upper left", facecolor=ELEVATED_BG, edgecolor=INK_SOFT)

# Save — bbox_inches omitted (must stay default None per seaborn canvas contract)
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

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

Other implementations