In the previous laboratory we arrived at the exponential growth formula:
\[ \frac{dN}{dt} = r N \quad\Longrightarrow\quad N(t) = N_0\, e^{rt} \]
With \(N_0 = 100\) water lilies and \(r = 0.1\)/day, let’s see what the model predicts over the long run:
## In 100 days: 2,202,647 water lilies
## In 200 days: 48,516,519,541 water lilies
## In 365 days: 7.108019e+17 water lilies
It is absurd. In less than a year, our small lake is supposed to have more water lilies than there are cells in the human body.
The problem is that the exponential model ignores space, light, nutrients and competition. In this laboratory we will fix this shortcoming.
Every real environment has a limit to the number of individuals it can sustain. We call this limit the carrying capacity and denote it by \(K\).
For our lake, let’s assume:
A note on the choice: The value \(K = 10{,}000\) is illustrative and was chosen for clarity in the plots. In a real study \(K\) is estimated from data or from the physical features of the habitat (lake surface area, average water lily size, available light, etc.).
The naive idea would be: “we let the population grow exponentially up to \(K\), and then we stop it.” But that is not what happens in nature — growth slows down gradually as we approach \(K\), long before we reach it.
Why?
Imagine our lake with the water lilies. When the population is small, each water lily has plenty of space around it and can reproduce freely. But as the population grows, the water lilies in the interior of the cluster are surrounded by other water lilies and struggle to expand. Only the water lilies at the periphery have space. The fuller the lake, the smaller the fraction of water lilies that can actually reproduce.
This is one example of a mechanism. In reality several reasons can act together:
All of these share the same qualitative feature: the per capita reproduction rate decreases as the population approaches \(K\).
So \(K\) is not just a limit we bump into. \(K\) modifies the rate of change itself.
We define the realized per capita rate of increase as a function \(r_{\text{eff}}(N)\) of the current population. Two natural requirements:
The simplest function that captures the two extremes is a straight line that falls from \(r\) (at \(N=0\)) to \(0\) (at \(N=K\)):
\[ \boxed{\;r_{\text{eff}}(N) \;=\; r\left(1 - \frac{N}{K}\right)\;} \]
A note on the model choice (important): The linear form is a modeling assumption, not a logical necessity. It is the simplest function that captures the correct qualitative behavior. Other possible forms (we will see them at the end):
- θ-logistic: \(r_{\text{eff}}(N) = r\bigl(1 - (N/K)^{\theta}\bigr)\)
- Gompertz: \(r_{\text{eff}}(N) = r\,\ln(K/N)\)
All give similar sigmoidal behavior, but with different geometry.
N_grid <- seq(0, K, length.out = 200)
df_pc <- data.frame(
N = rep(N_grid, 2),
rate = c(rep(r, length(N_grid)), # constant r (exponential)
r * (1 - N_grid/K)), # linearly decreasing (logistic)
Model = factor(rep(c("Exponential: r constant",
"Logistic: r·(1-N/K)"),
each = length(N_grid)))
)
ggplot(df_pc, aes(x = N, y = rate, colour = Model)) +
geom_line(linewidth = 1.2) +
geom_hline(yintercept = 0, linetype = "dotted") +
labs(
title = "Per capita growth rate as a function of population",
subtitle = "The essential difference between the exponential and logistic models",
x = "Population N",
y = expression(r[eff](N))
) +
theme(legend.position = "bottom")The total rate of change of the population is:
\[ \frac{dN}{dt} \;=\; r_{\text{eff}}(N) \cdot N \;=\; r\,N\left(1 - \frac{N}{K}\right) \]
This is the logistic equation (Verhulst, 1838).
\[ \boxed{\;\frac{dN}{dt} \;=\; r\,N\left(1 - \frac{N}{K}\right)\;} \]
One of the most useful tools in dynamical systems is to examine the behavior before solving analytically. Very often the solution does not even exist analytically — but the qualitative picture is enough for us.
Where \(\dfrac{dN}{dt} = 0\) the population stays stationary. We solve:
\[ r\,N\left(1 - \frac{N}{K}\right) = 0 \quad\Longrightarrow\quad N^* = 0 \;\text{ or }\; N^* = K \]
So \(N^*=K\) is stable — it is an attractor of the system.
N_grid <- seq(-500, K * 1.4, length.out = 300)
dN_dt <- r * N_grid * (1 - N_grid/K)
df_phase <- data.frame(N = N_grid, dN = dN_dt)
# Equilibrium points
eq_unstable <- data.frame(N = 0, dN = 0)
eq_stable <- data.frame(N = K, dN = 0)
# Flow arrows on the N axis: the sign of (xend - x) gives the direction of motion
arrows_df <- data.frame(
x = c(K/4, 3*K/4, 1.2*K),
xend = c(K/4 + 800, 3*K/4 + 800, 1.2*K - 800),
y = 0, yend = 0
)
ggplot(df_phase, aes(x = N, y = dN)) +
geom_hline(yintercept = 0, colour = "grey50") +
geom_line(linewidth = 1, colour = "darkgreen") +
geom_point(data = eq_unstable, aes(x = N, y = dN),
shape = 21, fill = "white", colour = "darkgreen",
size = 4, stroke = 1.2) +
geom_point(data = eq_stable, aes(x = N, y = dN),
colour = "darkgreen", size = 4) +
geom_segment(data = arrows_df,
aes(x = x, xend = xend, y = y, yend = yend),
arrow = arrow(length = unit(0.25, "cm")),
colour = "tomato", linewidth = 0.9,
inherit.aes = FALSE) +
annotate("text", x = 0, y = -150, label = "N* = 0\n(unstable)", vjust = 1) +
annotate("text", x = K, y = -150, label = "N* = K\n(stable)", vjust = 1) +
annotate("text", x = K/2, y = max(dN_dt) + 30,
label = "maximum rate\nat N = K/2", vjust = 0) +
labs(
title = "Phase portrait of the logistic equation",
subtitle = "The arrows show the direction of change of N",
x = "N", y = "dN/dt = r"
)The parabola represents the whole dynamics of the system in a single picture:
For those who want to see the solution: we start with separation of variables:
\[ \frac{dN}{N\,(1 - N/K)} = r\,dt \quad\Longleftrightarrow\quad \frac{K\,dN}{N\,(K - N)} = r\,dt \]
Partial fractions:
\[ \frac{K}{N(K-N)} = \frac{1}{N} + \frac{1}{K-N} \]
So:
\[ \int\!\left(\frac{1}{N} + \frac{1}{K-N}\right) dN = \int r\,dt \;\Longleftrightarrow\; \ln N - \ln(K-N) = rt + C \]
\[ \ln\!\left(\frac{N}{K-N}\right) = rt + C \;\Longrightarrow\; \frac{N}{K-N} = A\,e^{rt}, \quad A = \frac{N_0}{K - N_0} \]
Solving for \(N\) we arrive at the closed-form logistic solution:
\[ \boxed{\;N(t) \;=\; \dfrac{K}{1 + \left(\dfrac{K - N_0}{N_0}\right) e^{-rt}}\;} \]
Sanity checks:
deSolveNow that we know what to expect, let’s simulate:
logistic_model <- function(t, state, parameters) {
with(as.list(c(state, parameters)), {
dN <- r * N * (1 - N / K)
list(dN)
})
}
parameters <- c(r = 0.1, K = 10000)
state <- c(N = 100)
times <- seq(0, 200, by = 0.5)
out_log <- as.data.frame(
ode(y = state, times = times, func = logistic_model, parms = parameters)
)
head(out_log)N0 <- 100
K <- 10000
r <- 0.1
logistic_analytical <- function(t, N0, r, K) {
K / (1 + ((K - N0) / N0) * exp(-r * t))
}
df_log <- data.frame(
t = times,
Analytical = logistic_analytical(times, N0, r, K),
Numerical = out_log$N
)
ggplot(df_log, aes(x = t)) +
geom_line(aes(y = Analytical), linewidth = 1.2, colour = "steelblue") +
geom_point(aes(y = Numerical),
data = df_log[seq(1, nrow(df_log), by = 25), ],
colour = "tomato", size = 1.8) +
geom_hline(yintercept = K, linetype = "dashed", colour = "grey40") +
annotate("text", x = max(times) * 0.95, y = K * 1.04,
label = "K = 10,000", colour = "grey40", hjust = 1) +
labs(
title = "Logistic growth: analytical (line) vs numerical (dots)",
x = "Time (days)",
y = "Population N(t)"
)cat("Maximum error:", format(max(abs(df_log$Analytical - df_log$Numerical)),
scientific = TRUE), "\n")## Maximum error: 6.769672e-04
Look carefully at the time trajectory. It has three distinct stages:
stages <- data.frame(
t = times,
N = logistic_analytical(times, N0, r, K)
)
# Inflection point: N = K/2
t_inflection <- (1/r) * log((K - N0) / N0)
ggplot(stages, aes(x = t, y = N)) +
geom_line(linewidth = 1.1, colour = "steelblue") +
geom_hline(yintercept = K, linetype = "dashed", colour = "grey50") +
geom_hline(yintercept = K/2, linetype = "dotted", colour = "grey50") +
geom_vline(xintercept = t_inflection, linetype = "dotted", colour = "tomato") +
annotate("point", x = t_inflection, y = K/2, size = 3, colour = "tomato") +
annotate("text", x = t_inflection + 5, y = K/2,
label = "Inflection point (N=K/2)\nmaximum rate", hjust = 0) +
annotate("text", x = 20, y = 1500, label = "1. Almost exponential", hjust = 0) +
annotate("text", x = 60, y = 5500, label = "2. Transitional phase\n(acceleration → deceleration)", hjust = 0) +
annotate("text", x = 130, y = 9300, label = "3. Saturation toward K", hjust = 0) +
labs(
title = "The sigmoidal (S-shaped) logistic curve",
x = "Time (days)",
y = "N(t)"
)## Inflection point at t = 46 days (where N = K/2 = 5000 )
With the same \(r\), the same \(N_0\), but a different \(K\):
df_compare <- data.frame(
t = times,
Exponential = N0 * exp(r * times),
Logistic = logistic_analytical(times, N0, r, K)
)
# Long format for ggplot
df_long <- data.frame(
t = rep(times, 2),
N = c(df_compare$Exponential, df_compare$Logistic),
Model = factor(rep(c("Exponential", "Logistic"), each = length(times)))
)
ggplot(df_long, aes(x = t, y = N, colour = Model)) +
geom_line(linewidth = 1.1) +
geom_hline(yintercept = K, linetype = "dashed", colour = "grey50") +
scale_y_log10() +
annotate("text", x = max(times) * 0.95, y = K * 1.3,
label = "K = 10,000", colour = "grey40", hjust = 1) +
labs(
title = "Exponential vs logistic growth (logarithmic axis)",
subtitle = "In the first days they are almost identical — then they diverge dramatically",
x = "Time (days)",
y = "N(t) [logarithmic scale]"
) +
theme(legend.position = "bottom")In the first \(\approx 30\) days the two curves are almost indistinguishable. This gives us a warning: in the early stages of a population phenomenon, we cannot easily tell whether it is growing exponentially or logistically — both models fit equally well. The difference emerges only as we approach \(K\).
times2 <- seq(0, 100, by = 0.5)
scenarios <- data.frame(
t = rep(times2, 3),
N = c(logistic_analytical(times2, N0 = 100, r = 0.1, K = 10000),
logistic_analytical(times2, N0 = 5000, r = 0.1, K = 10000),
logistic_analytical(times2, N0 = 15000, r = 0.1, K = 10000)),
N0 = factor(rep(c("N0 = 100", "N0 = 5,000", "N0 = 15,000 (overshoot)"),
each = length(times2)))
)
ggplot(scenarios, aes(x = t, y = N, colour = N0)) +
geom_line(linewidth = 1.1) +
geom_hline(yintercept = K, linetype = "dashed", colour = "grey50") +
annotate("text", x = max(times2) * 0.95, y = K * 1.04,
label = "K = 10,000", colour = "grey40", hjust = 1) +
labs(
title = "Logistic growth: three different initial scenarios",
subtitle = "All converge to K, regardless of the initial condition",
x = "Time (days)",
y = "N(t)"
) +
theme(legend.position = "bottom")Notice that the case \(N_0 = 15,000 > K\) decreases toward \(K\). This follows automatically from the equation: if \(N > K\), then \(1 - N/K < 0\), so \(\frac{dN}{dt} < 0\).
This models, for example, the case where we introduce an excessive number of water lilies into a lake — the population will fall to the carrying level.
In practice biologists have data and want to
estimate \(r\) and
\(K\). This is done with non-linear
regression (nls() in R).
# We simulate data with noise
set.seed(123)
sample_days <- seq(0, 150, by = 10)
true_N <- logistic_analytical(sample_days, N0 = 100, r = 0.1, K = 10000)
observed_N <- true_N * exp(rnorm(length(sample_days), 0, 0.08))
# Fitting the logistic model
fit <- nls(observed_N ~ K / (1 + ((K - N0)/N0) * exp(-r * sample_days)),
start = list(K = 8000, r = 0.05, N0 = 200))
summary(fit)$coefficients## Estimate Std. Error t value Pr(>|t|)
## K 1.023666e+04 241.96331612 42.306681 2.599395e-15
## r 9.074180e-02 0.01263879 7.179627 7.158844e-06
## N0 1.601025e+02 91.19055361 1.755692 1.026589e-01
fit_curve <- data.frame(
t = times,
N = predict(fit, newdata = data.frame(sample_days = times))
)
ggplot() +
geom_point(data = data.frame(t = sample_days, N = observed_N),
aes(x = t, y = N), size = 2.5, colour = "tomato") +
geom_line(data = fit_curve, aes(x = t, y = N),
linewidth = 1, colour = "steelblue") +
geom_hline(yintercept = coef(fit)["K"],
linetype = "dashed", colour = "grey40") +
annotate("text", x = max(times) * 0.95,
y = coef(fit)["K"] * 1.05,
label = paste0("Estimate K = ", round(coef(fit)["K"])),
colour = "grey30", hjust = 1) +
labs(
title = "Fitting the logistic model to data",
subtitle = paste0("Estimates: r = ", round(coef(fit)["r"], 3),
", K = ", round(coef(fit)["K"]),
", N0 = ", round(coef(fit)["N0"])),
x = "Time (days)",
y = "Population N(t)"
)Practical significance: The
nls()estimated \(\hat{r}\) and \(\hat{K}\) come with a standard error — so we have statistical confidence intervals for the parameters. This is crucial for comparing two populations or two experimental conditions.
As we mentioned, the linear form \(r_{\text{eff}}(N) = r(1 - N/K)\) is one choice. Two important alternatives:
\[ \frac{dN}{dt} = r N \left(1 - \left(\frac{N}{K}\right)^{\theta}\right) \]
\[ \frac{dN}{dt} = r N \ln\!\left(\frac{K}{N}\right) \]
Used mainly for the growth of masses (e.g. cancerous tumors, organism development). It slows down earlier than the logistic.
gompertz <- function(t, N0, r, K) {
K * exp(log(N0/K) * exp(-r * t))
}
theta_logistic <- function(t, N0, r, K, theta) {
# Numerical solution
parms <- c(r = r, K = K, theta = theta)
func <- function(t, state, parms) {
with(as.list(c(state, parms)),
list(r * N * (1 - (N/K)^theta)))
}
out <- ode(y = c(N = N0), times = t, func = func, parms = parms)
as.data.frame(out)$N
}
t_vec <- seq(0, 200, by = 0.5)
df_alt <- data.frame(
t = rep(t_vec, 4),
N = c(logistic_analytical(t_vec, 100, 0.1, 10000),
gompertz(t_vec, 100, 0.1, 10000),
theta_logistic(t_vec, 100, 0.1, 10000, theta = 0.5),
theta_logistic(t_vec, 100, 0.1, 10000, theta = 3)),
Model = factor(rep(c("Logistic (θ=1)",
"Gompertz",
"θ-logistic (θ=0.5)",
"θ-logistic (θ=3)"),
each = length(t_vec)),
levels = c("Logistic (θ=1)",
"θ-logistic (θ=0.5)",
"θ-logistic (θ=3)",
"Gompertz"))
)
ggplot(df_alt, aes(x = t, y = N, colour = Model)) +
geom_line(linewidth = 1) +
geom_hline(yintercept = 10000, linetype = "dashed", colour = "grey50") +
labs(
title = "Different models of saturation",
subtitle = "All models have the same r=0.1, K=10,000, N0=100",
x = "Time (days)", y = "N(t)"
) +
theme(legend.position = "bottom")Pedagogical remark: The fact that all these models give different curves is the most important message. On real data the choice of model does not follow automatically — it depends on the biology of the organism and must be checked statistically (e.g. AIC).
Sensitivity to \(K\). Plot logistic curves with \(r=0.1\), \(N_0=100\) and \(K \in \{1{,}000,\ 5{,}000,\ 10{,}000,\ 50{,}000\}\) on the same graph. What changes in the shape of the curve?
Time to reach \(90\%\) of \(K\). Solve analytically: for which \(t\) does \(N(t) = 0.9 K\) hold? Verify numerically.
Inflection point. Show that the rate \(\frac{dN}{dt}\) is maximized at \(N = K/2\). (Hint: differentiate \(\frac{dN}{dt}\) with respect to \(N\) and find the root.)
From the data to \(r\). You are given measurements:
Fit the logistic model with nls() and compute the
confidence intervals for \(\hat{r}\)
and \(\hat{K}\)
(confint(fit)).
Conceptual. In the discussion we mentioned various mechanisms that can justify the decrease of the per capita rate (space, light, nutrients, pathogens). For the water lilies in our lake, which mechanism do you consider most important and why? How would you verify it experimentally?
Beyond the mean. All the models here are
deterministic — there is no chance involved. In real populations,
however, there is stochasticity (births/deaths are random events). Try
to simulate stochastic logistic growth: at each time step, add to \(\frac{dN}{dt}\) a random term
rnorm(1, 0, σ). How does the behavior change for different
\(\sigma\)?
So far we have examined a single population. In reality, however, populations interact with each other:
These are systems of differential equations and will be the subject of the next laboratory.
## R version 4.6.1 (2026-06-24)
## Platform: x86_64-pc-linux-gnu
## Running under: Ubuntu 24.04.4 LTS
##
## Matrix products: default
## BLAS: /usr/lib/x86_64-linux-gnu/blas/libblas.so.3.12.0
## LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.12.0 LAPACK version 3.12.0
##
## locale:
## [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
## [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
## [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
## [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
## [9] LC_ADDRESS=C LC_TELEPHONE=C
## [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
##
## time zone: Europe/Athens
## tzcode source: system (glibc)
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] deSolve_1.42 ggplot2_4.0.3
##
## loaded via a namespace (and not attached):
## [1] vctrs_0.7.3 cli_3.6.6 knitr_1.51 rlang_1.2.0
## [5] xfun_0.57 otel_0.2.0 S7_0.2.2 jsonlite_2.0.0
## [9] labeling_0.4.3 glue_1.8.1 htmltools_0.5.9 sass_0.4.10
## [13] scales_1.4.0 rmarkdown_2.31 grid_4.6.1 evaluate_1.0.5
## [17] jquerylib_0.1.4 fastmap_1.2.0 yaml_2.3.12 lifecycle_1.0.5
## [21] compiler_4.6.1 RColorBrewer_1.1-3 farver_2.1.2 digest_0.6.39
## [25] R6_2.6.1 bslib_0.10.0 tools_4.6.1 withr_3.0.2
## [29] gtable_0.3.6 cachem_1.1.0