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)")- `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()- `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()- `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).