Descriptive statistics, quantiles and least-squares fits
OCaml 88%
Python 5%
4%
Shell 2%
Dune 1%

README.md

stats #

Descriptive statistics, order statistics, least-squares fits, and tests and intervals by resampling, over a sample in hand.

Benchmarks, dashboards and experiments keep asking the same questions of a list of numbers: its mean and spread, its median and 99th percentile, the line through a set of points and how well it fits. When the data fits in memory, an exact answer is simpler and faster than an estimate. This library gives those answers, with the quantile and regression conventions of NumPy and SciPy.

ckms estimates quantiles over an unbounded stream in bounded memory, and hdr buckets values at a fixed relative precision; use those when the data does not fit in memory or does not end. Stats.Running covers the case between: a mean and a variance over a stream, in constant memory and one pass. It uses Welford's update (1962): on each new x it keeps the count n, the mean m and M2, the sum of squared deviations from the mean, with m' = m + (x − m)/n and M2' = M2 + (x − m)(x − m'), and the variance is M2/(n − 1). No large sums are ever subtracted, so it avoids the cancellation described under Conventions.

Absent data and invalid data #

The library reports the two differently. A question about an empty sample, such as the mean of no numbers, has no answer and returns None. An input that cannot produce a statistic, such as a NaN among the observations or a quantile outside [0, 1], raises Invalid_argument. Returning a number in either case would let a wrong answer travel downstream unnoticed.

Sample.t is abstract because it carries an invariant: it is sorted, and every observation in it is finite. A quantile read from an unsorted array looks like a quantile but is not one.

Checked against NumPy and SciPy #

The interface promises Hyndman and Fan type 7 quantiles, NumPy's inverted_cdf for weighted quantiles, and the four numbers scipy.stats.linregress reports. test/interop/numpy and test/interop/scipy replay committed traces from NumPy 2.0.2 and SciPy 1.13.1 and compare. REGEN=1 dune build @traces regenerates them; the tests themselves need no Python.

The traces also record two deliberate differences. For a two-point fit SciPy reports a standard error of 0, and this library reports infinity. The slope's standard error divides the residual sum of squares by n − 2 degrees of freedom; with two points the line passes through both, so the estimate is 0/0 and says nothing about the spread, which infinity reports and 0 hides. For the variance of one observation NumPy returns nan, and this library returns None.

Install #

$ opam install stats

If opam cannot find the package, add the overlay repository first:

$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam update
$ opam install stats

Conventions #

Every regression is one weighted least-squares fit underneath. Libraries disagree on two choices, so both are stated here.

The fit is computed from centred sums. The raw-sum form computes the slope as (n Σxy − Σx Σy) / (n Σx² − (Σx)²), which subtracts two large numbers to get a small one; with x near 2000 (a year), x² is near 4,000,000 and the difference cancels most of the 53-bit mantissa. The centred form first subtracts the means and computes Σ(x − x̄)(y − ȳ) / Σ(x − x̄)², whose terms stay small.

Quantiles are linearly interpolated between the two neighbouring order statistics, Hyndman and Fan type 7, the default of NumPy, R and pandas. For a sorted sample x[0] ≤ ... ≤ x[n − 1] and a probability p, let h = (n − 1)p and k = ⌊h⌋; the quantile is x[k] + (h − k)(x[k + 1] − x[k]). In the example below the sorted sample is 1 1 2 3 4 5 6 9, so p = 0.99 gives h = 6.93 and 6 + 0.93 × 3 = 8.79. Taking the nearer of the two instead discards the fraction and moves the answer by a whole rank, which on a short series separates a median from its neighbour.

The slope's standard error is the textbook ordinary least-squares one, which assumes independent observations. Spatial and time-series data usually are not independent, so on that data it understates the true spread; the interface documentation says so.

Usage #

# let s = Stats.Sample.of_list [ 3.; 1.; 4.; 1.; 5.; 9.; 2.; 6. ]
val s : Stats.Sample.t = <abstr>
# Stats.Sample.median s
- : float option = Some 3.5
# Stats.Sample.quantiles s [ 0.25; 0.75; 0.99 ]
- : float list option = Some [1.75; 5.25; 8.79]
# Stats.Sample.mean s, Stats.Sample.stddev s
- : float option * float option = (Some 3.875, Some 2.74837614393871288)

A line, and what to do with it:

# let f = Option.get (Stats.fit [ (1., 2.); (2., 4.); (3., 5.); (4., 9.) ])
val f : Stats.fit =
  {Stats.n = 4; slope = 2.2; intercept = -0.5; r = 0.964763821237732189;
   slope_stderr = 0.424264068711928177}
# f.Stats.slope, f.Stats.intercept
- : float * float = (2.2, -0.5)
# Stats.r_squared f
- : float = 0.930769230769230904
# Stats.predict f 5.
- : float = 10.5

Pearson correlation measures how close the points are to a straight line, and Spearman correlation only whether y rises with x:

# let curve = [ (1., 1.); (2., 4.); (3., 9.); (4., 16.); (5., 25.) ]
val curve : (float * float) list =
  [(1., 1.); (2., 4.); (3., 9.); (4., 16.); (5., 25.)]
# Stats.pearson curve
- : float = 0.981104910251592854
# Stats.spearman curve
- : float = 1.

And over a stream, in constant memory:

# let r = Stats.Running.v ()
val r : Stats.Running.t = <abstr>
# List.iter (Stats.Running.add r) [ 1.; 2.; 3.; 4. ]
- : unit = ()
# Stats.Running.mean r, Stats.Running.stddev r
- : float option * float option = (Some 2.5, Some 1.2909944487358056)

A test against a null distribution drawn by resampling, with the false discovery rate held over many such tests:

# let null = [| 1.; 2.; 5.; 3.; 0.5; 6.; 1.; 2.; 3. |]
val null : float array = [|1.; 2.; 5.; 3.; 0.5; 6.; 1.; 2.; 3.|]
# Stats.empirical_p Upper 5. null
- : float = 0.3
# Stats.q_values [ 0.01; 0.04; 0.03; 0.2 ]
- : float list = [0.04; 0.0533333333333333368; 0.0533333333333333368; 0.2]
# Stats.wilson 5 10
- : float * float = (0.236589593615487315, 0.76341040638451263)

API #

Stats (lib/stats.mli) is the whole interface: Sample for order statistics and descriptives, fit/weighted/predict/r_squared for least squares, pearson/spearman for correlation, Running for one pass over a stream, and empirical_p/q_values/permutation_null/bootstrap/ percentile_interval/wilson for tests and intervals by resampling.

License #

ISC. See LICENSE.md.