Tutorials

Logistic Curve Fitting

Many real-world processes follow an S-shaped (sigmoidal) growth curve: slow initial growth, rapid acceleration through a middle phase, then deceleration as the system approaches a ceiling. Product adoption, bacterial growth, epidemic spread, and chemical reaction saturation all behave this way. The logistic model describes this with three parameters: the carrying capacity L (the ceiling), the growth rate k (how steeply it rises), and the midpoint x₀ (the inflection point where growth is fastest). Fitting this model to data recovers these parameters and lets you predict when the process will saturate — information that a simple linear fit cannot provide.

### Simulating S-curve data

A product adoption curve where 80% of the market adopts over roughly 24 months provides a concrete example with known parameters.

import numpy as np

rng = np.random.default_rng(42)
months = np.linspace(0, 36, 100)

# True parameters
L_true  = 80.0   # saturation (% of market)
k_true  = 0.25   # growth rate
x0_true = 15.0   # inflection point (month of fastest adoption)

def logistic(x, L, k, x0):
    return L / (1 + np.exp(-k * (x - x0)))

y_true  = logistic(months, L_true, k_true, x0_true)
y_noisy = y_true + rng.normal(0, 2.5, len(months))
y_noisy = np.clip(y_noisy, 0, 100)  # keep within [0, 100]

print(f"Inflection point: month {x0_true}  (adoption = {L_true/2:.1f}%)")
print(f"Final saturation: {L_true}% of market")
print(f"Max growth rate:  {L_true * k_true / 4:.2f}% per month (at inflection)")
Inflection point: month 15.0  (adoption = 40.0%)
Final saturation: 80.0% of market
Max growth rate:  5.00% per month (at inflection)
- `L / (1 + exp(-k(x - x0)))` is the three-parameter logistic: at `x = x0` the function equals `L/2`; the slope there equals `L × k / 4`, the maximum growth rate.
- `k = 0.25` means the sigmoid steepens rapidly — a larger k produces a sharper transition from slow to fast growth.
- `np.clip` prevents noise from pushing the percentage outside the physically meaningful [0, 100] range.

### Fitting the logistic model

`curve_fit` with bounds prevents the optimiser from finding negative capacities or negative growth rates.

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit

rng = np.random.default_rng(42)
months = np.linspace(0, 36, 100)
L_true, k_true, x0_true = 80.0, 0.25, 15.0

def logistic(x, L, k, x0):
    return L / (1 + np.exp(-k * (x - x0)))

y_true  = logistic(months, L_true, k_true, x0_true)
y_noisy = np.clip(y_true + rng.normal(0, 2.5, len(months)), 0, 100)

p0 = [100, 0.1, 18]  # initial guesses: L, k, x0
bounds = ([0, 0, 0], [200, 5, 100])
popt, pcov = curve_fit(logistic, months, y_noisy, p0=p0, bounds=bounds, maxfev=5000)
perr = np.sqrt(np.diag(pcov))

print(f"{'Parameter':>12}  {'True':>8}  {'Fitted':>8}  {'±1σ':>8}")
print("-" * 44)
for name, true, fit, err in zip(
        ["L (cap.)", "k (rate)", "x0 (mid)"],
        [L_true, k_true, x0_true],
        popt, perr):
    print(f"{name:>12}  {true:>8.3f}  {fit:>8.3f}  {err:>8.4f}")

fig, ax = plt.subplots(figsize=(9, 4))
ax.scatter(months, y_noisy,  color="lightgray",  s=15, label="Observed data", zorder=2)
ax.plot(months, y_true,      color="black",      linewidth=1, linestyle="--", label="True curve")
ax.plot(months, logistic(months, *popt), color="tomato", linewidth=2,
        label=f"Fit: L={popt[0]:.1f}, k={popt[1]:.3f}, x₀={popt[2]:.1f}")
ax.axvline(popt[2], color="tomato", linewidth=0.8, linestyle=":")
ax.axhline(popt[0]/2, color="tomato", linewidth=0.8, linestyle=":")
ax.set_xlabel("Month"); ax.set_ylabel("Adoption (%)")
ax.set_title("Logistic growth curve fitting")
ax.legend()
plt.tight_layout()
plt.show()
   Parameter      True    Fitted       ±1σ
--------------------------------------------
    L (cap.)    80.000    79.208    0.4116
    k (rate)     0.250     0.251    0.0049
    x0 (mid)    15.000    14.828    0.0923
- `bounds=([0, 0, 0], [200, 5, 100])` enforces positive L, positive growth rate, and a midpoint within the data range — critical for the optimiser to find meaningful solutions.
- `maxfev=5000` increases the maximum function evaluations; logistic fitting sometimes needs more iterations than simpler models.
- The dotted lines mark the fitted inflection point (x₀) and half-saturation level (L/2) — the most practically relevant model outputs.

### Forecasting and saturation time

Once the model is fitted, you can forecast beyond the data range and estimate when 90% or 95% saturation will be reached.

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit

rng = np.random.default_rng(42)
months = np.linspace(0, 36, 100)
L_true, k_true, x0_true = 80.0, 0.25, 15.0

def logistic(x, L, k, x0):
    return L / (1 + np.exp(-k * (x - x0)))

y_true  = logistic(months, L_true, k_true, x0_true)
y_noisy = np.clip(y_true + rng.normal(0, 2.5, len(months)), 0, 100)

popt, _ = curve_fit(logistic, months, y_noisy, p0=[100, 0.1, 18],
                     bounds=([0,0,0],[200,5,100]), maxfev=5000)
L, k, x0 = popt

# Solve for when adoption reaches target fractions
def saturation_month(target_fraction, L, k, x0):
    """Month when adoption = target_fraction * L"""
    p = target_fraction
    return x0 - np.log(1/p - 1) / k

for pct in [0.5, 0.75, 0.90, 0.95]:
    m = saturation_month(pct, L, k, x0)
    print(f"{pct:.0%} saturation: month {m:.1f}  (adoption = {pct*L:.1f}%)")

# Plot including forecast
x_forecast = np.linspace(0, 60, 300)
y_forecast = logistic(x_forecast, *popt)

fig, ax = plt.subplots(figsize=(9, 4))
ax.scatter(months, y_noisy, color="steelblue", s=15, label="Observed data", zorder=2)
ax.plot(x_forecast[:100], y_forecast[:100], color="tomato", linewidth=2, label="Fit (in-sample)")
ax.plot(x_forecast[100:], y_forecast[100:], color="tomato", linewidth=2,
        linestyle="--", label="Forecast (out-of-sample)")
ax.axhline(L * 0.9, color="gray", linestyle=":", linewidth=1, label="90% saturation level")
ax.set_xlabel("Month"); ax.set_ylabel("Adoption (%)")
ax.set_title("Logistic growth — forecast to saturation")
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
50% saturation: month 14.8  (adoption = 39.6%)
75% saturation: month 19.2  (adoption = 59.4%)
90% saturation: month 23.6  (adoption = 71.3%)
95% saturation: month 26.5  (adoption = 75.2%)
- `saturation_month` inverts the logistic formula analytically: given a target fraction p, the month where adoption = p × L is `x0 − log(1/p − 1) / k`.
- The dashed portion of the forecast line shows extrapolation — useful for planning but increasingly uncertain further from the data.
- The 90% saturation estimate is typically the most useful business metric: when should infrastructure, supply chains, or staffing reach near-full capacity?

### Conclusion

Logistic curve fitting gives you three numbers that describe the entire growth trajectory: how large, how fast, and when. The inflection point (x₀) tells you when to expect the fastest growth; the carrying capacity (L) tells you the eventual ceiling. Always inspect whether your data includes enough of both the acceleration and deceleration phases — fitting a logistic to only the early exponential growth phase produces wildly uncertain estimates of L.

For fitting symmetric bell-shaped peaks rather than S-curves, see [Gaussian peak fitting](/tutorials/gaussian-peak-fitting). For the underlying optimisation framework, see [scipy.optimize basics](/tutorials/scipy-optimize-basics).