Lab 29: Dose-Response Curves & IC50 Calculation

BI 255 · Bethune-Cookman University · Fall 2026

Author

Dr. Rosie Stanbrook-Buyer

Published

November 6, 2026


From Drug Concentration to Biological Response

One of the most fundamental experiments in pharmacology, toxicology, and drug discovery is the dose-response experiment: you expose cells, organisms, or enzymes to increasing concentrations of a drug and measure the effect. The result is a sigmoidal curve, and the most important summary of that curve is the IC50 — the concentration that produces 50% of the maximum effect.

NoteLearning Objectives

By the end of this lab you will be able to:

  1. Explain the shape of a sigmoidal dose-response curve and its four key parameters
  2. Plot dose-response data on a log-concentration scale
  3. Fit a four-parameter log-logistic model using the drc package
  4. Extract and interpret the IC50 with confidence intervals
  5. Compare IC50 values between two drugs or conditions
  6. Understand the Hill equation and its biological meaning

Part 1: The Shape of a Dose-Response Curve

The four-parameter log-logistic (4PL) model describes most biological dose-response relationships:

y = Bottom + (Top − Bottom) / (1 + (IC50 / x)^Hill)

Where: - Bottom: minimum response (e.g., 0% inhibition) - Top: maximum response (e.g., 100% inhibition) - IC50: concentration producing 50% of maximum effect - Hill slope (n): steepness of the curve (also called the Hill coefficient)

# Simulate a dose-response experiment
# Drug: a cytotoxic compound tested against a cancer cell line
# Response: cell viability (% of untreated control)

set.seed(42)
concentrations <- c(0.001, 0.003, 0.01, 0.03, 0.1, 0.3, 1, 3, 10, 30, 100)  # uM

# True parameters
bottom  <- 5       # minimum viability (%)
top     <- 100     # maximum viability (%)
ic50    <- 1.2     # true IC50 (uM)
hill    <- 1.5     # Hill slope

# 4PL model
viability_true <- bottom + (top - bottom) / (1 + (ic50 / concentrations)^hill)

# Add biological noise (3 replicates per concentration)
viability_obs  <- sapply(viability_true,
                         function(mu) rnorm(3, mean = mu, sd = 4))
viability_mean <- colMeans(viability_obs)
viability_sd   <- apply(viability_obs, 2, sd)

# Also include a control (0 drug)
dr_df <- data.frame(
  conc      = concentrations,
  viability = viability_mean,
  sd        = viability_sd
)

head(dr_df, 6)
   conc viability       sd
1 0.001  6.561470 3.872415
2 0.003  6.253215 1.513370
3 0.010  9.652596 4.412674
4 0.030 10.079110 4.719791
5 0.100  4.830365 2.747022
6 0.300 12.482545 6.794911
library(ggplot2)

ggplot(dr_df, aes(x = conc, y = viability)) +
  geom_errorbar(aes(ymin = viability - sd, ymax = viability + sd),
                width = 0.05, colour = "grey60") +
  geom_point(colour = "#2C5F8A", size = 3) +
  scale_x_log10(breaks = c(0.001, 0.01, 0.1, 1, 10, 100),
                labels  = c("0.001", "0.01", "0.1", "1", "10", "100")) +
  geom_hline(yintercept = 50, linetype = "dashed", colour = "#C8102E") +
  labs(title    = "Cytotoxic Drug — Dose-Response Data",
       subtitle  = "Dashed line = 50% viability (IC50 reference)",
       x        = "Concentration (uM, log scale)",
       y        = "Cell Viability (% of control)") +
  theme_classic(base_size = 13)

ImportantWhy Log Scale on x-axis?

Concentrations in dose-response experiments span orders of magnitude (0.001 to 100 uM here). On a linear scale, the low concentrations are invisible. On a log scale, each doubling of concentration is equally spaced — and the sigmoidal curve becomes clearly visible as an S-shape.

Always plot dose-response data on a log concentration scale.


Part 2: Fitting the 4PL Model with the drc Package

# install.packages("drc")
library(drc)

# Fit the four-parameter log-logistic model
# LL.4() = 4-parameter log-logistic
# Parms: e (EC50/IC50), b (Hill slope), c (bottom), d (top)
dr_model <- drm(viability ~ conc,
                data   = dr_df,
                fct    = LL.4(names = c("Hill", "Bottom", "Top", "IC50")))

summary(dr_model)

Model fitted: Log-logistic (ED50 as parameter) (4 parms)

Parameter estimates:

                    Estimate Std. Error t-value   p-value    
Hill:(Intercept)    -1.67615    0.16853 -9.9454 2.218e-05 ***
Bottom:(Intercept)   7.03521    1.21100  5.8094 0.0006572 ***
Top:(Intercept)    100.08740    1.79116 55.8786 1.542e-10 ***
IC50:(Intercept)     1.32995    0.09481 14.0276 2.216e-06 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error:

 2.700056 (7 degrees of freedom)
# Extract IC50 with confidence interval
IC50_est <- ED(dr_model, respLev = 50, interval = "delta", type = "relative")

Estimated effective doses

       Estimate Std. Error   Lower   Upper
e:1:50  1.32995    0.09481 1.10576 1.55414
IC50_est
       Estimate Std. Error    Lower    Upper
e:1:50 1.329954 0.09480982 1.105764 1.554144
# Plot fitted curve with confidence band
plot(dr_model,
     type     = "all",
     col      = "#2C5F8A",
     pch      = 16,
     cex      = 1.3,
     log      = "x",
     xlab     = "Concentration (uM)",
     ylab     = "Cell Viability (%)",
     main     = "4PL Dose-Response Fit",
     broken   = FALSE)
abline(h = 50, col = "#C8102E", lty = 2)


Part 3: Manual Curve Plotting with ggplot2

For publication-quality figures, plot the fitted curve manually over the data:

# Generate smooth prediction line from fitted model
conc_seq    <- data.frame(conc = exp(seq(log(0.001), log(100), length.out = 300)))
fitted_vals <- predict(dr_model, newdata = conc_seq, interval = "confidence")

plot_df <- data.frame(
  conc  = conc_seq$conc,
  fit   = fitted_vals[, 1],
  lower = fitted_vals[, 2],
  upper = fitted_vals[, 3]
)

ic50_val <- IC50_est[1, "Estimate"]

ggplot() +
  geom_ribbon(data = plot_df, aes(x = conc, ymin = lower, ymax = upper),
              fill = "#A8C4D9", alpha = 0.5) +
  geom_line(data = plot_df, aes(x = conc, y = fit),
            colour = "#2C5F8A", linewidth = 1.2) +
  geom_errorbar(data = dr_df,
                aes(x = conc, ymin = viability - sd, ymax = viability + sd),
                width = 0.05, colour = "grey50") +
  geom_point(data = dr_df, aes(x = conc, y = viability),
             colour = "#2C5F8A", size = 3) +
  geom_hline(yintercept = 50, linetype = "dashed", colour = "#C8102E") +
  geom_vline(xintercept = ic50_val, linetype = "dashed", colour = "#C8102E") +
  annotate("text", x = ic50_val * 1.3, y = 5,
           label = paste0("IC50 = ", round(ic50_val, 2), " uM"),
           colour = "#C8102E", hjust = 0, size = 4) +
  scale_x_log10() +
  labs(title    = "Cytotoxic Drug: Fitted 4PL Dose-Response Curve",
       subtitle  = "Shaded region = 95% confidence band",
       x        = "Concentration (uM, log scale)",
       y        = "Cell Viability (% of control)") +
  theme_classic(base_size = 13)


Part 4: Comparing Two Drugs

set.seed(77)
# Drug B: same mechanism, but less potent (higher IC50)
viability_B <- bottom + (top - bottom) / (1 + (4.8 / concentrations)^1.2) +
               rnorm(length(concentrations), 0, 4)
viability_B <- pmin(pmax(viability_B, 0), 105)

# Combine data
dr_df_B <- data.frame(conc = concentrations, viability = viability_B, drug = "Drug B")
dr_df_A <- data.frame(conc = concentrations, viability = dr_df$viability, drug = "Drug A")
dr_combined <- rbind(dr_df_A, dr_df_B)

# Fit separate models for each drug
model_A <- drm(viability ~ conc, data = dr_df_A[, 1:2], fct = LL.4())
model_B <- drm(viability ~ conc, data = dr_df_B[, 1:2], fct = LL.4())

cat("Drug A IC50:", round(ED(model_A, 50, type = "relative")[1], 3), "uM\n")

Estimated effective doses

       Estimate Std. Error
e:1:50  1.32995    0.09481
Drug A IC50: 1.33 uM
cat("Drug B IC50:", round(ED(model_B, 50, type = "relative")[1], 3), "uM\n")

Estimated effective doses

       Estimate Std. Error
e:1:50   4.2426     0.6181
Drug B IC50: 4.243 uM
NoteInterpreting IC50 in Drug Comparison

A lower IC50 means greater potency — the drug achieves the same effect at a lower concentration.

Drug A IC50 ≈ 1.2 uM vs Drug B IC50 ≈ 4.8 uM: Drug A is approximately 4-fold more potent than Drug B. In drug discovery, potency differences of this magnitude are highly meaningful — a 4-fold difference affects dosing, side effect profile, and manufacturing cost.

Note that IC50 alone does not determine clinical utility — selectivity (activity against the target vs. off-target effects), bioavailability, and toxicity are equally important.


Part 5: The Hill Equation in Biochemistry

The dose-response equation is mathematically equivalent to the Hill equation from enzyme kinetics:

v = Vmax × [S]^n / (Km^n + [S]^n)

When n = 1, this is the Michaelis-Menten equation. When n > 1, it describes cooperative binding (e.g., haemoglobin-oxygen binding, allosteric enzymes).

# Simulate cooperative enzyme (haemoglobin-like)
# Oxygen partial pressure (mmHg) vs oxygen saturation (%)
pO2    <- seq(0, 150, by = 2)
hill_n <- 2.7       # real Hill coefficient for haemoglobin ~2.7
P50    <- 26        # P50 for haemoglobin (pO2 at 50% saturation) ≈ 26 mmHg

saturation <- 100 * pO2^hill_n / (P50^hill_n + pO2^hill_n)
saturation_obs <- saturation + rnorm(length(pO2), 0, 1.5)
saturation_obs <- pmax(0, pmin(100, saturation_obs))

hb_df <- data.frame(pO2 = pO2, sat = saturation_obs)

ggplot(hb_df, aes(x = pO2, y = sat)) +
  geom_point(colour = "#C8102E", size = 1.5, alpha = 0.7) +
  geom_line(data = data.frame(pO2 = pO2, sat = saturation),
            colour = "#C8102E", linewidth = 1.3) +
  geom_hline(yintercept = 50, linetype = "dashed", colour = "grey40") +
  geom_vline(xintercept = P50, linetype = "dashed", colour = "grey40") +
  annotate("text", x = 30, y = 10,
           label = paste0("Hill n = ", hill_n, "\nP50 = ", P50, " mmHg"),
           hjust = 0, size = 4) +
  labs(title    = "Haemoglobin Oxygen Dissociation Curve",
       subtitle  = "Cooperative binding: Hill coefficient n = 2.7",
       x        = "Partial Pressure of Oxygen (mmHg)",
       y        = "Oxygen Saturation (%)") +
  theme_classic(base_size = 13)


3-Minute Knowledge Check

Close your notes. Answer these on your own — you have 3 minutes. We’ll go through the answers together after.

CautionKnowledge Check Questions

1. In the 4PL dose-response model, what does the Hill slope (Hill coefficient) control?

2. Drug X has IC50 = 0.05 uM and Drug Y has IC50 = 12 uM. Which drug is more potent, and by approximately how many fold?

3. Why must dose-response data be plotted on a logarithmic x-axis?

4. In the drc package, what does ED(model, 50, type = "relative") calculate?

a) The dose that produces 50 units of response    b) The dose that produces 50% of the maximum response    c) The dose where 50 cells survive    d) The effective dose at the 50th percentile of the population

5. True or False: A Hill coefficient of exactly 1 in a dose-response curve indicates non-cooperative binding consistent with simple Michaelis-Menten kinetics.

1. The Hill slope controls the steepness of the sigmoidal curve. A Hill slope of 1 produces a gradual S-curve spanning about 2 log units of concentration from 10% to 90% effect. Slopes greater than 1 produce a steeper curve (effects switch on more abruptly), and slopes less than 1 produce a shallower curve. In receptor pharmacology, the Hill slope can indicate cooperativity or the presence of multiple binding sites.

2. Drug X is more potent by approximately 240-fold (12 / 0.05 = 240). Lower IC50 = higher potency. Drug X achieves 50% inhibition at 0.05 uM, while Drug Y requires 12 uM to produce the same effect. In medicinal chemistry, a 240-fold difference in potency is extremely large and would strongly favour Drug X as a lead compound.

3. Concentrations tested in dose-response experiments span many orders of magnitude (e.g., 0.001 to 100 uM = 100,000-fold range). On a linear scale, all low concentrations collapse into a tiny region near zero and the sigmoidal shape is invisible. The log scale spreads each concentration decade equally, making the sigmoidal curve visible and enabling the IC50 to be read directly from the midpoint of the curve.

4. b) The dose that produces 50% of the maximum response — ED(model, respLev = 50, type = "relative") calculates the Effective Dose at 50% of the response span. “Relative” means 50% of the fitted top-bottom span, not 50 absolute units. This is the IC50 (for inhibition) or EC50 (for stimulation).

5. True — A Hill coefficient of exactly 1 indicates simple hyperbolic (Michaelis-Menten) kinetics: non-cooperative binding where ligand binding at one site does not influence other sites. Values greater than 1 indicate positive cooperativity (binding makes subsequent binding easier — haemoglobin is the classic example). Values less than 1 can indicate negative cooperativity or heterogeneous receptor populations.


Lab 29 Checklist

Before you leave, make sure you can:

TipBonus Challenge

The drc package includes the built-in dataset ryegrass (herbicide dose-response on ryegrass root length). Run data(ryegrass) and explore it. Fit a 4PL model and extract the IC50 with 95% CI. Then fit a 3PL model (fixing Bottom = 0 with LL.3()) and compare both models with AIC(). Which model fits better? Produce a publication-quality ggplot2 figure with the fitted curve and both the data and IC50 annotated.


Before Next Class (Monday, Week 13)

  • Monday (Lab 30): Spatial mapping in R — species occurrence data, the sf package, and ggplot2 maps
  • Quiz 7 is Monday of Week 13 — covers Labs 25–29