Skip to content

special

Gamma, beta, error and normal functions, incomplete gamma and beta and their inverses, Bessel, zeta and elliptic integrals, measured in ULPs against mpmath.

Special mathematical functions in pure Go (no cgo, no assembly): the gamma family, error functions, incomplete gamma and beta functions and their inverses, Bessel functions, zeta functions, combinatorics, logistic helpers and complete elliptic integrals — with an element-wise layer over go-ndarray.

It is the numerical foundation of the go-numerics statistics library: every distribution’s cdf and quantile function is computed with these functions, so their accuracy is the product. Each function is measured in ULPs against mpmath at high precision, over its whole domain — tails, zeros, poles, huge and tiny arguments, subnormals, NaN and ±Inf — and SciPy is measured on exactly the same points.

go get github.com/go-numerics/special@v0.1.0
package main

import (
	"fmt"

	"github.com/go-numerics/special"
)

func main() {
	fmt.Println(special.Gamma(4.5))              // 11.631728396567448
	fmt.Println(special.Erfc(10))                // 2.088487583762545e-45
	fmt.Println(special.Ndtr(-30))               // 4.906713927148187e-198
	fmt.Println(special.Ndtri(1e-300))           // -37.0470962993612
	fmt.Println(special.GammaP(2.5, 1.5))        // regularised lower incomplete gamma
	fmt.Println(special.BetaIncInv(2, 3, 0.975)) // a beta quantile
	fmt.Println(special.Jv(2.5, 10))             // Bessel function of real order
}

Arrays (github.com/go-numerics/special/nd): any real dtype in, Float32 kept as Float32, everything else Float64; NumPy broadcasting; large arrays are split across cores.

x, _ := ndarray.Linspace(-5, 5, 1_000_001)
p, _ := nd.Ndtr(x)

A few of them, drawn with go-plot:

Bessel functions J0, J1, J2 and J2.5 on [0, 20]
package main

import (
	"fmt"

	"github.com/go-numerics/special"
	"github.com/go-plot/plot"
)

func init() { register("bessel", bessel) }

// Bessel functions of the first kind, integer and real order, with Jv.
func bessel() *plot.Figure {
	x := linspace(0, 20, 400)
	fig, axs := plot.Subplots(1, 1, plot.FigSize(6.4, 4))
	ax := axs[0][0]
	for _, v := range []float64{0, 1, 2, 2.5} {
		y := make([]float64, len(x))
		for i, s := range x {
			y[i] = special.Jv(v, s)
		}
		ax.Plot(x, y, plot.Label(fmt.Sprintf(`$J_{%g}(x)$`, v)))
	}
	ax.Axhline(0, plot.Color("gray"), plot.LineWidth(0.8))
	ax.SetTitle("Jv: Bessel functions of the first kind")
	ax.SetXLabel(`$x$`)
	ax.Legend()
	return fig
}
Gamma on [-4, 5] between its poles, and LogGamma
package main

import (
	"github.com/go-numerics/special"
	"github.com/go-plot/plot"
)

func init() { register("gamma", gamma) }

// Gamma on the real line: one curve per interval between its poles at
// 0, -1, -2, -3, so that no line is drawn across a pole.
func gamma() *plot.Figure {
	fig, axs := plot.Subplots(1, 1, plot.FigSize(6.4, 4))
	ax := axs[0][0]
	pieces := [][2]float64{{-4, -3}, {-3, -2}, {-2, -1}, {-1, 0}, {0, 5}}
	for k, p := range pieces {
		x := linspace(p[0]+0.005, p[1]-0.005, 300)
		if k == len(pieces)-1 {
			x = linspace(p[0]+0.005, p[1], 300)
		}
		y := make([]float64, len(x))
		for i, s := range x {
			y[i] = special.Gamma(s)
		}
		if k == len(pieces)-1 {
			ax.Plot(x, y, plot.Color("C0"), plot.Label(`$\Gamma(x)$`))
		} else {
			ax.Plot(x, y, plot.Color("C0"))
		}
	}
	x := linspace(0.05, 5, 200)
	lg := make([]float64, len(x))
	for i, s := range x {
		lg[i] = special.LogGamma(s)
	}
	ax.Plot(x, lg, plot.Color("C1"), plot.LineStyle("--"), plot.Label(`$\log\Gamma(x)$`))
	ax.Axhline(0, plot.Color("gray"), plot.LineWidth(0.8))
	ax.SetYLim(-8, 8)
	ax.SetXLim(-4, 5)
	ax.SetTitle("Gamma and LogGamma")
	ax.SetXLabel(`$x$`)
	ax.Legend(plot.Loc("lower right"))
	return fig
}
Ndtr and Erfc on a log scale, down to the subnormal range
package main

import (
	"github.com/go-numerics/special"
	"github.com/go-plot/plot"
)

func init() { register("ndtr", ndtr) }

// The lower tail of the normal distribution and the complementary error
// function, on a log scale, down to the subnormal range of float64.
func ndtr() *plot.Figure {
	x := linspace(-38, 0, 400)
	p := make([]float64, len(x))
	for i, s := range x {
		p[i] = special.Ndtr(s)
	}
	z := linspace(0, 26.5, 400)
	q := make([]float64, len(z))
	for i, s := range z {
		q[i] = special.Erfc(s)
	}
	fig, axs := plot.Subplots(1, 2, plot.FigSize(8, 3.6))
	axs[0][0].Plot(x, p)
	axs[0][0].SetYScale("log")
	axs[0][0].SetYLim(1e-320, 10)
	axs[0][0].SetTitle(`Ndtr($x$)`)
	axs[0][0].SetXLabel(`$x$`)
	axs[0][1].Plot(z, q, plot.Color("C1"))
	axs[0][1].SetYScale("log")
	axs[0][1].SetYLim(1e-320, 10)
	axs[0][1].SetTitle(`Erfc($x$)`)
	axs[0][1].SetXLabel(`$x$`)
	fig.TightLayout()
	return fig
}

Accuracy

Measured on about 250 000 points in 419 regions over 62 functions, this package’s maximum error is at or below SciPy’s in 418 regions (the exception, K1 at small x, is a tie at 1.05 ulp). Typical maxima:

this packageSciPy 1.18.1
Gamma, LogGamma, Beta (all regions)0.5–1 ulp3–3e15 ulp
Digamma near its negative zeros31 ulp2.5e17 ulp
Erfc to the subnormal range0.5 ulp2e13 ulp
Ndtr lower tail, x in [-38.5, -10)0.5 ulp1.8e12 ulp
Ndtri, ErfInv, ErfcInv0.5–0.6 ulp1.4–4.4 ulp (wrong at subnormals)
GammaP / GammaQ, a in [1e4, 1e15]0.6 ulp8.9e15 ulp
BetaInc, a and b in [100, 1e4)9 ulp3487 ulp
J0 / Y0 next to their zeros0.5 ulp3e10 / 2e12 ulp
Jv at x in [1e4, 1e300]1.7 ulp5e18 ulp

The one area not solved: inverses of the incomplete beta function with a or b far below 1e-3 (both packages can be wrong there).

Full method, sources of every algorithm, negative control and limitations: Accuracy; every region: the accuracy table.

Speed

Accuracy has a price: single-threaded, a Go loop is about 3x slower than SciPy’s C for the gamma and error-function families and 10–15x for the Bessel functions of real order and the incomplete beta function. The nd functions spread large arrays over the cores and beat SciPy for 21 of 31 functions measured (typically 1.5–3x on a 16-core host; e.g. Gamma 6.4 vs 13.4 ns/element, Ndtr 4.7 vs 9.0, GammaP 37 vs 137).

Method and full table: Performance.

Functions

familyfunctions
gammaGamma, LogGamma, LogGammaSign, GammaSign, Digamma, Polygamma, Beta, LogBeta
incomplete gamma / betaGammaP, GammaQ, GammaPInv, GammaQInv, BetaInc, BetaIncC, BetaIncInv, BetaIncCInv
error function, normalErf, Erfc, Erfcx, ErfInv, ErfcInv, Ndtr, Ndtri, LogNdtr
combinatoricsComb, Perm, Binom, Factorial, Factorial2, LogFactorial
logistic, log helpersExpit, LogExpit, Logit, Log1pExp, Xlogy, Xlog1py, LogSumExp, Softmax, LogSoftmax
zetaZeta, HurwitzZeta
BesselJ0, J1, Jn, Jv, Y0, Y1, Yn, Yv, I0, I1, In, Iv, K0, K1, Kn, Kv, I0e, I1e, Ive, K0e, K1e, Kve, JnZeros
ellipticEllipK, EllipKm1, EllipE

Conventions: functions return NaN outside their domain and ±Inf at poles, as the IEEE 754 operations do; nothing panics on a float argument. Edge values follow SciPy’s where SciPy’s are mathematically sound; the exceptions are documented at each function.

Portability

Pure Go; CI runs the full accuracy suite on amd64, arm64, 386, arm (32-bit), riscv64, loong64, ppc64le and s390x, compiles the tests for every go tool dist list target, and holds a 100% statement-coverage gate.

One finding along the way: on amd64, Go’s math.Log (assembly) returns about -709.09 for every subnormal argument (it should go down to -744.44); this package never calls it on a value that can be subnormal.

License

BSD 3-Clause. Ported algorithms keep their attribution in place: Cephes (Stephen L. Moshier, distributed under BSD by SciPy) and Boost.Math (Boost Software License 1.0).

Created on by tannevaled

Modified on by tannevaled