Estimate noise and stop on the floor#
Computational noise sets the smallest finite-difference step that is worth taking, and the gradient size at which a tighter tolerance is no longer a stationary point. The estimator is Moré and Wild, SIAM J. Sci. Comput. 33, 1292 (2011), https://doi.org/10.1137/100786125. The steps are Moré and Wild, ACM Trans. Math. Softw. 38, 19 (2012), https://doi.org/10.1145/2168773.2168777. The difference table itself is ECnoise.
Estimate#
use ndarray::array;
use rgmin::{ecnoise, sample_gradient_noise, sample_noise, NoiseFlag, DEFAULT_M};
let values = [1.0, 1.01, 1.0, 1.01, 1.0, 1.01, 1.0];
let table = ecnoise(&values).unwrap();
assert_eq!(table.ok, NoiseFlag::Detected);
let energy = sample_noise(&objective, x.view(), direction.view(), 1e-4, DEFAULT_M).unwrap();
let grad = sample_gradient_noise(
&objective,
x.view(),
direction.view(),
1e-4,
DEFAULT_M,
seed,
)
.unwrap();
h is the spacing between samples. m = 0 selects DEFAULT_M (8).
inform 2 retries at 100 h (next_spacing); inform 3 retries at
h / 100. NoiseEstimate::declared(eps) records a value the backend
already knows and skips the samples.
sample_gradient_noise projects the force onto one unit vector w.
Component i of w comes from a counter-based stream keyed by
(seed, i), not from a thread-local generator. The normal draw uses
the pure-Rust libm logarithm and cosine, so the bits do not depend
on the platform libm. The dot product is taken in index order. With
the par feature the m + 1 evaluations run in parallel and are
stored at the sample index, so the noise bits match a one-thread run.
sample_gradient_noise_k repeats that projection on k directions
keyed by (seed, j) and returns the root-mean-square of the detected
eps_g². k = 1 is the single-direction estimate. For any noise
covariance Σ, one uniform direction has E[eps_g²] = tr(Σ) / n,
because the sphere average of w wᵀ is I / n. The identity does
not need Σ to be isotropic.
Choose the step#
An ECnoise table is accepted because its high differences are noise.
Those differences are O(eps / h^k), so they are not a derivative.
|f''| and |T| come from Moré and Wild, ACM Trans. Math. Softw. 38,
19 (2012), Algorithm 5.1, https://doi.org/10.1145/2168773.2168777.
estimate_second(oracle, x, p, eps_f) builds a central second
difference at its own spacing. The first try is ha = eps_f^{1/4}.
It is accepted when |Δ²f| ≥ 100 eps_f and
|f(x±h) − f(x)| ≤ 0.1 max(|f(x)|, |f(x±h)|). One retry uses
hb = (eps_f / µa)^{1/4}. That retry is kept when it meets the same
two tests, and also when |µa − µb| ≤ µb / 2. Otherwise the estimate
is None.
estimate_third applies the same algorithm to the directional
derivative φ(t) = ∇f(x + t û) · û, with eps_g in place of eps_f.
The second difference of φ is the third-derivative scale |T| of
f along û. The two tests, the spacing, and the one retry are
unchanged apart from that substitution.
forward_step(eps_f, second, point_scale) returns
8^{1/4} sqrt(eps_f / |f''|). That step minimizes
(h/2)|f''| + sqrt(2) eps_f / h.
gradient_dr(eps_g, third, point_scale) returns
2 sqrt(eps_g / |T|). That step minimizes
(dr/2)|T| + 2 eps_g / dr.
point_scale is max_i |x_i|. The clamp is
[max(1e-16, 4 ulp(point_scale)), 1]. A step below one ulp of a
coordinate near 10 Å (1.8e-15) leaves x + h == x, so the lower
end follows the coordinate. A zero noise returns the lower end. A
zero scale, and a missing estimate (None), with positive noise
return the upper end.
Stop on the floor#
The floor is off until Control::noise_floor is set. gtol is
tested first, so a noise-free quadratic that meets gtol reports
Status::Tolerance.
let mut control = Control::default()
.with_noise_floor(NoiseFloor::new(grad.eps).with_c(3.0));
control.gtol = 1e-12;
NoiseFloor::new sets c = 3. The iteration stops when the
stationarity measure is below c * sqrt(n_free) * eps_g and still
at or above gtol. n_free is the number of coordinates that
measure runs over. Frozen or fixed coordinates are left out. On the
session path it is the length of the horizontal or projected
gradient, or the count of box coordinates with positive width.
One projection has variance tr(Σ) / n, so the noise in ||g||_2
is sqrt(n) times eps_g. Comparing ||g||_2 with eps_g alone
sits that factor too low. The rms of a noisy gradient whose true
norm is r is sqrt(r² + tr Σ). That equals the threshold
c sqrt(tr Σ) when r = sqrt(c² − 1) sqrt(tr Σ). At c = 3 the
50% firing radius is sqrt(c² − 1) sqrt(tr Σ) ≈ 2.83 sqrt(tr Σ).
With an exact eps_g, a radius of 2 sqrt(tr Σ) already fires on
93% of draws at n = 3 and on all of them at n = 21 and n = 114.
A measured eps_g sits lower. That spread, not the chi-square tail,
is what a tighter radius is covering. On a 100-fold spread of
component σ, one direction’s factor sqrt(n wᵀΣw / tr Σ) runs from
about 0.56 to 1.45. At k = 1 the measured floor at
1.7 sqrt(tr Σ) fired on 112 of 200 seeds. At k = 8 it cleared
90%. At a true gradient of 5 sqrt(tr Σ) the measured floor fired
on at most 5 of 200 seeds, for k = 1 and k = 8, isotropic and
anisotropic.
eps_f is the energy noise. sample_value_gradient_noise measures
it on the same stencil as eps_g, through one
value_and_gradient per sample. NoiseFloor::with_eps_f records
it. The line search then stops with Status::NoiseFloor, and keeps
the pre-step point, when no trial decreases the energy by at least
c * eps_f. A gradient floor does not see that stall: the gradient
can sit far above 3 sqrt(n) eps_g while every trial is inside the
energy roundoff.
The stop is Status::NoiseFloor. Spending the iteration budget
above both tests is Status::Budget. SCG’s own step and objective
tests, taken while ||g|| is still at or above gtol, are
Status::StepTolerance.
The bench noises the forces only on the Gaussian series, so a budget
run there descends on an exact energy. A second series adds an
independent energy draw of standard deviation σ · 1 Å at the same
point. It is not the gradient of a potential. f32 rounds both the
energy and the forces. calls includes the stencil. sample_calls
is that stencil alone, m + 1 = 9 gradients for each spacing tried.
The all-ones direction is a translation. LJ38’s energy and forces do
not change under it, so an f32 table is all zeros and ECnoise reports
StepTooSmall. The measurement then uses the first coordinate axis,
which changes a pair distance. On that axis at h = 1e-4, LJ38 f32
gives eps_g = 2.77e-6 and eps_f = 5.32e-7, both detected. Without
eps_f the line search first sticks at ||g|| = 1.57e-2, while
3 sqrt(114) eps_g = 8.9e-5, and the run spends the budget (25590
minimization calls, abs_de 1.0e-6). With eps_f the median f32
floor stops in 63 calls, 18 of them the stencil, at abs_de
4.6e-6. The baseline gtol = 0.01 stops in 57 calls at abs_de
2.3e-7. The call count is within twice baseline. The energy is not:
the test stops on the first search whose best decrease misses
c * eps_f, and the following search still clears it. The geometry
is not translation invariant. Its f32 stencil on the all-ones
direction detects eps_g = 3.17e-7 and eps_f = 7.26e-8. Without
eps_f the search first sticks at ||g|| = 7.98e-5 and spends the
budget. With eps_f the median stop is 24 calls, 9 of them the
stencil, at abs_de 1.3e-7. Baseline stops in 10 calls at abs_de
2.2e-6. The energy is closer. The calls are 2.4 times baseline.
LJ38 1e-2-floor seed 27 is the censored Gaussian row. The measured
eps_g was 2.47e-3 against a true component σ of 1e-2, so
3 sqrt(114) eps_g = 0.079 stayed under ||g|| (minimum 0.082,
final 0.101, fmax 0.023). The energy is exact. The minimization
used 22029 calls and the stencil another 18, 22047 in total,
abs_de 9.9e-6.
A fixed gtol set under the floor keeps iterating after the gradient
is noise. On LJ38 (n = 114) and on Lennard-Jones at the eOn
heptamer coordinates (lj-pt7-geom, 7 free atoms, n = 21) the
scaled floor stops in tens of calls once sampling is counted. The
tight tolerance 0.1 sqrt(n) σ is censored at 2000 iterations on
every force-only Gaussian seed. At σ ≤ 3e-4 the two endpoints
agree to about 1e-6 eV. At σ = 1e-2 the early stop sits higher,
near 1e-3 eV, while the budget-exhausted run is closer to the
noise-free minimum, because that run still sees an exact energy.
The baseline gtol = 0.01 stops early at low noise and burns the
same budget as the tight run once 0.01 is below the noisy gradient.
The geometry run is Lennard-Jones on those coordinates. It is not a
Pt heptamer. rgpot is not linked.