Pharmacological Dose-Response Curve — Plotly

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 Plotly

Python source (Plotly)

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

import os

import numpy as np
import plotly.graph_objects as go
from scipy.optimize import curve_fit


THEME = os.getenv("ANYPLOT_THEME", "light")

# Theme-adaptive chrome (Imprint palette)
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"
GRID = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)"

# Imprint categorical palette — positions 1 and 2
COLORS = ["#009E73", "#C475FD"]

# --- Data ---
np.random.seed(42)
concentrations = np.logspace(-9, -4, 8)

compound_names = ["Compound A", "Compound B"]

ec50_true = [1e-7, 5e-7]
hill_true = [1.2, 0.9]
top_true = [100, 95]
bottom_true = [5, 10]

raw_data = {}
for i, name in enumerate(compound_names):
    responses = []
    sems = []
    for conc in concentrations:
        true_resp = bottom_true[i] + (top_true[i] - bottom_true[i]) / (1 + (ec50_true[i] / conc) ** hill_true[i])
        reps = true_resp + np.random.normal(0, 3, 3)
        responses.append(np.mean(reps))
        sems.append(np.std(reps, ddof=1) / np.sqrt(3))
    raw_data[name] = {"concentrations": concentrations, "responses": np.array(responses), "sems": np.array(sems)}


def logistic_4pl(x, bottom, top, ec50, hill):
    return bottom + (top - bottom) / (1 + (ec50 / x) ** hill)


fit_results = {}
conc_fine = np.logspace(-9.5, -3.8, 300)

for i, name in enumerate(compound_names):
    popt, pcov = curve_fit(
        logistic_4pl,
        raw_data[name]["concentrations"],
        raw_data[name]["responses"],
        p0=[bottom_true[i], top_true[i], ec50_true[i], hill_true[i]],
        maxfev=10000,
    )
    perr = np.sqrt(np.diag(pcov))
    fit_results[name] = {"popt": popt, "perr": perr}

# --- Plot ---
fig = go.Figure()

for i, name in enumerate(compound_names):
    popt = fit_results[name]["popt"]
    bottom, top, ec50, hill = popt
    color = COLORS[i]
    fitted_curve = logistic_4pl(conc_fine, *popt)

    # 95% CI band for Compound A
    if i == 0:
        perr = fit_results[name]["perr"]
        upper = logistic_4pl(conc_fine, bottom - perr[0], top + perr[1], ec50, hill)
        lower = logistic_4pl(conc_fine, bottom + perr[0], top - perr[1], ec50, hill)
        r, g, b = int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)
        fill_alpha = 0.18 if THEME == "light" else 0.25

        fig.add_trace(
            go.Scatter(x=conc_fine, y=upper, mode="lines", line={"width": 0}, showlegend=False, hoverinfo="skip")
        )
        fig.add_trace(
            go.Scatter(
                x=conc_fine,
                y=lower,
                mode="lines",
                line={"width": 0},
                fill="tonexty",
                fillcolor=f"rgba({r},{g},{b},{fill_alpha})",
                showlegend=False,
                hoverinfo="skip",
            )
        )

    # Fitted sigmoid curve
    fig.add_trace(
        go.Scatter(
            x=conc_fine,
            y=fitted_curve,
            mode="lines",
            name=f"{name} (EC₅₀ = {ec50:.2e} M)",
            line={"color": color, "width": 3},
            hovertemplate=(f"<b>{name}</b><br>Conc: %{{x:.2e}} M<br>Response: %{{y:.1f}}%<extra></extra>"),
        )
    )

    # Data points with SEM error bars
    fig.add_trace(
        go.Scatter(
            x=raw_data[name]["concentrations"],
            y=raw_data[name]["responses"],
            mode="markers",
            name=f"{name} data",
            marker={"size": 10, "color": color, "line": {"color": PAGE_BG, "width": 2}},
            error_y={"type": "data", "array": raw_data[name]["sems"], "visible": True, "color": color, "thickness": 2},
            showlegend=False,
            hovertemplate=(
                f"<b>{name}</b><br>Conc: %{{x:.2e}} M<br>Response: %{{y:.1f}} ± %{{error_y.array:.1f}}%<extra></extra>"
            ),
        )
    )

    # EC50 dashed crosshair reference lines
    half_response = bottom + (top - bottom) / 2
    fig.add_shape(
        type="line", x0=ec50, x1=ec50, y0=-5, y1=half_response, line={"color": color, "width": 1.5, "dash": "dash"}
    )
    fig.add_shape(
        type="line",
        x0=1e-10,
        x1=ec50,
        y0=half_response,
        y1=half_response,
        line={"color": color, "width": 1.5, "dash": "dash"},
    )

    # EC50 annotation with arrow
    fig.add_annotation(
        x=np.log10(ec50),
        y=half_response + 5 + i * 8,
        text=f"<b>EC₅₀ = {ec50:.2e} M</b>",
        showarrow=True,
        arrowhead=2,
        arrowsize=1,
        arrowwidth=1.5,
        arrowcolor=color,
        ax=40 + i * 30,
        ay=-30 - i * 10,
        font={"size": 10, "color": color},
        bordercolor=color,
        borderwidth=1.5,
        borderpad=4,
        bgcolor=ELEVATED_BG,
    )

# Top and bottom asymptote dotted reference lines
fig.add_shape(
    type="line", x0=1e-10, x1=1e-3, y0=top_true[0], y1=top_true[0], line={"color": INK_MUTED, "width": 1, "dash": "dot"}
)
fig.add_shape(
    type="line",
    x0=1e-10,
    x1=1e-3,
    y0=bottom_true[0],
    y1=bottom_true[0],
    line={"color": INK_MUTED, "width": 1, "dash": "dot"},
)

# Asymptote labels (placed within axis range)
fig.add_annotation(
    x=-4.2, y=top_true[0], text="Top asymptote", showarrow=False, font={"size": 10, "color": INK_MUTED}, yshift=10
)
fig.add_annotation(
    x=-4.2,
    y=bottom_true[0],
    text="Bottom asymptote",
    showarrow=False,
    font={"size": 10, "color": INK_MUTED},
    yshift=-12,
)

# --- Layout ---
fig.update_layout(
    autosize=False,
    width=800,
    height=450,
    margin={"l": 80, "r": 40, "t": 80, "b": 60},
    title={
        "text": "<b>curve-dose-response · python · plotly · anyplot.ai</b>",
        "font": {"size": 16, "color": INK, "family": "Arial, Helvetica, sans-serif"},
        "x": 0.5,
        "xanchor": "center",
    },
    xaxis={
        "title": {"text": "Concentration (M)", "font": {"size": 12, "color": INK}},
        "type": "log",
        "tickfont": {"size": 10, "color": INK_SOFT},
        "showgrid": False,
        "showline": False,
        "range": [-9.5, -3.8],
    },
    yaxis={
        "title": {"text": "Response (%)", "font": {"size": 12, "color": INK}},
        "tickfont": {"size": 10, "color": INK_SOFT},
        "showgrid": True,
        "gridcolor": GRID,
        "gridwidth": 0.5,
        "range": [-5, 115],
        "zeroline": False,
        "showline": False,
    },
    template="plotly_white",
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    font={"color": INK},
    legend={
        "font": {"size": 10, "color": INK_SOFT},
        "x": 0.02,
        "y": 0.98,
        "bgcolor": ELEVATED_BG,
        "bordercolor": INK_SOFT,
        "borderwidth": 1,
        "traceorder": "normal",
    },
    updatemenus=[
        {
            "type": "dropdown",
            "direction": "down",
            "showactive": True,
            "x": 0.99,
            "xanchor": "right",
            "y": 0.01,
            "yanchor": "bottom",
            "bgcolor": ELEVATED_BG,
            "bordercolor": INK_SOFT,
            "font": {"color": INK_SOFT, "size": 10},
            "buttons": [
                {
                    "label": "Both Compounds",
                    "method": "update",
                    "args": [{"visible": [True, True, True, True, True, True]}],
                },
                {
                    "label": "Compound A only",
                    "method": "update",
                    "args": [{"visible": [True, True, True, True, False, False]}],
                },
                {
                    "label": "Compound B only",
                    "method": "update",
                    "args": [{"visible": [False, False, False, False, True, True]}],
                },
            ],
        }
    ],
)

# --- Save ---
fig.write_image(f"plot-{THEME}.png", width=800, height=450, scale=4)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

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

Other implementations