1.0)) { throw new InvalidArgumentException('Ratio must be' . " between 0 and 1, $ratio given."); } if (isset($contamination) and ($contamination < 0.0 or $contamination > 0.5)) { throw new InvalidArgumentException('Contamination must be' . " between 0 and 0.5, $contamination given."); } $this->estimators = $estimators; $this->ratio = $ratio; $this->contamination = $contamination; } /** * Return the 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 */ public function compatibility() : array { return [ DataType::categorical(), DataType::continuous(), ]; } /** * Return the settings of the hyper-parameters in an associative array. * * @internal * * @return mixed[] */ public function params() : array { return [ 'estimators' => $this->estimators, 'ratio' => $this->ratio, 'contamination' => $this->contamination, ]; } /** * Has the learner been trained? * * @return bool */ public function trained() : bool { return $this->threshold and $this->trees; } /** * 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(); $n = $dataset->numSamples(); $p = $this->ratio ? max(self::MIN_SUBSAMPLE, (int) round($this->ratio * $n)) : min(self::DEFAULT_SUBSAMPLE, $n); $maxHeight = (int) max(1, round(log($p, 2.0))); $this->trees = []; while (count($this->trees) < $this->estimators) { $tree = new ITree($maxHeight); $subset = $dataset->randomSubset($p); $tree->grow($subset); $this->trees[] = $tree; } $this->delta = $this->estimators * Depth::c($p); if (isset($this->contamination)) { $scores = array_map([$this, 'isolationScore'], $dataset->samples()); $threshold = Stats::quantile($scores, 1.0 - $this->contamination); } $this->threshold = $threshold ?? self::DEFAULT_THRESHOLD; $this->featureCount = $dataset->numFeatures(); } /** * Make predictions from a dataset. * * @param Dataset $dataset * @throws RuntimeException * @return list */ public function predict(Dataset $dataset) : array { if (empty($this->trees) or !$this->featureCount) { throw new RuntimeException('Estimator has not been trained.'); } DatasetHasDimensionality::with($dataset, $this->featureCount)->check(); return array_map([$this, 'predictSample'], $dataset->samples()); } /** * Predict a single sample and return the result. * * @internal * * @param list $sample * @return int */ public function predictSample(array $sample) : int { return $this->isolationScore($sample) > $this->threshold ? 1 : 0; } /** * Return the anomaly scores assigned to the samples in a dataset. * * @param Dataset $dataset * @throws RuntimeException * @return list */ public function score(Dataset $dataset) : array { if (empty($this->trees) or !$this->featureCount) { throw new RuntimeException('Estimator has not been trained.'); } DatasetHasDimensionality::with($dataset, $this->featureCount)->check(); return array_map([$this, 'isolationScore'], $dataset->samples()); } /** * Return the isolation score of a sample. * * @param list $sample * @return float */ protected function isolationScore(array $sample) : float { $depth = 0.0; foreach ($this->trees as $tree) { $node = $tree->search($sample); $depth += $node ? $node->depth() : EPSILON; } $depth /= $this->delta; return 2.0 ** -$depth; } /** * Return the string representation of the object. * * @internal * * @return string */ public function __toString() : string { return 'Isolation Forest (' . Params::stringify($this->params()) . ')'; } }