Student-t location-scale estimator¶
A single bad reading can move an average by as much as it wants. A Student-t estimator answers with a location that barely moves: the same idea as the median, but with a full probability model around it, so the answer also carries a scale and a likelihood.
What this is¶
A robot has an encoder on its elbow. The encoder is usually accurate, but once every few minutes it glitches and reports a value far outside the physical range of the joint. The running mean follows the glitch up and takes many clean readings to come back. A Student-t estimator, with the same data, keeps its location close to the true angle: the heavy tails of the t distribution give a very low probability to a sample that sits far from the bulk, so a single outlier moves the location by very little.
The estimator keeps three numbers: a location mu_, a log-scale
log_sigma_, and a count n_. The scale is stored in log form so it
stays positive without a constraint. The degrees of freedom nu is fixed
at construction, not estimated.
1. The first estimator¶
from dense_armor.utility.signals.stats import StudentT
st = StudentT(nu=5.0, learning_rate=0.1)
for v in [1.0, 2.0, 3.0, 4.0, 5.0]:
st.learn_one({"x": v})
print(round(st.mu_, 3), round(st.sigma_, 3))
The output is printed by the class docstring example and is not repeated here; the point of this section is that five samples go in one at a time, and the location and scale come out.
The dict {"x": v} is one sample. The smallest key is read by default;
StudentT(feature="q_elbow") names a specific one.
2. What nu does¶
nu is the degrees of freedom of the t distribution. Large nu (say 30)
makes the density almost Gaussian: a single outlier moves the location by
a lot, the same way it moves the mean. Small nu (say 3) makes the tails
very heavy: an outlier is treated as a plausible draw from a wide
distribution, and its effect on the location is small.
from dense_armor.utility.signals.stats import StudentT
for nu in (3.0, 30.0):
st = StudentT(nu=nu, learning_rate=0.1)
for v in [1.0, 2.0, 3.0, 4.0, 500.0]:
st.learn_one({"x": v})
print(nu, round(st.mu_, 3))
The exact output is in the table at the bottom of the page. The pattern:
with nu=30 the outlier dominates and the location moves toward it; with
nu=3 the location stays near the bulk.
nu is fixed because the maximum-likelihood estimation of nu via the
extended EM of Liu & Rubin (1995) is not implemented in this module: the
paper is not in the folder, and the reference paper in the folder (Ng et
al. 2026) does not report the update equations. The choice is declared in
the class docstring.
3. The density and the score¶
transform_one returns the density at the sample, in three forms:
logpdf, pdf, and cdf. score_one returns the negative log-likelihood:
high is anomalous, the same direction as every anomaly detector in the
library.
from dense_armor.utility.signals.stats import StudentT
st = StudentT(nu=5.0, learning_rate=0.1)
for v in [1.0, 2.0, 3.0, 4.0, 5.0] * 20:
st.learn_one({"x": v})
print(round(st.score_one({"x": 3.0}), 3))
print(round(st.score_one({"x": 30.0}), 3))
A sample in the bulk gets a small score; a sample far away gets a large score. The exact numbers are in the table at the bottom of the page.
4. A stream from a robot¶
A joint position in radians, a slow sinusoid with rare spikes and a step in the mean. The true mean is about 0.11 radians.
import numpy as np
from dense_armor.utility.signals.stats import StudentT, RunningMoments
rng = np.random.default_rng(20261026)
n = 2000
t = np.linspace(0.0, 4.0 * np.pi, n)
q = 0.1 * np.sin(t)
spikes = rng.random(n) < 0.02
q[spikes] += rng.normal(0.0, 0.5, int(spikes.sum()))
q[n // 2:] += 0.2
st = StudentT(nu=3.0, learning_rate=0.005)
naive = RunningMoments()
for v in q:
st.learn_one({"x": float(v)})
naive.learn_one({"x": float(v)})
print(round(st.mu_, 3), round(naive.mean, 3))
With learning_rate=0.005, StudentT gives a location of about 0.15
radians, the running mean gives about 0.10, and the true mean is about
0.11. The Student-t is closer than the mean at learning_rate=0.05
(which gives about 0.36, pulled by the spikes), but it does not beat the
plain mean on this domain. The benchmark records this as a declared
limit: the model requires calibration, and no single learning rate
wins on every domain.
5. A stream from a drone¶
An IMU acceleration in m/s², a random walk with Student-t noise and a variance change at the midpoint.
import numpy as np
from dense_armor.utility.signals.stats import StudentT
rng = np.random.default_rng(20261026)
n = 2000
noise = rng.standard_t(df=4.0, size=n) * 0.5
walk = np.cumsum(rng.normal(0.0, 0.05, n))
walk[n // 2:] *= 3.0
acc = walk + noise
st = StudentT(nu=4.0, learning_rate=0.005)
for v in acc:
st.learn_one({"x": float(v)})
print(round(st.mu_, 3), round(st.sigma_, 3))
The estimate follows the walk and does not settle on a value: the
underlying process has no fixed location to find. This is the correct
behaviour and the reason the model is not the right tool on a random
walk. The benchmark records the same fact: mu=-0.467 against a naive
mean of -0.291.
6. A stream of returns¶
A financial return stream, Student-t noise with df=3 and two volatility
regimes (0.01 before the midpoint, 0.03 after). The true location is
zero.
import numpy as np
from dense_armor.utility.signals.stats import StudentT
rng = np.random.default_rng(20261026)
n = 2000
vol = np.ones(n) * 0.01
vol[n // 2:] = 0.03
r = rng.standard_t(df=3.0, size=n) * vol
st = StudentT(nu=3.0, learning_rate=0.005)
for v in r:
st.learn_one({"x": float(v)})
print(round(st.mu_, 4), round(st.sigma_, 4))
The location stays near zero, the scale reflects the aggregated
volatility across the two regimes. The Student-t keeps wider tails than
a Gaussian on the same data, which is the correct answer: a single
extreme return is plausible under the model, not an error. The
benchmark records sigma=0.054 against a naive standard deviation of
0.039.
Details¶
References¶
- Ng, A., Zhu, S., Zhang, A. G., Reid, N. (2026). Robust Regression with Student's T: The Role of Degrees of Freedom. arXiv:2603.00269. Density at Eq. (2), page 1; gradients at page 12.
- Zellner, A. (1976). Bayesian and non-Bayesian analysis of the Student-t model. Journal of the American Statistical Association 71(354), 400-405. DOI 10.1080/01621459.1976.10480357. Not in the folder, bibliographic only.
- Liu, C., Rubin, D. B. (1995). ML estimation of the t distribution
with unknown degrees of freedom. Statistics and Computing 5, 19-28.
Not in the folder, bibliographic only. The reference for the (not
implemented) update of
nu.
Choices stated explicitly¶
nuis a fixed hyperparameter. The maximum-likelihood estimation ofnuvia the extended EM of Liu & Rubin (1995) is not implemented: the paper is not in the folder and Ng et al. 2026 does not report the equations.- The location and the scale are updated by a gradient step with a
fixed
learning_rate. Such a step converges to a neighbourhood of the maximum-likelihood estimate, not to the MLE exactly. The width of the neighbourhood is proportional tolearning_rate. - The scale is parametrised as
log_sigmato keep it positive without a constraint. mergeis a weighted average of the two states byn_. It is exact for the count and approximate for the location and scale: the online update is not additive, so the merge of two partial states is not identical to the sequential pass over the union.- The variance returned by
transform_oneissigma^2 * nu / (nu - 2)fornu > 2andinffornu <= 2. That is the exact variance of the Student-t distribution, not an approximation.
Measured cost¶
On the host of this session, median per-sample latency of learn_one
is 2.4 µs, 90th percentile 3.1 µs, maximum 8.2 µs (measured by
benchmarks/statistics_online.py --smoke --robust). The state is 5
floats (mu_, log_sigma_, n_, n_missing_, and the flag that the
first sample has been seen). Memory class: O(1).
Numeric results on this page¶
Every number quoted above was produced by running the code on this
page, in the session of benchmarks/statistics_online.py --smoke
--robust. The JSON file is benchmarks/statistics_online.json.
| Quantity | Value |
|---|---|
Robot, StudentT mu_ at lr=0.005, nu=3 |
0.150 |
| Robot, naive mean | 0.100 |
Robot, StudentT mu_ at lr=0.05, nu=3 |
0.358 |
Drone, StudentT mu_ at lr=0.005, nu=4 |
−0.467 |
| Drone, naive mean | −0.291 |
Finance, StudentT sigma_ at lr=0.005, nu=3 |
0.054 |
| Finance, naive std | 0.039 |
Median per-sample latency of learn_one |
2.4 µs |
The unit tests on test/stats/test_student_t.py (25 tests) check the
density against scipy.stats.t.logpdf within 1e-10, the variance
against the closed form, the ValueError on invalid constructor
arguments, merge, state_dict roundtrip, fit vs learn_one
parity, and check_estimator with the skipped list empty.