library(corehydror)29. Machine learning
The Numerics MachineLearning layer, which had no R or Python binding until this release: three unsupervised methods (k-means, Gaussian mixture models, Jenks natural breaks) and five supervised ones (decision trees, random forests, k-nearest neighbors, Gaussian naive Bayes, and generalized linear models). This page works through all eight on one regional flood-frequency problem – the kind of regionalization and prediction-in-ungauged-basins task these methods actually get used for in hydrology.
What you’ll learn
- How to group catchments into homogeneous regions with
ml_kmeans()andml_gaussian_mixture(), and what the mixture model tells you that k-means cannot. - How
ml_jenks_breaks()cuts a continuous variable into hazard classes, and why it will happily give you a class of one. - How to fit a regional flood-frequency regression with
ml_glm(), and a Poisson count model with the same verb and a different link. - How
ml_random_forest()compares against that regression out of sample – including a case where the parametric model wins, and why. - How
ml_naive_bayes()andml_knn()classify catchments above and below a flood threshold. - Why every fit on this page is reproducible, and what the seeds are actually doing.
Setup
A regional dataset
Thirty-six gauged catchments, each with a drainage area (km2), mean annual precipitation (mm), mean basin slope (m/m), and mean annual flood (m3/s). The values are illustrative rather than observed – they are generated from a power-law regional relation with a deterministic wobble, so the page’s numbers are stable and the R and Python versions define byte-identical inputs – but the shape and the ranges are those of a real regional study.
area <- c(
14.1, 11.4, 10.0, 13.1, 13.7, 10.5,
10.7, 13.9, 12.9, 9.9, 11.7, 14.2,
165.0, 133.6, 116.7, 152.5, 160.1, 122.4,
124.4, 161.7, 150.0, 115.8, 136.2, 165.2,
1060.6, 858.6, 750.0, 980.0, 1029.4, 786.6,
799.9, 1039.2, 964.3, 744.3, 875.9, 1062.0
)
precip <- c(
606, 634, 682, 590, 665, 657,
593, 686, 626, 613, 690, 600,
928, 971, 1044, 904, 1018, 1006,
908, 1051, 958, 938, 1057, 919,
1373, 1437, 1545, 1337, 1506, 1488,
1343, 1554, 1418, 1388, 1564, 1359
)
slope <- c(
0.0911, 0.0821, 0.0677, 0.0589, 0.0622, 0.0753,
0.0881, 0.0910, 0.0818, 0.0675, 0.0588, 0.0624,
0.0389, 0.0350, 0.0289, 0.0251, 0.0266, 0.0321,
0.0376, 0.0388, 0.0349, 0.0288, 0.0251, 0.0266,
0.0134, 0.0120, 0.0099, 0.0086, 0.0091, 0.0110,
0.0129, 0.0133, 0.0120, 0.0099, 0.0086, 0.0092
)
flood <- c(
2.18, 1.90, 1.79, 1.69, 2.13, 1.80,
1.73, 2.47, 2.09, 1.50, 1.98, 1.83,
15.67, 15.17, 12.87, 13.48, 15.24, 14.37,
12.41, 19.79, 14.91, 12.05, 14.15, 14.68,
68.03, 66.88, 55.60, 59.71, 65.53, 63.93,
53.11, 88.39, 63.52, 54.04, 60.00, 66.11
)
length(area)[1] 36
Basin characteristics span orders of magnitude, so every method on this page works in log10 space. That is not a detail: k-means and k-nearest neighbors measure Euclidean distance, so an untransformed area column would swamp a slope column outright.
X <- cbind(log10(area), log10(precip), log10(slope))
colnames(X) <- c("log_area", "log_precip", "log_slope")
round(head(X, 3), 4) log_area log_precip log_slope
[1,] 1.1492 2.7825 -1.0405
[2,] 1.0569 2.8021 -1.0857
[3,] 1.0000 2.8338 -1.1694
Regionalization: k-means
Regionalization is the task of grouping catchments that behave alike, so a flood estimate can be transferred from gauged to ungauged basins within a group. ml_kmeans() does it by minimizing within-cluster distance.
km <- ml_kmeans(X, k = 3, seed = 20260827)
table(km$labels)
0 1 2
12 12 12
labels are 0-based in both R and Python, matching the library’s own indexing – add 1 before using them to subset an R object. Here the three clusters recover the three catchment size classes exactly, twelve each:
round(km$means, 4) [,1] [,2] [,3]
[1,] 2.9565 3.1585 -1.9713
[2,] 1.0817 2.8033 -1.1370
[3,] 2.1484 2.9884 -1.5069
The centroids are in log10 space, so the third cluster’s log_area of 2.96 is a catchment of about 900 km2. The fit converged in 2 iterations.
Regionalization: a Gaussian mixture
ml_gaussian_mixture() fits the same kind of grouping but carries a full covariance matrix per component rather than only a centre, so it can represent clusters that are elongated or correlated rather than spherical. That matters here because area, precipitation and slope co-vary strongly.
gmm <- ml_gaussian_mixture(X, k = 3, seed = 20260827)
round(gmm$weights, 6)[1] 0.333333 0.333333 0.333333
Equal mixing weights, and the same three groups. What the mixture adds is the covariance:
round(gmm$sigmas[[3]], 5) [,1] [,2] [,3]
[1,] 0.00328 -0.00019 0.00020
[2,] -0.00019 0.00060 -0.00016
[3,] 0.00020 -0.00016 0.00496
The off-diagonal terms are the useful part – within the largest catchments, log_area and log_slope move together (steeper basins in this set are smaller), which k-means has no way to express.
log_likelihood follows the library’s own definition, which omits the multivariate-normal normalizing constant. It is short of the true mixture log-likelihood by nrow(X) * ncol(X) / 2 * log(2 * pi). That constant cancels when comparing two fits of the same data, which is what the library uses it for, but do not feed this value straight to AIC.
Hazard classes: Jenks natural breaks
ml_jenks_breaks() cuts a continuous variable into classes that minimize within-class variance – the standard way to build a legend for a hazard map.
jn <- ml_jenks_breaks(flood, n_clusters = 4)
round(jn$breaks, 2)[1] 2.47 19.79 68.03 88.39
jn$clusters[, c("count", "min", "max")] count min max
[1,] 12 1.50 2.47
[2,] 12 12.05 19.79
[3,] 11 53.11 68.03
[4,] 1 88.39 88.39
Note the fourth class: a single catchment. Jenks minimizes within-class variance and nothing else, so a genuine outlier (the 88.4 m3/s catchment) gets a class to itself rather than being absorbed. That is correct behaviour and usually a signal to look at that gauge, not to change n_clusters.
round(jn$gvf, 6)[1] 0.987557
The goodness-of-variance-fit measure runs from 0 to 1; 0.988 says four classes explain nearly all the variance in the flood column.
Regional regression: ml_glm()
The classic regional flood-frequency model is a power law in the basin characteristics, which is a linear model in log space. Split the catchments into a training set and a held-out test set of nine:
train <- c(1:9, 13:21, 25:33)
test <- setdiff(seq_along(flood), train)
fit <- ml_glm(X[train, ], log10(flood[train]))
round(fit$coefficients, 5)[1] 0.01087 0.92989 -0.17801 0.20440
Read as a power law, the fitted exponents are 0.930 on area, -0.178 on precipitation and 0.204 on slope. Their standard errors say how much to trust each:
round(fit$standard_errors, 5)[1] 0.87750 0.06683 0.34598 0.10998
round(fit$p_values, 5)[1] 0.99012 0.00000 0.60691 0.06310
Only the area exponent is well determined – unsurprising, since area spans two orders of magnitude here while precipitation spans less than one.
A Poisson model with the same verb
ml_glm()’s link argument selects the response family as well as the transform, so a count response needs only a different link. Here the count is the number of days per year each catchment exceeds its local flood threshold:
exceedances <- c(
1, 2, 3, 1, 2, 2, 1, 3, 2, 1, 3, 1,
4, 5, 7, 4, 6, 6, 4, 7, 5, 4, 7, 4,
9, 11, 14, 8, 12, 12, 8, 14, 10, 9, 14, 9
)
Xp <- cbind(log10(precip), log10(area))
pois <- ml_glm(Xp, exceedances, link = "log")
round(pois$coefficients, 5)[1] 0.01320 -0.12084 0.92862
round(pois$aic, 3)[1] 142.878
The fitted counts track the observed ones closely:
fitted_counts <- exp(as.vector(cbind(1, Xp) %*% pois$coefficients))
round(cor(fitted_counts, exceedances), 6)[1] 0.911463
The other three links, "logit", "probit" and "complementary_log_log", take a 0/1 response and fit the corresponding binomial model.
Random forest, and when it loses
ml_random_forest() grows many trees on bootstrap resamples and reports the spread of their predictions as an interval, which is what makes it attractive when the functional form is unknown.
rf <- ml_random_forest(X[train, ], log10(flood[train]), newdata = X[test, ],
seed = 20260827, number_of_trees = 200)
round(head(rf, 3), 4) lower median upper mean
[1,] 0.2529 0.2553 0.3201 0.2699
[2,] 0.2279 0.3284 1.1951 0.5425
[3,] 0.2776 0.3385 1.1951 0.6306
Compare both models out of sample:
rmse <- function(a, b) sqrt(mean((a - b)^2))
lm_pred <- as.vector(cbind(1, X[test, ]) %*% fit$coefficients)
c(
glm = rmse(lm_pred, log10(flood[test])),
random_forest = rmse(rf[, "median"], log10(flood[test]))
) glm random_forest
0.04068522 0.06857046
The regression wins, by a factor of about 1.7 in RMSE. That is the right answer, and it is worth being explicit about why, because the usual demonstration runs the other way:
- The data really is a power law, so a model that assumes one is using information the forest has to rediscover from 27 training points.
- A tree predicts by averaging the training responses in a leaf, so it cannot extrapolate. Every prediction is inside the range of the training data, which is exactly wrong for a regional relation you want to apply to an ungauged basin outside the gauged range.
Reach for the forest when you suspect interactions or thresholds you cannot write down, not as a default. The same trade-off is why a bare ml_decision_tree() is rarely the end of an analysis: at its defaults a regression tree recurses until every leaf holds one training observation, so it memorizes the training set.
Classification: above or below a threshold
Recast the problem as a yes-or-no question – will this catchment’s mean annual flood exceed 20 m3/s? – and two classifiers answer it.
big <- as.numeric(flood > 20)
nb <- ml_naive_bayes(X[train, ], big[train], newdata = X[test, ])
knn_pred <- ml_knn(X[train, ], big[train], newdata = X[test, ], k = 3, regression = FALSE)
data.frame(truth = big[test], naive_bayes = nb$prediction, knn = knn_pred) truth naive_bayes knn
1 0 0 0
2 0 0 0
3 0 0 0
4 0 0 0
5 0 0 0
6 0 0 0
7 1 1 1
8 1 1 1
9 1 1 1
Both classify all nine held-out catchments correctly. Naive Bayes also reports the per-class feature distributions it fitted, which is often more informative than the predictions:
round(nb$means, 4) [,1] [,2] [,3]
[1,] 1.6183 2.8965 -1.3003
[2,] 2.9598 3.1591 -1.9499
round(nb$priors, 4)[1] 0.6667 0.3333
classes follows the order the labels first appear in the training response, not sorted order, and every row of means, standard_deviations and priors is indexed to match.
ml_knn() can also report which training catchments a prediction came from, which is exactly the “donor catchment” idea regional hydrology uses directly:
neighbours <- ml_knn(X[train, ], big[train], newdata = X[test, , drop = FALSE],
k = 3, what = "neighbors")
head(neighbours, 3) [,1] [,2] [,3]
[1,] 2 5 1
[2,] 4 3 2
[3,] 3 4 8
These are 0-based indices into the training rows, like the cluster labels.
Reproducibility
Every method on this page that draws a random number takes a seed, and a seeded fit is reproducible: repeated calls agree bit for bit, and so do the R and Python versions of this page, because the entire computation happens in the shared compiled core with the same bit-exact Mersenne Twister.
identical(
ml_random_forest(X[train, ], log10(flood[train]), newdata = X[test, ],
seed = 20260827, number_of_trees = 200),
rf
)[1] TRUE
Leave seed at its default and the generator is seeded from the clock instead, which is the right choice when you want an honest sense of run-to-run variability and the wrong one when you want a result you can publish.
Reproduction check
Every value below is deterministic given the fixed inputs and seeds above, checked at 1e-15 relative tolerance against this page’s own executed values. The k-means and naive-Bayes paths are additionally pinned against the upstream C# oracles in fixtures/ml/machine_learning.json, which the dotnet gate replays against the real library; those fixtures use the iris measurements the C# tests use, so what is checked here is that this page’s own numbers do not drift.
near <- \(x, literal, tol = 1e-15) abs(x / literal - 1) < tol
stopifnot(
# Clustering recovers the three size classes exactly.
identical(as.integer(table(km$labels)), c(12L, 12L, 12L)),
km$iterations == 2L,
near(km$means[1, 1], 2.9565201679743947),
near(km$means[1, 3], -1.9713001801472083),
# The mixture puts equal weight on the three components and its covariances are symmetric.
all(abs(gmm$weights - 1 / 3) < 1e-12),
near(gmm$log_likelihood, 238.87583471023325),
all(abs(gmm$sigmas[[3]] - t(gmm$sigmas[[3]])) < 1e-12),
# Jenks, including the single-member top class.
near(jn$breaks[4], 88.390000000000001),
identical(as.integer(jn$clusters[, "count"]), c(12L, 12L, 11L, 1L)),
near(jn$gvf, 0.98755725105630288),
# The regional regression.
near(fit$coefficients[2], 0.92989165483884118),
near(fit$standard_errors[2], 0.066834707598246085),
near(fit$sigma, 0.04726688233226619),
fit$n == 27L, fit$df == 23L,
# The Poisson model.
near(pois$coefficients[3], 0.92861744959233672),
near(pois$aic, 142.87786000842539),
near(cor(fitted_counts, exceedances), 0.91146273024268387),
# The forest is seeded-reproducible, its interval is ordered, and the regression beats it here.
all(rf[, "lower"] <= rf[, "median"]), all(rf[, "median"] <= rf[, "upper"]),
near(rmse(lm_pred, log10(flood[test])), 0.040685221310457167),
near(rmse(rf[, "median"], log10(flood[test])), 0.068570459600494127),
rmse(lm_pred, log10(flood[test])) < rmse(rf[, "median"], log10(flood[test])),
# Both classifiers get all nine held-out catchments right.
identical(as.numeric(nb$prediction), big[test]),
identical(as.numeric(knn_pred), big[test]),
identical(as.numeric(nb$classes), c(0, 1)),
all(neighbours >= 0 & neighbours < 27),
identical(dim(neighbours), c(9L, 3L))
)
cat("All reproduction checks passed.\n")All reproduction checks passed.