Code
n01b <- tibble(y = c(n01$v2[1:18], 4, 4.5))
n01b |>
ggplot(aes(x = y, y = 1)) +
geom_jitter(width = 0, alpha = 0.5) +
scale_y_discrete() +
labs(y = "", x = "Synthetic data")
The early history of anomaly detection methods involved statistical tests and assumptions about the distribution of the data. There are much better methods available now, but these old methods are still widely used. So they are included here for historical interest, and to point out their flaws to those still using them. There is nothing in later chapters that assumes knowledge of this chapter, so if you want to skip on to more useful methods, head to Chapter 5.
We will only consider univariate methods in this chapter. There have been multivariate parametric tests for anomaly detection, but they are much less widely used and are also best avoided.
We will test the methods using six examples: two involving real data, and four involving simulated data.
cricket_batting data set. As discussed in Section 1.5, these appear to contain one genuine anomaly, the batter Don Bradman.n01 data, along with the values 4.0 and 4.5. The latter two are anomalies as they are unlikely to arise from the N(0,1) distribution. We save this data set as n01b. The plot below shows the data for this example.n01b <- tibble(y = c(n01$v2[1:18], 4, 4.5))
n01b |>
ggplot(aes(x = y, y = 1)) +
geom_jitter(width = 0, alpha = 0.5) +
scale_y_discrete() +
labs(y = "", x = "Synthetic data")
The other three simulated examples will use 1000 observations from each of the following distributions:
n01 data set);The density functions for these three distributions can be seen in Figure 4.2.
c(t3 = dist_student_t(3), X4 = dist_chisq(4), N01 = dist_normal()) |>
gg_density() +
labs(y = "Probability density") +
scale_colour_discrete(
breaks = c("t3", "X4", "N01"),
labels = c(
latex2exp::TeX("$t_{~3}$"),
latex2exp::TeX("$\\chi^{~2}_{~~4}$"),
"N(0,1)"
)
) +
theme(legend.text = element_text(hjust = 0)) +
coord_cartesian(xlim = c(-5, 10))
Data for the last two examples are generated below.
set.seed(1)
t3 <- tibble(y = rt(1000, df = 3))
chisq4 <- tibble(y = rchisq(1000, df = 4))All methods considered in this chapter assume the underlying data follow a Normal distribution. In our examples, only #4 is from a Normal distribution, so the methods should work well in that case, but perhaps not in the other cases.
Good anomaly detection methods should pick up one anomaly in the cricket batting example, at least three anomalies in the Old Faithful example, and the two anomalies in the n01b example. They should identify no anomalies in the remaining examples.
Many parametric methods are based on z-scores (Section 3.7) and assume that the data come from a Normal distribution. Recall that if our data are given by y_1,\dots,y_n, then their z-scores are given by z_i = (y_i - \bar{y})/s_y where \bar{y} is the mean and s_y is the standard deviation of the observations.
Some books recommend that observations be identified as anomalies when the absolute value of the corresponding z is above some threshold (usually 3). This is a bad idea for several reasons.
We now examine what happens when we apply this test to the six examples.
cricket_batting |>
filter(Innings > 20) |>
mutate(z = (Average - mean(Average)) / sd(Average)) |>
filter(abs(z) > 3) |>
select(Player, Average, z)#> # A tibble: 1 × 3
#> Player Average z
#> <chr> <dbl> <dbl>
#> 1 DG Bradman 99.9 5.52
oldfaithful |>
mutate(z = (duration - mean(duration)) / sd(duration)) |>
filter(abs(z) > 3)#> # A tibble: 3 × 5
#> time recorded_duration duration waiting z
#> <dttm> <chr> <dbl> <dbl> <dbl>
#> 1 2018-04-25 19:08:00 1s 1 5700 -4.83
#> 2 2022-12-07 17:19:00 ~4 30s 30 5220 -4.21
#> 3 2023-07-04 12:03:00 ~1 minute 55ish seconds 60 4920 -3.56
n01b |>
mutate(z = (y - mean(y)) / sd(y)) |>
filter(abs(z) > 3)#> # A tibble: 0 × 2
#> # ℹ 2 variables: y <dbl>, z <dbl>
n01 |>
select(v1) |>
mutate(z = (v1 - mean(v1)) / sd(v1)) |>
filter(abs(z) > 3)#> # A tibble: 1 × 2
#> v1 z
#> <dbl> <dbl>
#> 1 3.81 3.69
t3 |>
mutate(z = (y - mean(y)) / sd(y)) |>
filter(abs(z) > 3)#> # A tibble: 18 × 2
#> y z
#> <dbl> <dbl>
#> 1 -7.40 -4.78
#> 2 5.53 3.49
#> 3 10.5 6.66
#> 4 -5.82 -3.77
#> 5 -5.23 -3.39
#> 6 6.20 3.92
#> 7 4.88 3.08
#> 8 -4.77 -3.10
#> 9 5.99 3.79
#> 10 -5.72 -3.70
#> 11 -4.70 -3.05
#> 12 -5.37 -3.48
#> 13 -6.61 -4.27
#> 14 5.02 3.17
#> 15 -11.4 -7.33
#> 16 7.65 4.85
#> 17 5.32 3.36
#> 18 6.38 4.04
chisq4 |>
mutate(z = (y - mean(y)) / sd(y)) |>
filter(abs(z) > 3)#> # A tibble: 15 × 2
#> y z
#> <dbl> <dbl>
#> 1 14.4 3.74
#> 2 14.1 3.63
#> 3 13.0 3.23
#> 4 13.5 3.42
#> 5 13.7 3.48
#> 6 13.3 3.35
#> 7 12.5 3.04
#> 8 17.0 4.68
#> 9 13.7 3.48
#> 10 12.9 3.20
#> 11 14.0 3.60
#> 12 15.2 4.04
#> 13 14.1 3.62
#> 14 13.9 3.55
#> 15 12.7 3.12
Even if the data did come from a Normal distribution, the probability of an observation being more than 3 standard deviations from the mean is 0.0027, so we would expect to see approximately 1 in every 1 / 0.0027 = 370 regular observations being identified as an anomaly using this approach.
In fact, we can be more accurate than this. If our n observations come from a Normal distribution, then the z-scores follow (approximately) a \text{t}_{n-1} distribution. (The approximation arises because we replace the mean and standard deviation by their sample estimates.) When we identify anomalies as those points with |z| > c, then the probability of finding at least one spurious anomaly is the probability of the maximum being above c or the minimum being below -c. Since the distribution is symmetric, these probabilities are the same. Let M_n be the maximum of n z-scores computed from the data; then \Pr(M_n \le c) = (F_{t}(c; n - 1))^n where F_{t}(y; n - 1) is the cumulative distribution function of a t distribution with n - 1 degrees of freedom. So the probability of at least one spurious anomaly is 1 - (F_{t}(c; n - 1))^{2n}. \tag{4.1} This probability is accurate for large n, but not for small sample sizes. So, instead, we will use simulation to compute the probabilities; these are plotted in Figure 4.3 for several values of c and n.
Let’s also compute the probability of spurious anomalies for different data distributions — the \text{t}_3 and \chi^2_4 distributions shown in Figure 4.2.
Notice how the departures from normality, either via skewness (for the \chi^2 distribution) or with heavier tails (for the t distribution), lead to much greater probabilities for spurious anomalies. Any tests for anomaly detection that assume an underlying data distribution will be sensitive to the shape of that distribution.
Even if we were prepared to believe that the data come from a Normal distribution, we need to adjust the threshold to allow for the sample size. This is the idea behind the next methods we will consider.
Benjamin Peirce was a Harvard mathematician in the mid 1800s who worked with astronomical data that were prone to anomalous observations. He proposed the first known test based on what are now called z-scores (Peirce, 1852). His criterion was that any observations with |z| > c should be “rejected”, where c is a complicated function depending on the sample size n and the number of suspected anomalies.
Figure 4.5 shows the value of c as a function of sample size, when there is only one suspected anomaly.
The threshold increases with sample size n to allow for the increasing likelihood of observations falling in the extreme tails of the distribution.
The peirce_anomalies() function returns a logical vector indicating which observations are anomalous under this criterion. Let’s apply it to the six examples.
cricket_batting |> filter(peirce_anomalies(Average))#> # A tibble: 0 × 15
#> # ℹ 15 variables: Player <chr>, Country <chr>, Start <int>, End <int>, Matches <int>,
#> # Innings <int>, NotOuts <int>, Runs <int>, HighScore <int>, HighScoreNotOut <lgl>,
#> # Average <dbl>, Hundreds <int>, Fifties <int>, Ducks <int>, Gender <chr>
oldfaithful |> filter(peirce_anomalies(duration))#> # A tibble: 2 × 4
#> time recorded_duration duration waiting
#> <dttm> <chr> <dbl> <dbl>
#> 1 2018-04-25 19:08:00 1s 1 5700
#> 2 2022-12-07 17:19:00 ~4 30s 30 5220
n01 |>
select(v1) |>
filter(peirce_anomalies(v1))#> # A tibble: 1 × 1
#> v1
#> <dbl>
#> 1 3.81
n01b |> filter(peirce_anomalies(y))#> # A tibble: 2 × 1
#> y
#> <dbl>
#> 1 4
#> 2 4.5
t3 |> filter(peirce_anomalies(y))#> # A tibble: 10 × 1
#> y
#> <dbl>
#> 1 -7.40
#> 2 10.5
#> 3 -5.82
#> 4 6.20
#> 5 5.99
#> 6 -5.72
#> 7 -6.61
#> 8 -11.4
#> 9 7.65
#> 10 6.38
chisq4 |> filter(peirce_anomalies(y))#> # A tibble: 6 × 1
#> y
#> <dbl>
#> 1 14.4
#> 2 14.1
#> 3 17.0
#> 4 14.0
#> 5 15.2
#> 6 14.1
Peirce’s proposal was largely superseded by an alternative proposed by the astrophysicist William Chauvenet, which was much simpler to describe. He suggested (Chauvenet, 1863) replacing the threshold c by the 1 - 0.25/n quantile from the standard Normal distribution. This threshold is also shown in Figure 4.5. A consequence of this choice is that Chauvenet’s criterion will reject, on average, half an observation of genuine data from a Normal distribution regardless of the value of n. However, for non-normal data, there is no such guarantee that genuine observations will not be detected as anomalies. Despite its flaws, the method is still widely used in some disciplines, especially engineering.
The chauvenet_anomalies() function can be used to implement this test. For our six examples, it gives similar results to those above for peirce_anomalies() (with two additional spurious anomalies for the \text{t}_3 example, and one additional spurious anomaly for the \chi^2_4 example.)
By the 20th century, the concept of hypothesis testing had been developed, and the t-distribution had been discovered, and both were applied to the identification of anomalies using z-scores. Many tests were developed under different assumptions about the underlying distribution and what was assumed to be known (Hawkins, 1980). We will mention just two of them here, as they are the most widely used.
Egon Pearson and Chandra Sekar proposed (Pearson & Sekar, 1936) that an observation be considered an anomaly if |z_i| > c_\alpha, where the critical value is given by c_\alpha = t_{\alpha/n, n-2} \sqrt{\frac{n - 1}{n - 2 + t^2_{\alpha/n, n-2}}} and t_{p, k} is the 1 - p quantile of the t distribution with k degrees of freedom. Later, this was extended by Frank Grubbs (Grubbs, 1950) who proposed using c_\alpha = \frac{(n - 1)t_{\alpha/2n, n-2}}{\sqrt{n(n - 2 + t^2_{\alpha/2n, n-2})}}.
Figure 4.6 shows the critical values at \alpha = 0.05 for these tests, along with the corresponding value from Chauvenet (1863) for comparison.
From these, we can compute the probability of a spurious anomaly in a Normal distribution, using Equation 4.1. This probability should be equal to \alpha = 0.05.
Figure 4.7 shows that only Grubbs’ test gives reasonable results, with the others finding too many anomalies except in small samples.
However, when we allow for different data distributions, Grubbs’ test also gives poor results, showing it is sensitive to the assumed data distribution.
We can apply the test using the grubbs_anomalies() function to our six examples.
cricket_batting |>
filter(Innings > 20) |>
filter(grubbs_anomalies(Average)) |>
select(Player, Country, Average)#> # A tibble: 1 × 3
#> Player Country Average
#> <chr> <chr> <dbl>
#> 1 DG Bradman Australia 99.9
oldfaithful |>
filter(grubbs_anomalies(duration))#> # A tibble: 1 × 4
#> time recorded_duration duration waiting
#> <dttm> <chr> <dbl> <dbl>
#> 1 2018-04-25 19:08:00 1s 1 5700
n01b |>
filter(grubbs_anomalies(y))#> # A tibble: 0 × 1
#> # ℹ 1 variable: y <dbl>
n01 |>
filter(grubbs_anomalies(v1))#> # A tibble: 0 × 10
#> # ℹ 10 variables: v1 <dbl>, v2 <dbl>, v3 <dbl>, v4 <dbl>, v5 <dbl>, v6 <dbl>, v7 <dbl>,
#> # v8 <dbl>, v9 <dbl>, v10 <dbl>
t3 |>
filter(grubbs_anomalies(y))#> # A tibble: 6 × 1
#> y
#> <dbl>
#> 1 -7.40
#> 2 10.5
#> 3 -6.61
#> 4 -11.4
#> 5 7.65
#> 6 6.38
chisq4 |>
filter(grubbs_anomalies(y))#> # A tibble: 1 × 1
#> y
#> <dbl>
#> 1 17.0
The clear failure is with the \text{t}_3 distribution which has no real anomalies. A spurious anomaly is also detected in the \chi^2_4 example, and real anomalies are missed in the Old Faithful data and in n01b.
The z-score tests above all measure the distance of an observation from the centre of the distribution. A different approach looks instead at the gaps between consecutive order statistics (see Section 2.4): if a value is anomalous, we expect a noticeably large gap between it and its nearest neighbour in the sorted data. Dixon (1950) introduced the first widely-used test of this type.
If y_{(1)},\dots,y_{(n)} denote the ordered values of our sample, then Dixon’s Q statistic is given by Q = \frac{y_{(n)} - y_{(n-1)}}{y_{(n)} - y_{(1)}}, the ratio of the difference between the two largest values to the range of the data. If the largest value is the only anomaly, then Q will take a larger value than expected.
The corresponding test for the minimum to be an anomaly uses y_{(2)} - y_{(1)} in the numerator instead, the difference between the second smallest and minimum observations. Both minimum and maximum values can be tested simultaneously using the two-sided test, where the numerator is the maximum of y_{(n)} - y_{(n-1)} and y_{(2)} - y_{(1)}.
The test is clearly flawed for several reasons. First, if the two largest values are both anomalies of similar size, then Q will be small and these anomalies will be missed. Also, if both maximum and minimum values are anomalies, the denominator will be larger than expected, thereby reducing the size of Q.
As with the other tests considered here, the test assumes that the underlying data distribution is normal, and anomalies are identified which appear inconsistent with that assumption.
Simulation can be used to compute the critical values for this test, assuming that the data come from a Normal distribution. We can also use simulation to compute the probability of false positives, giving the results shown in Figure 4.9. As with the other tests we have considered, Dixon’s test is sensitive to the assumed data distribution making it largely useless for real data analysis.
We can apply the test using the dixon_anomalies() function, which does a two-sided test of both minimum and maximum observations.
cricket_batting |>
filter(Innings > 20) |>
filter(dixon_anomalies(Average)) |>
select(Player, Country, Average)#> # A tibble: 1 × 3
#> Player Country Average
#> <chr> <chr> <dbl>
#> 1 DG Bradman Australia 99.9
oldfaithful |>
filter(dixon_anomalies(duration))#> # A tibble: 0 × 4
#> # ℹ 4 variables: time <dttm>, recorded_duration <chr>, duration <dbl>, waiting <dbl>
n01b |>
filter(dixon_anomalies(y))#> # A tibble: 0 × 1
#> # ℹ 1 variable: y <dbl>
n01 |>
filter(dixon_anomalies(v1))#> # A tibble: 0 × 10
#> # ℹ 10 variables: v1 <dbl>, v2 <dbl>, v3 <dbl>, v4 <dbl>, v5 <dbl>, v6 <dbl>, v7 <dbl>,
#> # v8 <dbl>, v9 <dbl>, v10 <dbl>
t3 |>
filter(dixon_anomalies(y))#> # A tibble: 1 × 1
#> y
#> <dbl>
#> 1 -11.4
chisq4 |>
filter(dixon_anomalies(y))#> # A tibble: 0 × 1
#> # ℹ 1 variable: y <dbl>
For these examples, the test has failed to identify any of the anomalies in oldfaithful and n01b, and it has identified a spurious anomaly in t3.
Dixon’s test only examines the gap adjacent to the minimum or maximum observation. Tukey (1977) proposed looking for large gaps anywhere in the sorted data, while Wainer & Schacht (1978) formalised this into a practical method for determining whether any such spacing is unexpectedly large for its position in the distribution. The method is best thought of as detecting unusually large separations or holes in a one-dimensional data set. A large tail gap may suggest an outlier; a large interior gap usually suggests a separation between two groups rather than a single anomalous observation.
Consider the spacings defined (Section 2.4) by d_i = y_{(i+1)} - y_{(i)}, \qquad i = 1, \dots, n - 1, where y_{(1)} \le y_{(2)} \le \cdots \le y_{(n)} are the sorted observations.
Raw spacings are not directly comparable across the sorted sample. Even for data from a single unimodal distribution, spacings tend to be small near the centre of the distribution, where observations are dense, and larger in the tails, where observations are sparse.
Tukey’s gapping scheme addresses this by weighting each gap according to its position in the ordered data. Specifically, the gap between y{(i)} and y{(i + 1)} is assigned weight a_i = i(n - i), \qquad i = 1, \ldots, n - 1, \tag{4.2} which is largest near the middle of the sample and smallest at the extremes. This emphasises unusually large gaps in the centre, where observations are normally close together, and downweights gaps in the tails, where larger spacings are expected.
Wainer & Schacht (1978) developed this idea into a practical diagnostic by transforming and standardising the weighted gaps. They proposed using the square root of the weighted gaps, h_i = \sqrt{a_i d_i}, which makes their distribution more symmetric, and then comparing each h_i with a robust estimate of typical gap size, namely the 25% trimmed mean of the h_i values: z^*_i = \frac{h_i}{\bar h_{T25}}. \tag{4.3} Large values of z^*_i identify gaps that are large relative to other gaps after allowing for their position in the sorted data. Tukey suggested z^*_i > 2.25 as an exploratory cut-off; Wainer & Schacht (1978) showed by simulation that, under a Gaussian distribution, this corresponds to a per-gap false positive rate of about 3%. Because there are n - 1 gaps, this is not a 5% test for the whole data set. If many gaps are inspected, the chance of at least one crossing the threshold can be much larger than the per-gap rate.
The code below constructs a data frame of gaps between batter averages.
batters <- cricket_batting |>
filter(Innings > 25) |>
arrange(Average)
batting_gaps <- batters |>
transmute(
lower_player = Player,
lower_average = Average,
upper_player = lead(Player),
upper_average = lead(Average),
spacing = upper_average - lower_average,
weight = row_number() * (n() - row_number())
) |>
mutate(
weighted_gap = sqrt(weight * spacing),
ws = weighted_gap / mean(weighted_gap, trim = 0.25, na.rm = TRUE)
) |>
filter(!is.na(ws))
batting_gaps |>
ggplot(aes(x = upper_average, y = ws)) +
geom_hline(yintercept = 2.25, linetype = "dashed", colour = "gray50") +
geom_point(alpha = 0.5) +
labs(x = "Career batting average", y = "Tukey-Wainer-Schacht gap score")
Restricting attention to scores above the exploratory threshold of 2.25, we show the five largest gaps below.
batting_gaps |>
filter(ws > 2.25) |>
slice_max(spacing, n = 5)#> # A tibble: 5 × 8
#> lower_player lower_average upper_player upper_average spacing weight weighted_gap
#> <chr> <dbl> <chr> <dbl> <dbl> <int> <dbl>
#> 1 AC Voges 61.9 DG Bradman 99.9 38.1 1045 199.
#> 2 BF Butcher 43.1 IR Redpath 43.5 0.348 126073 209.
#> 3 J Da Silva 24.8 KD Karthik 25 0.240 265248 252.
#> 4 MAK Pataudi 34.9 Shoaib Malik 35.1 0.236 235504 236.
#> 5 Soumya Sarkar 27.7 SO Hetmyer 27.9 0.233 273528 253.
#> # ℹ 1 more variable: ws <dbl>
The largest gap of 38.1 should be interpreted as the separation between Adam Voges (who has the second highest batting average) and Don Bradman, not as a score belonging to Bradman alone. This illustrates that the method detects separations, not individual anomalous observations.
The Tukey–Wainer–Schacht score does not yield a direct p-value. A related way to construct a formal gap test is to calculate the uniform spacings (see Section 2.4) of the data after transforming them using the fitted distribution function F: \delta_i = F(y_{(i+1)}) - F(y_{(i)}), \quad i = 1, \dots, n - 1. Since the transformed spacings are approximately \text{Exp}(n + 1) (Section 2.4), and the exponential distribution is in the Gumbel domain of attraction (Section 2.10), then \delta_{\max} = \max(\delta_1,\dots,\delta_{n-1}) satisfies t = (n + 1)\delta_{\max} - \log(n - 1) \approx \text{Gumbel}(0,1). This gives the approximate upper-tail probability p \approx 1 - \exp(-e^{-t}). \tag{4.4}
In practice, F is unknown. The example below uses a robustly fitted Normal distribution, with the median and MAD as estimates of location and scale. This makes the fit less sensitive to the possible outlier, but the resulting test is still only an approximate parametric test.
mu_hat <- median(batters$Average)
sigma_hat <- robustbase::s_mad(batters$Average)
batting_gaps |>
mutate(
transformed_gap = pnorm(upper_average, mu_hat, sigma_hat) -
pnorm(lower_average, mu_hat, sigma_hat),
t = (nrow(batters) + 1) * transformed_gap - log(nrow(batting_gaps)),
p_value = 1 - exp(-exp(-t))
) |>
filter(p_value < 0.05) |>
select(
lower_player,
lower_average,
upper_player,
upper_average,
spacing,
p_value
)#> # A tibble: 1 × 6
#> lower_player lower_average upper_player upper_average spacing p_value
#> <chr> <dbl> <chr> <dbl> <dbl> <dbl>
#> 1 AC Voges 61.9 DG Bradman 99.9 38.1 0.00786
The p-value is very small, confirming that the gap below Bradman’s average is far too large to be consistent with all batters coming from a single fitted Normal distribution.
Finally, we apply the same approach to the other five examples considered in this chapter. Here we associate the p-value with the larger observation in each gap pair.
gap_test_pvalue <- function(x) {
mu_hat <- median(x)
sigma_hat <- robustbase::s_mad(x)
n <- length(x)
ix <- order(x)
y <- x[ix]
lower <- y[-n]
upper <- y[-1]
transformed_gap <- pnorm(upper, mu_hat, sigma_hat) -
pnorm(lower, mu_hat, sigma_hat)
t <- (n + 1) * transformed_gap - log(n - 1)
p_value <- 1 - exp(-exp(-t))
p_value_original_order <- rep(NA_real_, n)
p_value_original_order[ix[-1]] <- p_value
p_value_original_order
}
cricket_batting |>
filter(Innings > 25) |>
filter(gap_test_pvalue(Average) < 0.05) |>
select(Player, Country, Average)#> # A tibble: 1 × 3
#> Player Country Average
#> <chr> <chr> <dbl>
#> 1 DG Bradman Australia 99.9
oldfaithful |>
filter(gap_test_pvalue(duration) < 0.05)#> # A tibble: 68 × 4
#> time recorded_duration duration waiting
#> <dttm> <chr> <dbl> <dbl>
#> 1 2017-01-26 14:27:00 ~4m 240 5820
#> 2 2017-01-31 13:27:00 ~3.5m 210 5580
#> 3 2017-02-04 22:14:00 3m 34s 214 5400
#> 4 2017-02-05 19:00:00 4m 2s 242 6060
#> 5 2017-02-05 21:49:00 4m 15s 255 6840
#> 6 2017-02-06 06:19:00 4m 04s 244 5940
#> 7 2017-02-06 07:58:00 4m 28s 268 5520
#> 8 2017-02-06 14:34:00 3m 51s 231 5760
#> 9 2017-02-08 19:05:00 3m 47s 227 6660
#> 10 2017-02-08 22:26:00 3m 55s 235 6360
#> # ℹ 58 more rows
n01b |>
filter(gap_test_pvalue(y) < 0.05)#> # A tibble: 0 × 1
#> # ℹ 1 variable: y <dbl>
n01 |>
filter(gap_test_pvalue(v1) < 0.05)#> # A tibble: 0 × 10
#> # ℹ 10 variables: v1 <dbl>, v2 <dbl>, v3 <dbl>, v4 <dbl>, v5 <dbl>, v6 <dbl>, v7 <dbl>,
#> # v8 <dbl>, v9 <dbl>, v10 <dbl>
t3 |>
filter(gap_test_pvalue(y) < 0.05)#> # A tibble: 0 × 1
#> # ℹ 1 variable: y <dbl>
chisq4 |>
filter(gap_test_pvalue(y) < 0.05)#> # A tibble: 0 × 1
#> # ℹ 1 variable: y <dbl>
While the method worked well for the cricket batting averages, it identified 68 “anomalies” in the Old Faithful data, and it failed to identify either of the two clear anomalies in n01b.
Like all methods in this chapter, both the normalised gap score and the transformed-gap test depend on an assumed null distribution, and so the results depend on the adequacy of the model. Related extreme-spacing ideas underlie the HDoutliers and stray algorithms discussed in Chapter 6, where gaps in nearest-neighbour distances are used rather than gaps in the raw univariate data.
We can summarise the results of the various tests used here in the following table.
| Example | N observations | Expected anomalies | Zscore | Peirce | Chauvenet | Grubbs | Dixon | Gap |
|---|---|---|---|---|---|---|---|---|
| 1. Cricket batting | 3968 | 1 | 1 | 0 | 0 | 1 | 1 | 1 |
| 2. Old Faithful duration | 2097 | 3 | 3 | 2 | 2 | 1 | 0 | 68 |
| 3. N(0,1) + 2 outliers | 20 | 2 | 0 | 2 | 2 | 0 | 0 | 0 |
| 4. N(0,1) | 1000 | 0 | 1 | 1 | 1 | 0 | 0 | 0 |
| 5. \text{t}_3 | 1000 | 0 | 18 | 10 | 12 | 6 | 1 | 0 |
| 6. \chi^2_4 | 1000 | 0 | 15 | 6 | 7 | 1 | 0 | 0 |
Only Z scores found the three (and only the three) clear anomalies in the Old Faithful data, and most failed to find either of the artificial anomalies in n01b. All tests found at least one spurious anomaly in the large simulated data sets with no real anomalies.
As noted at the start of this chapter, anomaly detection methods based on assumed data distributions are unreliable. Until about 1975, they were the only viable methods given the lack of computing facilities available, because they could be implemented using tables and hand calculations. However, there is little justification for continuing to use such methods.
They are particularly sensitive to the assumed data distribution, and the probability of detecting spurious anomalies is usually much higher in reality than under the ideal conditions in which the tests were conceived.
These tests are sometimes applied iteratively, where observations are removed from the data if determined to be anomalies, and the test re-applied to the remaining data. This process continues until no more anomalies are found. However, this procedure will clearly change the size of the test due to the problem of multiple comparisons.
Next, we will turn our attention to methods that arose in the latter part of the 20th century which were based on quantiles and data depth rather than on any underlying parametric data distribution.