← Back
Editing: Loda.php
<?php namespace Rubix\ML\AnomalyDetectors; use Tensor\Matrix; use Rubix\ML\Online; use Rubix\ML\Learner; use Rubix\ML\DataType; use Rubix\ML\Estimator; use Rubix\ML\Persistable; use Rubix\ML\EstimatorType; use Rubix\ML\Helpers\Stats; use Rubix\ML\Helpers\Params; use Rubix\ML\Datasets\Dataset; use Rubix\ML\Traits\AutotrackRevisions; use Rubix\ML\Specifications\DatasetIsNotEmpty; use Rubix\ML\Specifications\SpecificationChain; use Rubix\ML\Specifications\DatasetHasDimensionality; use Rubix\ML\Specifications\SamplesAreCompatibleWithEstimator; use Rubix\ML\Exceptions\InvalidArgumentException; use Rubix\ML\Exceptions\RuntimeException; use function Rubix\ML\linspace; use function count; use function is_null; use function array_slice; use function array_fill; use function round; use function min; use function max; use function log; use function sqrt; use const Rubix\ML\LOG_EPSILON; /** * Loda * * *Lightweight Online Detector of Anomalies* uses a series of sparse random * projection vectors to produce scalar inputs to an ensemble of unique * one-dimensional equi-width histograms. The histograms are then used to estimate * the probability density of an unknown sample during inference. * * References: * [1] T. Pevný. (2015). Loda: Lightweight on-line detector of anomalies. * [2] L. Birg´e et al. (2005). How Many Bins Should Be Put In A Regular Histogram. * * @category Machine Learning * @package Rubix/ML * @author Andrew DalPino */ class Loda implements Estimator, Learner, Online, Scoring, Persistable { use AutotrackRevisions; /** * The minimum number of histogram bins. * * @var int */ protected const MIN_BINS = 3; /** * The minimum dimensionality required to produce sparse projections. * * @var int */ protected const MIN_SPARSE_DIMENSIONS = 3; /** * The proportion of outliers that are assumed to be present in the training set. * * @var float */ protected float $contamination; /** * The number of projection/histogram pairs in the ensemble. * * @var positive-int */ protected int $estimators; /** * The number of bins in each equi-width histogram. * * @var int|null */ protected ?int $bins = null; /** * Should we calculate the equi-width bin count on the fly? * * @var bool */ protected bool $fitBins; /** * The sparse random projection matrix. * * @var Matrix|null */ protected ?Matrix $r = null; /** * The edges and bin counts of each histogram. * * @var array{list<float>,list<int<0,max>>}|mixed[] */ protected array $histograms = [ // ]; /** * The minimum negative log likelihood score necessary to flag an anomaly. * * @var float|null */ protected ?float $threshold; /** * The number of samples that have been learned so far. * * @var int */ protected int $n = 0; /** * @param float $contamination * @param int $estimators * @param int|null $bins * @throws InvalidArgumentException */ public function __construct(float $contamination = 0.1, int $estimators = 100, ?int $bins = null) { if ($contamination < 0.0 or $contamination > 0.5) { throw new InvalidArgumentException('Contamination must be' . " between 0 and 0.5, $contamination given."); } if ($estimators < 1) { throw new InvalidArgumentException('Number of estimators' . " must be greater than 0, $estimators given."); } if (isset($bins) and $bins < self::MIN_BINS) { throw new InvalidArgumentException('Bins must be greater' . ' than ' . self::MIN_BINS . ", $bins given."); } $this->contamination = $contamination; $this->estimators = $estimators; $this->bins = $bins; $this->fitBins = is_null($bins); } /** * Return the integer encoded estimator type. * * @internal * * @return EstimatorType */ public function type() : EstimatorType { return EstimatorType::anomalyDetector(); } /** * Return the data types that the estimator is compatible with. * * @internal * * @return list<DataType> */ public function compatibility() : array { return [ DataType::continuous(), ]; } /** * Return the settings of the hyper-parameters in an associative array. * * @internal * * @return mixed[] */ public function params() : array { return [ 'contamination' => $this->contamination, 'estimators' => $this->estimators, 'bins' => $this->bins, ]; } /** * Has the learner been trained? * * @return bool */ public function trained() : bool { return $this->r and $this->histograms and $this->threshold; } /** * Train the learner with a dataset. * * @param Dataset $dataset */ public function train(Dataset $dataset) : void { SpecificationChain::with([ new DatasetIsNotEmpty($dataset), new SamplesAreCompatibleWithEstimator($dataset, $this), ])->check(); [$m, $n] = $dataset->shape(); if ($this->fitBins) { $this->bins = max(self::MIN_BINS, (int) round(log($m, 2.0)) + 1); } $this->r = Matrix::gaussian($n, $this->estimators); if ($n >= self::MIN_SPARSE_DIMENSIONS) { $mask = Matrix::rand($n, $this->estimators) ->less(sqrt($n) / $n); $this->r = $this->r->multiply($mask); } $projections = Matrix::quick($dataset->samples()) ->matmul($this->r) ->transpose() ->asArray(); foreach ($projections as $values) { $min = (float) min($values); $max = (float) max($values); $edges = linspace($min, $max, $this->bins + 1); $edges = array_slice($edges, 1, -1); $edges[] = INF; $counts = array_fill(0, count($edges), 0); foreach ($values as $value) { foreach ($edges as $k => $edge) { if ($value <= $edge) { ++$counts[$k]; continue 2; } } } $this->histograms[] = [$edges, $counts]; } $this->n = $m; $densities = $this->densities($projections); $this->threshold = Stats::quantile($densities, 1.0 - $this->contamination); } /** * Perform a partial train on the learner. * * @param Dataset $dataset */ public function partial(Dataset $dataset) : void { if (!$this->r or !$this->histograms or !$this->threshold) { $this->train($dataset); return; } SpecificationChain::with([ new DatasetIsNotEmpty($dataset), new SamplesAreCompatibleWithEstimator($dataset, $this), new DatasetHasDimensionality($dataset, $this->r->m()), ])->check(); $projections = Matrix::quick($dataset->samples()) ->matmul($this->r) ->transpose() ->asArray(); foreach ($projections as $i => $values) { [$edges, $counts] = $this->histograms[$i]; foreach ($values as $value) { foreach ($edges as $k => $edge) { if ($value <= $edge) { ++$counts[$k]; continue 2; } } } $this->histograms[$i] = [$edges, $counts]; } $n = $dataset->numSamples(); $this->n += $n; $densities = $this->densities($projections); $threshold = Stats::quantile($densities, 1.0 - $this->contamination); $beta = $n / $this->n; $this->threshold = (1.0 - $beta) * $this->threshold + $beta * $threshold; } /** * Make predictions from a dataset. * * @param Dataset $dataset * @return list<int> */ public function predict(Dataset $dataset) : array { return array_map([$this, 'decide'], $this->score($dataset)); } /** * Return the anomaly scores assigned to the samples in a dataset. * * @param Dataset $dataset * @throws RuntimeException * @return list<float> */ public function score(Dataset $dataset) : array { if (!$this->r or !$this->histograms or !$this->threshold) { throw new RuntimeException('Estimator has not been trained.'); } DatasetHasDimensionality::with($dataset, $this->r->m())->check(); $projections = Matrix::quick($dataset->samples()) ->matmul($this->r) ->transpose() ->asArray(); return $this->densities($projections); } /** * Estimate the probability density function of each 1-dimensional projection using the histograms * created during training. * * @param list<list<float>> $projections * @return list<float> */ protected function densities(array $projections) : array { $n = count(current($projections) ?: []); $densities = array_fill(0, $n, 0.0); foreach ($projections as $i => $values) { [$edges, $counts] = $this->histograms[$i]; foreach ($values as $j => $value) { foreach ($edges as $k => $edge) { if ($value <= $edge) { $count = $counts[$k]; $densities[$j] += $count > 0 ? -log($count / $this->n) : -LOG_EPSILON; break; } } } } foreach ($densities as &$density) { $density /= $this->estimators; } return $densities; } /** * The decision function. * * @param float $score * @return int */ protected function decide(float $score) : int { return $score > $this->threshold ? 1 : 0; } /** * Return the string representation of the object. * * @internal * * @return string */ public function __toString() : string { return 'Loda (' . Params::stringify($this->params()) . ')'; } }
Save File
Cancel