import numpy as np
import matplotlib.pyplot as plt
import corehydropy as ch13. A custom objective
Every other fitting verb in this package – Distribution.fit(), fit_mle(), model_bulletin17c() and friends – wraps a model the port already knows how to build. Sometimes the model is yours: a custom rating curve, a regional regression, anything you can write as a likelihood in a handful of lines. optim_minimize()/optim_maximize() expose the same six ported Numerics optimizers those verbs use internally, over a plain Python function, so a hand-written likelihood gets the same optimizers – including the same seeded differential evolution, whose PARAMETERS reproduce bit-exact across languages – as the built-in models. It has no upstream counterpart: the USACE-RMC Numerics-Python-Examples repository has no general-purpose optimization notebook.
What you’ll learn
- Write a small negative log-likelihood by hand and hand it to
optim_minimize(). - Fit it two ways – global (
"de", seeded) and local ("bfgs", from a starting guess) – and see the two agree. - Confirm the seeded
"de"run’s PARAMETERS are bit-identical to the R twin’s: the optimizer’s candidate-generating random number stream lives in the shared C++ core, not in R or Python. optim_maximize()’s sign convention: it does not negate the objective’s own value.
Setup
A synthetic rating curve
A power-law stage-discharge relationship, \(Q = a\,(h - h_0)^b\), is the classic hydraulic rating curve – discharge as a power of stage above some effective channel-control elevation \(h_0\). This is deliberately NOT the BaRatin model behind rating_curve_analysis() (that one fits a matrix-of-controls addition-mode curve with its own log10-space likelihood); it is a small, different model written from scratch to show what optim_minimize() is for.
Twenty-five stage readings and a synthetic “true” curve with multiplicative log-normal measurement error – the noise draw is seeded through the port, so it is bit-identical to the R twin’s.
stage = np.linspace(1, 10, 25)
a_true, b_true, h0_true = 15.0, 1.7, 0.5
discharge_true = a_true * (stage - h0_true) ** b_true
noise = np.asarray(ch.Distribution("Normal", [0, 0.05]).random(25, seed=2024))
discharge = discharge_true * np.exp(noise)
print("First three discharges:", ", ".join(f"{v:.6f}" for v in discharge[:3]))
plt.scatter(stage, discharge, color="#5b7a8c", label="Observed (with noise)", zorder=3)
plt.plot(stage, discharge_true, color="#6b7f3f", linewidth=2, label="True curve")
plt.xlabel("Stage (ft)")
plt.ylabel("Discharge (cfs)")
plt.title("Synthetic rating curve data")
plt.legend()
plt.show()First three discharges: 4.668426, 12.377813, 22.499345

The likelihood
Four parameters: \(a\), \(b\), \(h_0\), and the log-space error standard deviation \(\sigma\). Assuming independent log-normal errors, the negative log-likelihood is
\[ -\ell(a, b, h_0, \sigma) = \sum_{i=1}^{n} \left[ \tfrac12 \log(2\pi\sigma^2) + \frac{\left(\log Q_i - \log\hat{Q}_i\right)^2}{2\sigma^2} \right], \qquad \hat{Q}_i = a\,(h_i - h_0)^b \]
optim_minimize() needs lower/upper bounds for every method, including "de", which takes no starting guess at all; "bfgs" additionally needs one (initial).
def nll(p):
a, b, h0, sigma = p
predicted = a * np.maximum(stage - h0, 1e-6) ** b
resid = np.log(discharge) - np.log(predicted)
return -np.sum(-0.5 * np.log(2 * np.pi * sigma**2) - resid**2 / (2 * sigma**2))
lower = [1, 0.5, 0, 0.001]
upper = [50, 4, 2, 1]Fit two ways
Differential evolution ("de") is a global, population-based, stochastic method – seeded here, so it reproduces exactly on every run and, more importantly, its PARAMETERS reproduce exactly in the R twin (see the reproduction check for the caveat on value and on BFGS). BFGS is a local, gradient-based method that needs a starting guess; control={"compute_hessian": False} skips the (here, unneeded) numerical Hessian both methods compute by default.
fit_de = ch.optim_minimize(nll, lower=lower, upper=upper, method="de", seed=2024,
control={"compute_hessian": False})
fit_bfgs = ch.optim_minimize(nll, lower=lower, upper=upper, initial=[10, 1, 0, 0.1],
method="bfgs", control={"compute_hessian": False})
print(fit_de)
print(fit_bfgs)<OptimResult Success after 173 iterations (6960 evaluations)>
value: -37.317232
parameters: [15.050239 1.694614 0.495115 0.054387]
<OptimResult Success after 26 iterations (630 evaluations)>
value: -37.317232
parameters: [15.049867 1.694626 0.495104 0.054387]
Global and local agree to about four decimal places, and both land close to the true generating parameters (\(a=15\), \(b=1.7\), \(h_0=0.5\), \(\sigma=0.05\)) – unsurprising with a clean, well-identified likelihood and only mild noise, but worth checking rather than assuming.
comparison = {
"True": [a_true, b_true, h0_true, 0.05],
"DE": fit_de.parameters,
"BFGS": fit_bfgs.parameters,
}
for name, params in comparison.items():
print(f"{name:>5}:", np.round(params, 4)) True: [15. 1.7 0.5 0.05]
DE: [15.0502 1.6946 0.4951 0.0544]
BFGS: [15.0499 1.6946 0.4951 0.0544]
optim_maximize()’s sign convention
Maximizing the log-likelihood directly (the negation of nll) with the same seed lands on the same parameters DE found minimizing nll – and optim_maximize()’s reported value is the objective’s OWN value at the optimum, not negated, so it comes out as -fit_de.value rather than fit_de.value.
def log_likelihood(p):
return -nll(p)
fit_de_max = ch.optim_maximize(log_likelihood, lower=lower, upper=upper, method="de", seed=2024,
control={"compute_hessian": False})
print(f"optim_minimize(nll).value: {fit_de.value:.6f}")
print(f"optim_maximize(log_likelihood).value: {fit_de_max.value:.6f}")
print("Same parameters:", np.allclose(fit_de.parameters, fit_de_max.parameters))optim_minimize(nll).value: -37.317232
optim_maximize(log_likelihood).value: 37.317232
Same parameters: True
Plot: fitted curve
h_grid = np.linspace(stage.min(), stage.max(), 200)
a_hat, b_hat, h0_hat, _ = fit_de.parameters
fitted_curve = a_hat * np.maximum(h_grid - h0_hat, 0) ** b_hat
plt.scatter(stage, discharge, color="#5b7a8c", label="Observed", zorder=3)
plt.plot(h_grid, fitted_curve, color="#b06a3b", linewidth=2, label="Fitted (DE)")
plt.plot(stage, discharge_true, color="#6b7f3f", linewidth=2, linestyle="--", label="True curve")
plt.xlabel("Stage (ft)")
plt.ylabel("Discharge (cfs)")
plt.title("Fitted rating curve (DE)")
plt.legend()
plt.show()
Key takeaways
optim_minimize()/optim_maximize()take any Python callable of a numeric parameter vector – nothing about the model needs to be one the port already knows how to build.- A seeded
"de"run’s PARAMETERS are exactly reproducible, run to run and language to language: the PRNG lives in the shared C++ core, not in R’s or Python’s own random state. The reported objective VALUE only agrees to about 15 decimal digits, because it comes from re-evaluating each language’s own likelihood code. - Global (
"de") and local ("bfgs") methods are worth cross-checking against each other on the same objective, not just trusted individually. optim_maximize()’svalueis the objective’s own value at the optimum – never negated – so maximizinglog_likelihoodreports the log-likelihood itself, not its negation.
Reproduction check
No upstream literals exist for this example (it has no upstream counterpart), so every value below is an internal-consistency or cross-language check. One honest floating-point note, in the same spirit as example 09’s Box-Cox lambda: discharge and DE’s parameters are bit-identical across languages, because they never leave the shared, seeded C++ core (the synthetic-noise draw and DE’s own candidate-generation stream). fit_de.value, by contrast, comes from re-evaluating THIS PAGE’S OWN likelihood – a hand-written formula here, R’s dnorm(..., log = TRUE) in the R twin – at that shared parameter vector, and R’s and Python’s floating-point libraries do not guarantee bit-identical rounding for the same formula, so it only agrees to about 15 decimal digits, not all 17. BFGS goes further: as a local, gradient-based method, it is sensitive enough to those same sub-ulp differences that its OWN trajectory diverges slightly language to language (both still land within about 1e-10 of each other and close to the truth), so its numbers are not pinned against the R twin’s literal, only checked for internal consistency below.
def near(x, literal, tol=1e-15):
return abs(x / literal - 1) < tol
# DE and BFGS agree with each other to 1e-3 and recover parameters close to the truth.
assert max(abs(np.asarray(fit_de.parameters) - np.asarray(fit_bfgs.parameters))) < 1e-3
assert abs(fit_de.parameters[0] - a_true) < 0.2
assert abs(fit_de.parameters[1] - b_true) < 0.05
assert abs(fit_de.parameters[2] - h0_true) < 0.05
# optim_maximize()'s value is the exact negation of optim_minimize()'s on the negated objective,
# and both land on the same parameters.
assert np.allclose(fit_de.parameters, fit_de_max.parameters)
assert fit_de_max.value == -fit_de.value
# Cross-language identity: the R twin asserts these same literals for `discharge` and DE's
# `parameters` (bit-exact, see above); DE's `value` is asserted only to 1e-13 relative,
# comfortably above the ~1e-16 drift actually measured but well below "coincidentally close".
# BFGS is NOT pinned against the R twin -- see the prose above.
assert near(discharge[0], 4.66842623508654)
assert near(discharge[1], 12.377812912604149)
assert near(discharge[2], 22.499344656381929)
assert near(fit_de.parameters[0], 15.050239124703497)
assert near(fit_de.parameters[1], 1.6946139965788718)
assert near(fit_de.parameters[2], 0.49511501008458236)
assert near(fit_de.parameters[3], 0.054386727106046459)
assert near(fit_de.value, -37.317232039101832, tol=1e-13)
print("All reproduction checks passed.")All reproduction checks passed.