Skip to content

Commit 87edc62

Browse files
authored
feat: add GeneralizedEsd, Rosner's test for up to r outliers (#7612)
Grubbs' test asks whether the single most extreme point is an outlier and breaks as soon as there are two of them, because each one inflates the standard deviation the other is measured against. The generalized extreme Studentized deviate test removes the most extreme point, recomputes the statistics on what is left and repeats r times, and the answer is the largest step whose statistic exceeds its critical value rather than the first, which is what defeats that masking. The critical values need a Student t quantile, which the repository did not have, so the class computes it from the regularized incomplete beta function through a Lentz continued fraction and a Lanczos log gamma, inverted by bisection. No table is needed. The tests check the critical values and the number of outliers against Rosner's published 54 point example. Signed-off-by: alxkm <19151554+alxkm@users.noreply.github.com> Co-authored-by: alxkm <19151554+alxkm@users.noreply.github.com>
1 parent 6bdb1ac commit 87edc62

2 files changed

Lines changed: 546 additions & 0 deletions

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
package com.thealgorithms.streaming;
2+
3+
import java.util.Arrays;
4+
5+
/**
6+
* The <b>generalized extreme Studentized deviate test</b> of Rosner: finds up to {@code r} outliers
7+
* in a sample, without being told how many are there.
8+
*
9+
* <p>Grubbs' test asks whether the single most extreme point is an outlier, and it breaks as soon as
10+
* there are two of them: each one inflates the standard deviation the other is measured against, so
11+
* a pair of outliers can hide one another completely. This is the masking problem, and the
12+
* generalized test is the standard answer to it. It removes the most extreme point, recomputes the
13+
* statistics on what is left, and repeats {@code r} times:
14+
*
15+
* <pre>
16+
* R_i = max |x - mean| / sd over the points that are still in
17+
* lambda_i = (n - i) * t / sqrt( (n - i - 1 + t^2)(n - i + 1) )
18+
* where t = the 1 - alpha / (2(n - i + 1)) quantile of a t distribution with n - i - 1 degrees of freedom
19+
* </pre>
20+
*
21+
* <p>The number of outliers is the <i>largest</i> {@code i} whose statistic exceeds its critical
22+
* value, not the first one. That is what defeats masking: in a sample with three outliers the first
23+
* two tests can easily fall short while the third one succeeds, and the test then reports all three.
24+
*
25+
* <p>This is a batch test and it needs the whole sample, which is exactly the trade it makes against
26+
* the streaming detectors in this package: it has a stated significance level and it decides how many
27+
* points are outliers, where {@link HampelFilter} answers one sample at a time against a threshold the
28+
* caller has to choose. Rosner's own recommendation is to use it on at least 25 points, and {@code r}
29+
* is an upper bound that may be set generously, because overstating it costs accuracy only in extreme
30+
* cases.
31+
*
32+
* <h2>Usage</h2>
33+
*
34+
* <pre>{@code
35+
* GeneralizedEsd test = new GeneralizedEsd(0.05);
36+
* int[] outliers = test.findOutliers(sample, 10);
37+
* }</pre>
38+
*
39+
* <p>The test costs O(r * n) time and allocates O(n). The t quantile behind the critical value is
40+
* computed from the regularized incomplete beta function, so no table is needed.
41+
*
42+
* @see HampelFilter
43+
* @see <a href="https://en.wikipedia.org/wiki/Grubbs%27s_test">Grubbs's test and its generalization</a>
44+
*/
45+
public final class GeneralizedEsd {
46+
47+
/** Significance level used when none is given. */
48+
public static final double DEFAULT_SIGNIFICANCE_LEVEL = 0.05;
49+
50+
private static final int MINIMUM_SAMPLE_SIZE = 3;
51+
private static final double[] LANCZOS = {
52+
0.99999999999980993,
53+
676.5203681218851,
54+
-1259.1392167224028,
55+
771.32342877765313,
56+
-176.61502916214059,
57+
12.507343278686905,
58+
-0.13857109526572012,
59+
9.9843695780195716e-6,
60+
1.5056327351493116e-7,
61+
};
62+
63+
private final double significanceLevel;
64+
65+
/**
66+
* Creates a test at the customary significance level of {@code 0.05}.
67+
*/
68+
public GeneralizedEsd() {
69+
this(DEFAULT_SIGNIFICANCE_LEVEL);
70+
}
71+
72+
/**
73+
* Creates a test.
74+
*
75+
* @param significanceLevel the probability of declaring an outlier in clean data, in {@code (0, 1)}
76+
* @throws IllegalArgumentException if {@code significanceLevel} is not inside {@code (0, 1)}
77+
*/
78+
public GeneralizedEsd(double significanceLevel) {
79+
if (!(significanceLevel > 0.0) || !(significanceLevel < 1.0)) {
80+
throw new IllegalArgumentException("The significance level must lie in (0, 1), but was " + significanceLevel);
81+
}
82+
this.significanceLevel = significanceLevel;
83+
}
84+
85+
/**
86+
* Finds the outliers of a sample.
87+
*
88+
* @param sample the data to inspect, left untouched
89+
* @param maxOutliers upper bound on the number of outliers, at least one and at most
90+
* {@code sample.length - 2}
91+
* @return the indices of the outliers in ascending order, empty if the sample looks clean
92+
* @throws IllegalArgumentException if the sample holds fewer than three points or a non-finite
93+
* value, or if {@code maxOutliers} is out of range
94+
* @throws NullPointerException if {@code sample} is {@code null}
95+
*/
96+
public int[] findOutliers(double[] sample, int maxOutliers) {
97+
if (sample.length < MINIMUM_SAMPLE_SIZE) {
98+
throw new IllegalArgumentException("The sample must hold at least " + MINIMUM_SAMPLE_SIZE + " points, but held " + sample.length);
99+
}
100+
if (maxOutliers < 1 || maxOutliers > sample.length - 2) {
101+
throw new IllegalArgumentException("The number of outliers to look for must lie in [1, " + (sample.length - 2) + "], but was " + maxOutliers);
102+
}
103+
for (double value : sample) {
104+
if (!Double.isFinite(value)) {
105+
throw new IllegalArgumentException("Samples must be finite, but was " + value);
106+
}
107+
}
108+
109+
int size = sample.length;
110+
double[] values = Arrays.copyOf(sample, size);
111+
int[] indices = new int[size];
112+
for (int i = 0; i < size; i++) {
113+
indices[i] = i;
114+
}
115+
116+
int[] removed = new int[maxOutliers];
117+
int outliers = 0;
118+
int remaining = size;
119+
120+
for (int step = 1; step <= maxOutliers; step++) {
121+
double mean = mean(values, remaining);
122+
double deviation = standardDeviation(values, remaining, mean);
123+
if (deviation == 0.0) {
124+
break;
125+
}
126+
127+
int worst = indexOfLargestDeviation(values, remaining, mean);
128+
double statistic = Math.abs(values[worst] - mean) / deviation;
129+
removed[step - 1] = indices[worst];
130+
if (statistic > criticalValue(size, step)) {
131+
outliers = step;
132+
}
133+
134+
remaining--;
135+
values[worst] = values[remaining];
136+
indices[worst] = indices[remaining];
137+
}
138+
139+
int[] result = Arrays.copyOf(removed, outliers);
140+
Arrays.sort(result);
141+
return result;
142+
}
143+
144+
/**
145+
* Returns the critical value a statistic has to exceed at one step of the test.
146+
*
147+
* @param sampleSize the size of the whole sample
148+
* @param step which removal this is, counting from one
149+
* @return the critical value, that is {@code lambda_i}
150+
* @throws IllegalArgumentException if {@code sampleSize} is smaller than three, or if {@code step}
151+
* is outside {@code [1, sampleSize - 2]}
152+
*/
153+
public double criticalValue(int sampleSize, int step) {
154+
if (sampleSize < MINIMUM_SAMPLE_SIZE) {
155+
throw new IllegalArgumentException("The sample size must be at least " + MINIMUM_SAMPLE_SIZE + ", but was " + sampleSize);
156+
}
157+
if (step < 1 || step > sampleSize - 2) {
158+
throw new IllegalArgumentException("The step must lie in [1, " + (sampleSize - 2) + "], but was " + step);
159+
}
160+
int size = sampleSize - step + 1;
161+
int degreesOfFreedom = size - 2;
162+
double probability = 1.0 - significanceLevel / (2.0 * size);
163+
double t = studentTQuantile(probability, degreesOfFreedom);
164+
return (size - 1) * t / Math.sqrt((degreesOfFreedom + t * t) * size);
165+
}
166+
167+
/**
168+
* Returns the significance level of the test.
169+
*
170+
* @return the level given at construction time
171+
*/
172+
public double significanceLevel() {
173+
return significanceLevel;
174+
}
175+
176+
@Override
177+
public String toString() {
178+
return "GeneralizedEsd{significanceLevel=" + significanceLevel + "}";
179+
}
180+
181+
private static double mean(double[] values, int size) {
182+
double sum = 0.0;
183+
for (int i = 0; i < size; i++) {
184+
sum += values[i];
185+
}
186+
return sum / size;
187+
}
188+
189+
private static double standardDeviation(double[] values, int size, double mean) {
190+
double sum = 0.0;
191+
for (int i = 0; i < size; i++) {
192+
double deviation = values[i] - mean;
193+
sum += deviation * deviation;
194+
}
195+
return Math.sqrt(sum / (size - 1));
196+
}
197+
198+
private static int indexOfLargestDeviation(double[] values, int size, double mean) {
199+
int worst = 0;
200+
double largest = -1.0;
201+
for (int i = 0; i < size; i++) {
202+
double deviation = Math.abs(values[i] - mean);
203+
if (deviation > largest) {
204+
largest = deviation;
205+
worst = i;
206+
}
207+
}
208+
return worst;
209+
}
210+
211+
/**
212+
* Returns the {@code probability} quantile of a t distribution, found by bisecting its cumulative
213+
* distribution function.
214+
*
215+
* @param probability the probability to invert, in {@code (0, 1)}
216+
* @param degreesOfFreedom the degrees of freedom, at least one
217+
* @return the quantile
218+
*/
219+
private static double studentTQuantile(double probability, int degreesOfFreedom) {
220+
if (probability < 0.5) {
221+
return -studentTQuantile(1.0 - probability, degreesOfFreedom);
222+
}
223+
double low = 0.0;
224+
double high = 1.0;
225+
while (studentTCumulative(high, degreesOfFreedom) < probability && high < 1e12) {
226+
high *= 2.0;
227+
}
228+
for (int i = 0; i < 200; i++) {
229+
double middle = 0.5 * (low + high);
230+
if (studentTCumulative(middle, degreesOfFreedom) < probability) {
231+
low = middle;
232+
} else {
233+
high = middle;
234+
}
235+
}
236+
return 0.5 * (low + high);
237+
}
238+
239+
/**
240+
* Returns the probability that a t distributed variable stays below {@code t}.
241+
*
242+
* @param t the point to evaluate at
243+
* @param degreesOfFreedom the degrees of freedom, at least one
244+
* @return the cumulative probability
245+
*/
246+
private static double studentTCumulative(double t, int degreesOfFreedom) {
247+
double x = degreesOfFreedom / (degreesOfFreedom + t * t);
248+
double tail = 0.5 * regularizedIncompleteBeta(0.5 * degreesOfFreedom, 0.5, x);
249+
return t >= 0.0 ? 1.0 - tail : tail;
250+
}
251+
252+
/**
253+
* Returns the regularized incomplete beta function, evaluated with the continued fraction of
254+
* Lentz.
255+
*
256+
* @param a first shape parameter, strictly positive
257+
* @param b second shape parameter, strictly positive
258+
* @param x the point to evaluate at, in {@code [0, 1]}
259+
* @return {@code I_x(a, b)}
260+
*/
261+
private static double regularizedIncompleteBeta(double a, double b, double x) {
262+
if (x <= 0.0) {
263+
return 0.0;
264+
}
265+
if (x >= 1.0) {
266+
return 1.0;
267+
}
268+
double logBeta = logGamma(a) + logGamma(b) - logGamma(a + b);
269+
if (x < (a + 1.0) / (a + b + 2.0)) {
270+
return Math.exp(a * Math.log(x) + b * Math.log1p(-x) - logBeta) * betaContinuedFraction(a, b, x) / a;
271+
}
272+
return 1.0 - Math.exp(b * Math.log1p(-x) + a * Math.log(x) - logBeta) * betaContinuedFraction(b, a, 1.0 - x) / b;
273+
}
274+
275+
private static double betaContinuedFraction(double a, double b, double x) {
276+
double tiny = 1e-300;
277+
double c = 1.0;
278+
double d = 1.0 - (a + b) * x / (a + 1.0);
279+
if (Math.abs(d) < tiny) {
280+
d = tiny;
281+
}
282+
d = 1.0 / d;
283+
double fraction = d;
284+
285+
for (int m = 1; m <= 300; m++) {
286+
int even = 2 * m;
287+
double numerator = m * (b - m) * x / ((a + even - 1.0) * (a + even));
288+
d = 1.0 + numerator * d;
289+
if (Math.abs(d) < tiny) {
290+
d = tiny;
291+
}
292+
c = 1.0 + numerator / c;
293+
if (Math.abs(c) < tiny) {
294+
c = tiny;
295+
}
296+
d = 1.0 / d;
297+
fraction *= d * c;
298+
299+
numerator = -(a + m) * (a + b + m) * x / ((a + even) * (a + even + 1.0));
300+
d = 1.0 + numerator * d;
301+
if (Math.abs(d) < tiny) {
302+
d = tiny;
303+
}
304+
c = 1.0 + numerator / c;
305+
if (Math.abs(c) < tiny) {
306+
c = tiny;
307+
}
308+
d = 1.0 / d;
309+
double step = d * c;
310+
fraction *= step;
311+
312+
if (Math.abs(step - 1.0) < 1e-15) {
313+
break;
314+
}
315+
}
316+
return fraction;
317+
}
318+
319+
/**
320+
* Returns the logarithm of the gamma function, using the Lanczos approximation.
321+
*
322+
* @param x the point to evaluate at, strictly positive
323+
* @return {@code log(gamma(x))}
324+
*/
325+
private static double logGamma(double x) {
326+
double shifted = x - 1.0;
327+
double series = LANCZOS[0];
328+
for (int i = 1; i < LANCZOS.length; i++) {
329+
series += LANCZOS[i] / (shifted + i);
330+
}
331+
double t = shifted + 7.5;
332+
return 0.5 * Math.log(2.0 * Math.PI) + (shifted + 0.5) * Math.log(t) - t + Math.log(series);
333+
}
334+
}

0 commit comments

Comments
 (0)