thermite_special/specialized/generic/ndtr.rs
1//! The standard normal CDF `ndtr`, its logarithm `log_ndtr`, and `logerfc = ln erfc`.
2//!
3//! # Motivation
4//!
5//! `ndtr(x) = erfc(-x/sqrt 2)/2` is one line, and is here so the forward CDF exists
6//! beside its inverse `probit`. The two log forms are not re-expressions: `ndtr`
7//! underflows to zero near `x = -38.6` (binary64) and `-14.4` (binary32), and `erfc`
8//! near `27` / `9.3`, so `ln(ndtr(x))` and `ln(erfc(x))` return `-inf` exactly where a
9//! log-likelihood, a censored-data model or a Bayesian-optimization acquisition
10//! function needs them most. Both logs are perfectly ordinary numbers there
11//! (`log_ndtr(-100) = -5004.6`). These kernels carry them.
12//!
13//! # Algorithm
14//!
15//! Three arms per function, chosen by where each spelling is accurate. For `log_ndtr`,
16//! with `u = |x|/sqrt 2`:
17//!
18//! ```text
19//! x > 0: ln_1p(-erfc(u)/2) ndtr -> 1, the complement is the small side
20//! -5.7 < x <= 0: ln(erfc(u)/2) bit-identical to ln(ndtr(x))
21//! x <= -5.7: ln(erfcx(u)/2) - u^2 the tail; erfcx has no underflow
22//! ```
23//!
24//! The moderate region runs on `erfc` deliberately. `erfcx` is a Weideman rational whose
25//! term count follows the policy, and at the default tier it is 4.2e-10 relative. The
26//! log turns relative error into absolute, so `ln(erfcx(u)/2)` at `x = -1` would be
27//! 1e6 ulp off the `ln(ndtr(x))` a caller could write by hand. `erfc` is a rational-times-
28//! exp fit that holds a few ulp at every tier, and its one weakness (the `x^2` under the
29//! exp amplifies the argument's rounding by `x^2`) is exactly what the log absorbs: an
30//! error of `c x^2 epsilon` relative to `erfc` is `c x^2 epsilon` absolute in the log,
31//! against a result of `-x^2/2`.
32//!
33//! In the tail the same absorption is what makes `erfcx` affordable: its error goes into
34//! the log as an absolute `delta`, against a result dominated by `-u^2`, so the tail arm
35//! runs `erfcx` two rungs above the caller's tier ([`LogTailPolicy`], N = 40 from the
36//! default tier) and starts at `u = 4`, where `8.7e-16 / 16` is a quarter ulp. The
37//! threshold is where `erfc` still has a hundred orders of magnitude of headroom in
38//! either format. The arms share one `ln`: the argument is lane-selected between
39//! `erfc/2` and `erfcx/2`, and only the tail subtracts `u^2`.
40//!
41//! `u^2` is formed as `(|x|/2)|x|` rather than `x*x/2`, so the intermediate does not
42//! overflow before the result does: `log_ndtr(-1.3e154)` is a representable `-8.45e307`.
43//!
44//! `logerfc` is the same shape with the tail on the right (`ln(erfcx(x)) - x^2` above
45//! `x = 4`, `ln(erfc(x))` between `1/2` and `4`) and a bounded left side. There
46//! `erfc(-|x|) = 1 + erf(|x|)`, so the arm is `ln_1p(erf(|x|))`, which is `2|x|/sqrt(pi)`
47//! near zero and must not be formed from `erfc`. The same cancellation sits in
48//! `erfc(x) = 1 - erf(x)` for small positive `x` (it rounds `1.128e-8` to `1.1e-8` at
49//! `x = 1e-8`), so below `x = 1/2` the right side is `ln_1p(-erf(x))` as well. A python
50//! model of the seam puts both spellings at 1-2 ulp on either side of it.
51//!
52//! Each transcendental is evaluated only when some lane's arm needs it, or
53//! unconditionally under `avoid_branching`. A packet of one sign in the moderate region,
54//! the common case, pays one `erfc` and one log.
55
56use thermite::{
57 element::FloatElement,
58 math::{
59 TranscendentalMathWithPolicy as _,
60 algorithms::newtons_method,
61 policy::{
62 Policy, PrecisionPolicy,
63 policies::{ExtraPrecision, LessPrecision, MaxIterations},
64 },
65 },
66 prelude::*,
67};
68
69use crate::specialized::{SpecializedRealSpecialMath, SpecializedSpecialMath};
70
71/// The policy the tail arms evaluate `erfcx` under: two precision rungs above the
72/// caller's, so the default tier takes the full N = 40 Weideman table.
73///
74/// The log absorbs `erfcx`'s relative error as an absolute one against a result of
75/// `-x^2/2`, which is why the bump is affordable and why the tail can start as early as
76/// `u = 4`. Public so that `Dual`'s derivative factor, an `erfcx` quotient, can match.
77pub type LogTailPolicy<P> = ExtraPrecision<ExtraPrecision<P>>;
78
79/// Where the log forms hand over from `erfc` to `erfcx`: `u = |x|/sqrt 2` for
80/// `log_ndtr`, `|x|` for `logerfc`.
81#[inline(always)]
82fn tail_start<E: FloatElement, V: FloatVector<Element = E>>() -> V {
83 V::splat(<E as FloatElement>::ConstRatio::<4, 1>::VALUE)
84}
85
86/// `ndtr(x) = erfc(-x/sqrt 2)/2`, the standard normal CDF.
87///
88/// The `erfc` kernel handles the reflection to the right side itself, so this is the
89/// whole function. The `1/sqrt 2` scaling costs one rounding in the argument, which the
90/// tail amplifies by `x^2`. That is the function's own condition number, not the
91/// kernel's.
92#[inline(always)]
93pub fn ndtr_impl<P, E, V>(x: V) -> V
94where
95 P: Policy,
96 E: FloatElement,
97 V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
98{
99 <V as SpecializedSpecialMath<E>>::erfc::<P>(x * -V::FRAC_1_SQRT_2) * V::HALF
100}
101
102/// `ln(ndtr(x))`, finite for every finite `x`.
103#[inline(always)]
104pub fn log_ndtr_impl<P, E, V>(x: V) -> V
105where
106 P: Policy,
107 E: FloatElement,
108 V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
109{
110 log_ndtr_with_deriv_impl::<P, E, V, false>(x).0
111}
112
113/// `(ln ndtr(x), phi(x)/ndtr(x))`: the value and its derivative, the inverse Mills ratio.
114///
115/// The ratio costs one `exp` in the moderate and right arms (`phi` needs `e^{-x^2/2}`,
116/// which `erfc` keeps inside itself) and nothing in the tail, where it is
117/// `1/(sqrt(2 pi) a)` from the `erfcx` already in hand. With `DERIV = false` the second
118/// element is zero and no extra work is done.
119#[inline(always)]
120pub fn log_ndtr_with_deriv_impl<P, E, V, const DERIV: bool>(x: V) -> (V, V)
121where
122 P: Policy,
123 E: FloatElement,
124 V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
125{
126 let ax = x.abs();
127 let u = ax * V::FRAC_1_SQRT_2;
128 // (|x|/2)|x|, not x*x/2: the product overflows only where the result does.
129 let u2 = (ax * V::HALF) * ax;
130
131 let neg = x.cmp_lt(V::ZERO);
132 let tail = neg & u.cmp_gt(tail_start());
133
134 // `arg` is the argument of the one `ln` shared by the two left arms. `sub` is the
135 // `u^2` only the tail subtracts.
136 let mut arg = V::ZERO;
137 let mut sub = V::ZERO;
138 let mut right = V::ZERO;
139 let mut mills = V::ZERO;
140
141 if const { P::POLICY.avoid_branching } || !tail.all() {
142 // ndtr(-|x|), the same bits `ndtr` itself returns.
143 let c = <V as SpecializedSpecialMath<E>>::erfc::<P>(u) * V::HALF;
144 arg = c;
145
146 if const { P::POLICY.avoid_branching } || !neg.all() {
147 right = (-c).ln_1p_p::<P>();
148 }
149
150 if const { DERIV } {
151 // phi/Phi with Phi = c on the left and 1 - c on the right.
152 let phi = (-u2).exp_p::<P>() * V::FRAC_1_SQRT_TAU;
153 mills = phi / neg.select(c, V::ONE - c);
154 }
155 }
156
157 if const { P::POLICY.avoid_branching } || tail.any() {
158 let a = <V as SpecializedSpecialMath<E>>::erfcx::<LogTailPolicy<P>>(u) * V::HALF;
159
160 arg = tail.select(a, arg);
161 sub = tail.select(u2, V::ZERO);
162
163 if const { DERIV } {
164 // Phi = e^{-u^2} a and phi = e^{-u^2}/sqrt(2 pi): the exponential cancels.
165 mills = tail.select(V::FRAC_1_SQRT_TAU / a, mills);
166 }
167 }
168
169 let mut left = V::ZERO;
170 if const { P::POLICY.avoid_branching } || neg.any() {
171 left = arg.ln_p::<P>() - sub;
172 }
173
174 (neg.select(left, right), mills)
175}
176
177/// The inverse of [`log_ndtr_impl`]: the `x` with `ln ndtr(x) = y`, for `y <= 0`.
178///
179/// Newton on `log_ndtr` with the inverse Mills ratio as the derivative, from a `probit(e^y)`
180/// seed one tier down wherever `e^y` is a normal number, and from the tail asymptotic
181/// `x^2 = -2y - 2 ln(-x) - ln 2 pi` (one substitution) below `y = -700`. `ln ndtr` is concave
182/// and increasing, so every Newton step lands left of the root and the iteration is
183/// monotone from there. No bracket is needed. The residual tolerance is a few ulp of `y`,
184/// which is the forward's own noise floor and, through the ratio `|y| / (x phi/Phi)`,
185/// under two ulp of `x` everywhere.
186#[inline(always)]
187pub fn inv_log_ndtr_impl<P, E, V>(y: V) -> V
188where
189 P: Policy,
190 E: FloatElement,
191 V: FloatVector<Element = E> + SpecializedRealSpecialMath<E>,
192{
193 // The domain is (-inf, 0]. y = 0 and the two infinities are selected in at the end.
194 let active = y.cmp_lt(V::ZERO) & y.is_finite();
195
196 // Three seeds. On the right (x > 0, y > -ln 2) it is the complement that carries the
197 // information: e^y rounds to 1 for |y| under an ulp and probit(1) is nothing, while
198 // -expm1(y) is 1 - e^y to full precision, so x0 = -probit(1 - e^y). In the middle it
199 // is probit(e^y), which wants e^y as a normal number. The seam is y < -708 in f64 and
200 // y < -87 in f32, and is read off e^y rather than spelled per format.
201 let p = y.exp_p::<LessPrecision<P>>();
202 let far = p.cmp_lt(V::MIN_POSITIVE);
203 let right = y.cmp_gt(-V::LN_2);
204
205 let mut x0 = V::ZERO;
206 if const { P::POLICY.avoid_branching } || !(far | right).all() {
207 x0 = <V as SpecializedRealSpecialMath<E>>::probit::<LessPrecision<P>>(p);
208 }
209 if const { P::POLICY.avoid_branching } || right.any() {
210 // `exp_m1` at the caller's tier, not one down: below `Average` it is `exp(y) - 1`,
211 // which is exactly zero for |y| under an ulp, and probit(0) seeds nothing.
212 let q = -y.exp_m1_p::<P>();
213 x0 = right.select(-<V as SpecializedRealSpecialMath<E>>::probit::<LessPrecision<P>>(q), x0);
214 }
215 if const { P::POLICY.avoid_branching } || far.any() {
216 // x^2 = -2y - 2 ln(-x) - ln 2pi, with -x = sqrt(-2y) inside the log.
217 let m2y = -(y + y);
218 let ln_m2y = m2y.ln_p::<LessPrecision<P>>();
219 let xa = -(m2y - ln_m2y - (V::LN_2 + V::LN_PI)).sqrt();
220 x0 = far.select(xa, x0);
221 }
222
223 let tol = residual_tolerance::<P, E, V>(y.abs());
224 let (x, _) = newtons_method::<V, MaxIterations<P, 8>, _>(x0, tol, active, None, |x| {
225 let (v, m) = log_ndtr_with_deriv_impl::<P, E, V, true>(x);
226 (v - y, m)
227 });
228
229 let x = y.cmp_eq(V::NEG_INFINITY).select(V::NEG_INFINITY, x);
230 let x = y.cmp_eq(V::ZERO).select(V::INFINITY, x);
231 (active | y.cmp_eq(V::NEG_INFINITY) | y.cmp_eq(V::ZERO)).select(x, V::NAN)
232}
233
234/// The residual tolerance the Newton inverses stop at: a tier-dependent number of ulps of
235/// `scale`, which the caller sets to the size of the quantity the residual is measured in.
236///
237/// The floor is the forward kernel's own rounding (a few ulp), below which the residual
238/// is noise and the loop would run to its cap for nothing.
239#[inline(always)]
240pub fn residual_tolerance<P, E, V>(scale: V) -> V
241where
242 P: Policy,
243 E: FloatElement,
244 V: FloatVector<Element = E>,
245{
246 let ulps: V = if const { P::POLICY.precision.le(PrecisionPolicy::Worst) } {
247 V::splat(<E as FloatElement>::ConstInt::<65536>::VALUE)
248 } else if const { P::POLICY.precision.le(PrecisionPolicy::Medium) } {
249 V::splat(<E as FloatElement>::ConstInt::<256>::VALUE)
250 } else if const { P::POLICY.precision.le(PrecisionPolicy::Average) } {
251 V::splat(<E as FloatElement>::ConstInt::<8>::VALUE)
252 } else {
253 V::splat(<E as FloatElement>::ConstInt::<4>::VALUE)
254 };
255
256 scale * (<V as FloatVector>::EPSILON * ulps)
257}
258
259/// `ln(erfc(x))`, finite for every finite `x`.
260#[inline(always)]
261pub fn logerfc_impl<P, E, V>(x: V) -> V
262where
263 P: Policy,
264 E: FloatElement,
265 V: FloatVector<Element = E> + SpecializedSpecialMath<E>,
266{
267 let ax = x.abs();
268
269 let neg = x.cmp_lt(V::ZERO);
270 // ln_1p(+-erf(|x|)): the whole left side, and the right side below 1/2.
271 let bounded = neg | ax.cmp_lt(V::HALF);
272 // ln(erfcx(x)) - x^2, the right tail. Everything else on the right is ln(erfc(x)).
273 let tail = ax.cmp_gt(tail_start()) & !bounded;
274
275 let mut bounded_arm = V::ZERO;
276 if const { P::POLICY.avoid_branching } || bounded.any() {
277 let e = <V as SpecializedSpecialMath<E>>::erf::<P>(ax);
278 bounded_arm = e.neg_c(!neg).ln_1p_p::<P>();
279 }
280
281 let mut arg = V::ZERO;
282 let mut sub = V::ZERO;
283 let mut log_arm = V::ZERO;
284 if const { P::POLICY.avoid_branching } || !bounded.all() {
285 if const { P::POLICY.avoid_branching } || !(bounded | tail).all() {
286 arg = <V as SpecializedSpecialMath<E>>::erfc::<P>(ax);
287 }
288
289 if const { P::POLICY.avoid_branching } || tail.any() {
290 // The result there is -x^2 - ln(sqrt(pi) x) + ..., so x^2 overflowing means
291 // the result does too. No rescue is needed or possible.
292 let c = <V as SpecializedSpecialMath<E>>::erfcx::<LogTailPolicy<P>>(ax);
293 arg = tail.select(c, arg);
294 sub = tail.select(ax * ax, V::ZERO);
295 }
296
297 log_arm = arg.ln_p::<P>() - sub;
298 }
299
300 bounded.select(bounded_arm, log_arm)
301}