blob: 70713ac57e4a283bb132234eac73a163e2ef06ce [file] [log] [blame]
//! Tukey's method
//!
//! The original method uses two "fences" to classify the data. All the observations "inside" the
//! fences are considered "normal", and the rest are considered outliers.
//!
//! The fences are computed from the quartiles of the sample, according to the following formula:
//!
//! ``` ignore
//! // q1, q3 are the first and third quartiles
//! let iqr = q3 - q1; // The interquartile range
//! let (f1, f2) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); // the "fences"
//!
//! let is_outlier = |x| if x > f1 && x < f2 { true } else { false };
//! ```
//!
//! The classifier provided here adds two extra outer fences:
//!
//! ``` ignore
//! let (f3, f4) = (q1 - 3 * iqr, q3 + 3 * iqr); // the outer "fences"
//! ```
//!
//! The extra fences add a sense of "severity" to the classification. Data points outside of the
//! outer fences are considered "severe" outliers, whereas points outside the inner fences are just
//! "mild" outliers, and, as the original method, everything inside the inner fences is considered
//! "normal" data.
//!
//! Some ASCII art for the visually oriented people:
//!
//! ``` ignore
//! LOW-ish NORMAL-ish HIGH-ish
//! x | + | o o o o o o o | + | x
//! f3 f1 f2 f4
//!
//! Legend:
//! o: "normal" data (not an outlier)
//! +: "mild" outlier
//! x: "severe" outlier
//! ```
use std::iter::IntoIterator;
use std::ops::{Deref, Index};
use std::slice;
use crate::stats::float::Float;
use crate::stats::univariate::Sample;
use self::Label::*;
/// A classified/labeled sample.
///
/// The labeled data can be accessed using the indexing operator. The order of the data points is
/// retained.
///
/// NOTE: Due to limitations in the indexing traits, only the label is returned. Once the
/// `IndexGet` trait lands in stdlib, the indexing operation will return a `(data_point, label)`
/// pair.
#[derive(Clone, Copy)]
pub struct LabeledSample<'a, A>
where
A: Float,
{
fences: (A, A, A, A),
sample: &'a Sample<A>,
}
impl<'a, A> LabeledSample<'a, A>
where
A: Float,
{
/// Returns the number of data points per label
///
/// - Time: `O(length)`
#[cfg_attr(feature = "cargo-clippy", allow(clippy::similar_names))]
pub fn count(&self) -> (usize, usize, usize, usize, usize) {
let (mut los, mut lom, mut noa, mut him, mut his) = (0, 0, 0, 0, 0);
for (_, label) in self {
match label {
LowSevere => {
los += 1;
}
LowMild => {
lom += 1;
}
NotAnOutlier => {
noa += 1;
}
HighMild => {
him += 1;
}
HighSevere => {
his += 1;
}
}
}
(los, lom, noa, him, his)
}
/// Returns the fences used to classify the outliers
pub fn fences(&self) -> (A, A, A, A) {
self.fences
}
/// Returns an iterator over the labeled data
pub fn iter(&self) -> Iter<'a, A> {
Iter {
fences: self.fences,
iter: self.sample.iter(),
}
}
}
impl<'a, A> Deref for LabeledSample<'a, A>
where
A: Float,
{
type Target = Sample<A>;
fn deref(&self) -> &Sample<A> {
self.sample
}
}
// FIXME Use the `IndexGet` trait
impl<'a, A> Index<usize> for LabeledSample<'a, A>
where
A: Float,
{
type Output = Label;
#[cfg_attr(feature = "cargo-clippy", allow(clippy::similar_names))]
fn index(&self, i: usize) -> &Label {
static LOW_SEVERE: Label = LowSevere;
static LOW_MILD: Label = LowMild;
static HIGH_MILD: Label = HighMild;
static HIGH_SEVERE: Label = HighSevere;
static NOT_AN_OUTLIER: Label = NotAnOutlier;
let x = self.sample[i];
let (lost, lomt, himt, hist) = self.fences;
if x < lost {
&LOW_SEVERE
} else if x > hist {
&HIGH_SEVERE
} else if x < lomt {
&LOW_MILD
} else if x > himt {
&HIGH_MILD
} else {
&NOT_AN_OUTLIER
}
}
}
impl<'a, 'b, A> IntoIterator for &'b LabeledSample<'a, A>
where
A: Float,
{
type Item = (A, Label);
type IntoIter = Iter<'a, A>;
fn into_iter(self) -> Iter<'a, A> {
self.iter()
}
}
/// Iterator over the labeled data
pub struct Iter<'a, A>
where
A: Float,
{
fences: (A, A, A, A),
iter: slice::Iter<'a, A>,
}
impl<'a, A> Iterator for Iter<'a, A>
where
A: Float,
{
type Item = (A, Label);
#[cfg_attr(feature = "cargo-clippy", allow(clippy::similar_names))]
fn next(&mut self) -> Option<(A, Label)> {
self.iter.next().map(|&x| {
let (lost, lomt, himt, hist) = self.fences;
let label = if x < lost {
LowSevere
} else if x > hist {
HighSevere
} else if x < lomt {
LowMild
} else if x > himt {
HighMild
} else {
NotAnOutlier
};
(x, label)
})
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.iter.size_hint()
}
}
/// Labels used to classify outliers
pub enum Label {
/// A "mild" outlier in the "high" spectrum
HighMild,
/// A "severe" outlier in the "high" spectrum
HighSevere,
/// A "mild" outlier in the "low" spectrum
LowMild,
/// A "severe" outlier in the "low" spectrum
LowSevere,
/// A normal data point
NotAnOutlier,
}
impl Label {
/// Checks if the data point has an "unusually" high value
pub fn is_high(&self) -> bool {
matches!(*self, HighMild | HighSevere)
}
/// Checks if the data point is labeled as a "mild" outlier
pub fn is_mild(&self) -> bool {
matches!(*self, HighMild | LowMild)
}
/// Checks if the data point has an "unusually" low value
pub fn is_low(&self) -> bool {
matches!(*self, LowMild | LowSevere)
}
/// Checks if the data point is labeled as an outlier
pub fn is_outlier(&self) -> bool {
matches!(*self, NotAnOutlier)
}
/// Checks if the data point is labeled as a "severe" outlier
pub fn is_severe(&self) -> bool {
matches!(*self, HighSevere | LowSevere)
}
}
/// Classifies the sample, and returns a labeled sample.
///
/// - Time: `O(N log N) where N = length`
pub fn classify<A>(sample: &Sample<A>) -> LabeledSample<'_, A>
where
A: Float,
usize: cast::From<A, Output = Result<usize, cast::Error>>,
{
let (q1, _, q3) = sample.percentiles().quartiles();
let iqr = q3 - q1;
// Mild
let k_m = A::cast(1.5_f32);
// Severe
let k_s = A::cast(3);
LabeledSample {
fences: (
q1 - k_s * iqr,
q1 - k_m * iqr,
q3 + k_m * iqr,
q3 + k_s * iqr,
),
sample,
}
}