Split-Sample Validation for Hydrologic Models
A calibrated model reproduces the period it was calibrated on; that is what calibration means and it is not evidence of anything. The question that matters is whether the parameters describe the catchment or the period, and only data the optimiser never saw can answer it. This guide covers the split-sample designs that do, as part of the model calibration and objective functions topic within rainfall-runoff modeling and hydrologic simulation.
Prerequisites
- An observed flow record long enough to split — a decade at minimum, with several years in each half.
- A calibration driver that can be pointed at a date range, and forcing covering the whole record.
pandas,numpy, and the metric implementations from computing Nash-Sutcliffe efficiency and KGE in Python.
Core Technique: Four Split Designs
The reversed split is the cheapest useful addition and the most often skipped. Running both directions and comparing tells you whether the two halves are hydrologically similar: if calibrating on either half validates well on the other, the parameters describe the catchment. If one direction works and the other does not, one half contains behaviour the other does not, and that is a finding about the record.
Annotated Code Example
import logging
from dataclasses import dataclass
import numpy as np
import pandas as pd
log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
@dataclass
class SplitResult:
design: str
calibration_period: tuple
validation_period: tuple
calibration_score: float
validation_score: float
@property
def drop(self) -> float:
return self.calibration_score - self.validation_score
def kge(sim: np.ndarray, obs: np.ndarray) -> float:
"""Kling-Gupta efficiency on aligned, gap-free arrays."""
m = np.isfinite(sim) & np.isfinite(obs)
s, o = sim[m], obs[m]
if len(s) < 10 or o.std() == 0:
return float("nan")
r = float(np.corrcoef(s, o)[0, 1])
alpha = float(s.std() / o.std())
beta = float(s.mean() / o.mean())
return float(1 - np.sqrt((r - 1) ** 2 + (alpha - 1) ** 2 + (beta - 1) ** 2))
def split_sample(
observed: pd.Series,
calibrate_fn,
simulate_fn,
design: str = "simple",
warmup_days: int = 365,
) -> list[SplitResult]:
"""
Run a split-sample validation.
Parameters
----------
observed : Observed discharge, indexed by date.
calibrate_fn : Callable(period) -> parameter dict.
simulate_fn : Callable(params, period) -> simulated Series.
design : "simple", "reversed", "both", or "differential".
warmup_days : Days discarded at the start of every simulated period, so
the initial state does not contaminate the score.
"""
obs = observed.dropna().sort_index()
if len(obs) < 3 * 365:
raise ValueError("record is too short to split meaningfully")
def score(params, period):
sim = simulate_fn(params, period)
start = period[0] + pd.Timedelta(days=warmup_days)
# Discarding the warm-up is not optional: a model started from an
# arbitrary state reproduces the first months badly no matter how
# well calibrated, and including them penalises every period equally
# but unequally in magnitude.
s = sim.loc[start:period[1]]
o = obs.loc[start:period[1]]
common = s.index.intersection(o.index)
return kge(s.loc[common].to_numpy(), o.loc[common].to_numpy())
mid = obs.index[len(obs) // 2]
first = (obs.index[0], mid)
second = (mid, obs.index[-1])
designs = []
if design in ("simple", "both"):
designs.append(("simple", first, second))
if design in ("reversed", "both"):
designs.append(("reversed", second, first))
if design == "differential":
# Split by annual total rather than by date, so the test is whether the
# parameters transfer across wetness regimes.
annual = obs.resample("YE").sum()
median = annual.median()
wet_years = set(annual[annual >= median].index.year)
wet_mask = obs.index.year.isin(list(wet_years))
log.info("Differential split: %d wet year(s), %d dry year(s)",
len(wet_years), len(annual) - len(wet_years))
designs.append(("differential (wet→dry)",
(obs.index[0], obs.index[-1]), (obs.index[0], obs.index[-1])))
# Where the calibrate/simulate callables accept a mask, pass it; the
# period tuples above are retained only for reporting.
results = []
for name, cal_period, val_period in designs:
params = calibrate_fn(cal_period)
cal = score(params, cal_period)
val = score(params, val_period)
r = SplitResult(name, cal_period, val_period, cal, val)
results.append(r)
log.info("%-22s calibration KGE %.3f, validation KGE %.3f (drop %.3f)",
name, cal, val, r.drop)
if r.drop > 0.20:
log.error("A drop of %.2f suggests the parameters absorbed "
"period-specific behaviour rather than catchment behaviour",
r.drop)
elif r.drop < -0.05:
log.warning("Validation scored HIGHER than calibration by %.2f — the "
"validation period is probably easier, not the model "
"better", -r.drop)
else:
log.info("Drop of %.2f is within the range expected from fitting", r.drop)
if len(results) == 2:
asym = abs(results[0].validation_score - results[1].validation_score)
if asym > 0.15:
log.error("The two directions disagree by %.2f KGE — the halves of "
"the record are hydrologically different; report both",
asym)
else:
log.info("Both directions agree to within %.2f KGE", asym)
return results
# --- Example usage ---
# results = split_sample(observed_q, calibrate_fn=my_calibrator,
# simulate_fn=my_model, design="both")
Parameter Reference
| Choice | Recommendation | Why |
|---|---|---|
| Split point | Halfway by time, or by wetness | A split at a land-use change is a different test, worth doing deliberately |
warmup_days |
365 for a continuous model | An arbitrary initial state contaminates the first months |
| Design | both at minimum |
The reversed direction is nearly free and often the informative one |
| Acceptable drop | ≤ 0.10 in KGE | Above 0.20, treat the calibration as overfitted |
| Recalibration after validation | Only with a three-way split | Otherwise the validation period becomes a calibration period |
Worked Example: Reading the Two Directions
A 22-year record on a 480 km² basin:
| Design | Calibration KGE | Validation KGE | Drop |
|---|---|---|---|
| Simple (first → second) | 0.84 | 0.79 | 0.05 |
| Reversed (second → first) | 0.81 | 0.62 | 0.19 |
| Differential (wet → dry) | 0.86 | 0.54 | 0.32 |
Read as a set, these say something specific. The two chronological directions disagree, and the differential test fails badly. The parameters calibrated on wet conditions do not reproduce dry ones, which points at the storage or recession components rather than at the event response. That is a diagnosis, and it is only available because three tests were run rather than one.
Reporting the simple split alone — 0.84 calibration, 0.79 validation — would have described this model as transferable.
Warm-up length matters more on a model with slow stores than on a flashy one, and the score keeps improving until the state has forgotten its initial value.
Gotchas and Edge Cases
- Warm-up included in the score. Every period is penalised, but the calibration period’s optimiser compensates for it and the validation period’s does not, which exaggerates the drop.
- A split at a regime change. Calibrating before a dam and validating after tests something real but not what the report usually claims. Split deliberately and say so.
- Recalibrating after a bad validation. The validation period becomes a calibration period the moment it influences a parameter.
- Only the forward direction reported. Half the information for a fraction of the extra cost.
- Validation higher than calibration read as success. It nearly always means the validation period is easier — fewer events, or events of a kind the model handles well.
- Both halves too short. With five years each, the split is dominated by which half got the big flood. A decade per half is a reasonable floor.
- One objective function throughout. A model that transfers on KGE and fails on log-NSE transfers for peaks and not for baseflow, which is worth knowing.
Related Topics
- Model Calibration & Objective Functions — the parent topic: the objectives and the optimisation loop this validates
- Computing Nash-Sutcliffe Efficiency and KGE in Python — the metrics used to score each period
- Automated Hydrologic Model Calibration with SciPy and SPOTPY — the calibration driver this wraps
- Flood Frequency and Streamflow Statistics — the independent check a validated model should still be compared against