Fitting Log-Pearson III Flood Frequency Curves in Python

The fit itself is four lines of arithmetic. Everything that makes it defensible sits around it: the skew weighting, the plotting-position check that reveals a bad distribution choice, and the confidence limits that carry the record length into the result. This guide covers all of it, as part of the flood frequency and streamflow statistics topic within rainfall-runoff modeling and hydrologic simulation.

Prerequisites

Core Technique: Three Moments and a Frequency Factor

The fit computes the mean, standard deviation and skew of the base-ten logarithms of the peaks. A quantile at exceedance probability p is then the log-mean plus the frequency factor for that probability and skew, times the log standard deviation — exponentiated back.

The frequency factor is where the skew enters, and the Wilson-Hilferty approximation gives it in closed form:

The Skew Controls the Whole Upper Tail Frequency factor curves against return period from 2 to 500 years for skews of minus 0.4, minus 0.2, zero, plus 0.4 and plus 0.8. The curves are nearly identical at a 2-year return period and diverge sharply beyond 50 years, where a skew of plus 0.8 gives a factor half again as large as zero skew. 2 10 50 100 500 return period (years) 0 1.0 2.0 3.0 4.0 Kt −0.4 −0.2 0.0 +0.4 +0.8 the five curves are almost indistinguishable at short return periods, and this is why a bad skew hides until you extrapolate

Annotated Code Example

python
import logging

import numpy as np
import pandas as pd
from scipy import stats

log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")


def frequency_factor(p_exceed: float, skew: float) -> float:
    """
    Pearson III frequency factor via the Wilson-Hilferty approximation.

    Reduces to the standard normal deviate at zero skew, which is the
    log-normal case, and is accurate across the skew range seen in practice.
    """
    z = stats.norm.ppf(1.0 - p_exceed)
    if abs(skew) < 1e-6:
        return float(z)
    k = skew / 6.0
    return float((2.0 / skew) * (((z - k) * k + 1.0) ** 3 - 1.0))


def station_skew_mse(station_skew: float, n: int) -> float:
    """
    Mean square error of a station skew estimate, as a function of record
    length. Short records give a large MSE, which is what pulls the weighted
    skew toward the regional value.
    """
    a = (-0.33 + 0.08 * abs(station_skew) if abs(station_skew) <= 0.9
         else -0.52 + 0.30 * abs(station_skew))
    b = (0.94 - 0.26 * abs(station_skew) if abs(station_skew) <= 1.5 else 0.55)
    return float(10 ** (a - b * np.log10(n / 10.0)))


def fit_lp3(
    peaks: pd.Series,
    regional_skew: float | None = None,
    regional_mse: float = 0.302,
    return_periods=(2, 5, 10, 25, 50, 100, 200, 500),
    confidence: float = 0.90,
) -> dict:
    """
    Fit Log-Pearson III and return quantiles, plotting positions and diagnostics.
    """
    s = pd.Series(peaks).dropna()
    s = s[s > 0]
    n = len(s)
    if n < 10:
        raise ValueError(f"{n} usable years is too short for a frequency fit")

    logs = np.log10(s.to_numpy(dtype=float))
    mean_log, std_log = float(logs.mean()), float(logs.std(ddof=1))
    g_station = float(stats.skew(logs, bias=False))

    skew, weight = g_station, 1.0
    if regional_skew is not None:
        mse_station = station_skew_mse(g_station, n)
        weight = regional_mse / (regional_mse + mse_station)
        skew = weight * g_station + (1.0 - weight) * regional_skew
        log.info("Station skew %.3f (MSE %.3f, weight %.2f), regional %.3f "
                 "→ weighted %.3f", g_station, mse_station, weight,
                 regional_skew, skew)

    z_conf = stats.norm.ppf(0.5 + confidence / 2.0)
    rows = []
    for T in return_periods:
        p = 1.0 / T
        kt = frequency_factor(p, skew)
        log_q = mean_log + kt * std_log
        # The quantile's standard error grows with |Kt| and shrinks with n,
        # which is why the band flares at long return periods on short records.
        se = std_log * np.sqrt((1.0 + 0.5 * kt ** 2) / n)
        rows.append({
            "return_period": T,
            "kt": kt,
            "estimate": 10 ** log_q,
            "lower": 10 ** (log_q - z_conf * se),
            "upper": 10 ** (log_q + z_conf * se),
        })

    quantiles = pd.DataFrame(rows).set_index("return_period")

    # --- Plotting positions for the visual check. Weibull is unbiased for the
    # exceedance probability, which is what we are plotting against. ---
    ranked = np.sort(s.to_numpy(dtype=float))[::-1]
    ranks = np.arange(1, n + 1)
    plotting = pd.DataFrame({
        "peak": ranked,
        "exceedance": ranks / (n + 1.0),
        "return_period": (n + 1.0) / ranks,
    })

    q100 = quantiles.loc[100]
    log.info("n=%d, mean(log)=%.4f, sd(log)=%.4f, skew=%.3f", n, mean_log, std_log, skew)
    log.info("Q100 = %.1f  [%.1f, %.1f]  — band spans a factor of %.2f",
             q100.estimate, q100.lower, q100.upper, q100.upper / q100.lower)
    if n < 100:
        log.warning("Record is %d years; the 100-year estimate is an "
                    "extrapolation of %.1f× the record length", n, 100 / n)

    # --- Leave-one-out on the largest peak: if the answer moves a lot, the
    # result is one flood's opinion and must be reported as such. ---
    without_max = fit_lp3_quantile(s.drop(s.idxmax()), 100, regional_skew, regional_mse)
    shift = 100.0 * (without_max - q100.estimate) / q100.estimate
    log.info("Dropping the largest peak moves Q100 by %+.1f %%", shift)

    return {
        "n": n, "mean_log": mean_log, "std_log": std_log,
        "station_skew": g_station, "weighted_skew": skew, "skew_weight": weight,
        "quantiles": quantiles, "plotting_positions": plotting,
        "q100_sensitivity_pct": shift,
    }


def fit_lp3_quantile(peaks: pd.Series, T: int, regional_skew=None,
                     regional_mse: float = 0.302) -> float:
    """A single quantile, used by the leave-one-out check."""
    logs = np.log10(pd.Series(peaks).dropna().pipe(lambda x: x[x > 0]).to_numpy(float))
    n = len(logs)
    g = float(stats.skew(logs, bias=False))
    if regional_skew is not None:
        w = regional_mse / (regional_mse + station_skew_mse(g, n))
        g = w * g + (1 - w) * regional_skew
    return float(10 ** (logs.mean() + frequency_factor(1.0 / T, g) * logs.std(ddof=1)))


# --- Example usage ---
# result = fit_lp3(natural_peaks, regional_skew=-0.05)
# print(result["quantiles"].round(1))

Parameter Reference

Parameter Typical Effect
regional_skew −0.4 to +0.4 Stabilises the tail; omitting it lets one flood set the design value
regional_mse 0.302 The published value for the generalised skew map; a regional study may supply a smaller one
confidence 0.90 Two-sided; the reported band is what makes the estimate honest
Plotting positions Weibull Only affects the visual check, not the fit
Minimum n 10 Below 30, report the extrapolation ratio prominently

Worked Example: Reading the Diagnostics

A 34-year natural record with a regional skew of −0.05:

Diagnostic Value Reading
Station skew +0.412 Large for the record length
Station skew MSE 0.181 Comparable to the regional MSE
Weighted skew +0.238 Pulled roughly 40 % toward the regional value
Q100 estimate 486 m³/s
Q100 90 % band 371–637 m³/s Spans a factor of 1.72
Q100 without the largest peak 441 m³/s −9.3 %

The last row is the one to lead with. A 9 % shift from dropping one observation is normal for a record of this length; a shift above 20 % would mean the curve is describing a single flood rather than a distribution, and the confidence band should be quoted in the headline rather than in a footnote.

How Much One Flood Moves the Answer The percentage change in the 100-year quantile when the largest peak is removed, for records of 15, 34 and 70 years. A 15-year record moves 26 percent, a 34-year record 9 percent and a 70-year record 3 percent. −26 % 15 years one flood is the curve −9 % 34 years normal for the length −3 % 70 years a distribution, not an event shift in Q100 change in the 100-year quantile when the largest peak is dropped

How much the record can be trusted to say is a function of its length, and the practical guidance follows directly from the confidence band rather than from convention.

What Record Length Buys The 90 percent confidence band on a 100-year quantile spans a factor of 2.6 with a 15-year record, 1.9 with 25 years, 1.5 with 45 years, 1.3 with 70 years and 1.2 with 100 years. ×2.6 — not an estimate ×1.9 ×1.3 — a usable design number 15 yr 25 45 70 100 record length used for the fit band width, ratio

Gotchas and Edge Cases

  • Unweighted station skew on a short record. The single most consequential shortcut in the whole procedure.
  • Zero or negative peaks. Cannot be log-transformed. A zero annual peak on an ephemeral stream needs conditional-probability treatment, not deletion.
  • Mixed populations. Snowmelt and rainfall peaks pooled produce a visible kink against the plotting positions. Fit separately and combine the probabilities.
  • Regulated years included. Truncates the upper tail after the construction date. Split the record.
  • Confidence limits computed with the normal deviate rather than a non-central t. The simplification used above is adequate for reporting and slightly narrow in the far tail; where a formal interval is required, use the non-central t formulation.
  • Reporting to three significant figures. A Q100 of 486 m³/s with a band of 371 to 637 is not known to three figures. Round the estimate to the precision the band supports.
  • Extrapolating past 2n years. Beyond about twice the record length the curve is an assumption about the distribution’s shape, not an inference from data.