*/ abstract class DecisionTree implements BinaryTree, IteratorAggregate { /** * The maximum number of characters before a node label is truncated. * * @var int */ protected const MAX_NODE_LABEL_LENGTH = 30; /** * The maximum depth of a branch before it is forced to terminate. * * @var int */ protected int $maxHeight; /** * The maximum number of samples that a leaf node can contain. * * @var int */ protected int $maxLeafSize; /** * The minimum increase in purity necessary for a node not to be post pruned. * * @var float */ protected float $minPurityIncrease; /** * The root node of the tree. * * @var Split|null */ protected ?Split $root = null; /** * The number of feature columns in the training set. * * @var int<0,max> */ protected ?int $featureCount = null; /** * Return the brightness of a color in hex format. * * @param string $color * @return int */ protected static function brightness(string $color) : int { $brightness = hexdec(substr($color, 0, 2)); $brightness += hexdec(substr($color, 2, 2)); $brightness += hexdec(substr($color, 4, 2)); $brightness /= 3; return (int) round($brightness); } /** * @internal * * @param int $maxHeight * @param int $maxLeafSize * @param float $minPurityIncrease * @throws \InvalidArgumentException */ public function __construct( int $maxHeight, int $maxLeafSize, float $minPurityIncrease ) { if ($maxHeight < 1) { throw new InvalidArgumentException('Tree must have' . " depth greater than 0, $maxHeight given."); } if ($maxLeafSize < 1) { throw new InvalidArgumentException('At least one sample is' . " required to form a leaf node, $maxLeafSize given."); } if ($minPurityIncrease < 0.0) { throw new InvalidArgumentException('Min purity increase' . " must be greater than 0, $minPurityIncrease given."); } $this->maxHeight = $maxHeight; $this->maxLeafSize = $maxLeafSize; $this->minPurityIncrease = $minPurityIncrease; } /** * Return the number of levels in the tree. * * @return int */ public function height() : int { return $this->root ? $this->root->height() : 0; } /** * Return a factor that quantifies the skewness of the distribution of nodes in the tree. * * @return int */ public function balance() : int { return $this->root ? $this->root->balance() : 0; } /** * Is the tree bare? * * @internal * * @return bool */ public function bare() : bool { return !$this->root; } /** * Insert a root node and recursively split the dataset a terminating condition is met. * * @internal * * @param Labeled $dataset * @throws \InvalidArgumentException */ public function grow(Labeled $dataset) : void { $n = $dataset->numFeatures(); $this->featureCount = $n; $this->root = $this->split($dataset); $stack = [[$this->root, 0]]; while ($stack) { [$current, $depth] = array_pop($stack); [$left, $right] = $current->subsets(); $current->cleanup(); ++$depth; if ($left->empty() or $right->empty()) { $node = $this->terminate($left->merge($right)); $current->attachLeft($node); $current->attachRight($node); continue; } if ($depth >= $this->maxHeight) { $current->attachLeft($this->terminate($left)); $current->attachRight($this->terminate($right)); continue; } if ($left->numSamples() > $this->maxLeafSize) { $leftNode = $this->split($left); } else { $leftNode = $this->terminate($left); } if ($right->numSamples() > $this->maxLeafSize) { $rightNode = $this->split($right); } else { $rightNode = $this->terminate($right); } $current->attachLeft($leftNode); $current->attachRight($rightNode); if ($current->purityIncrease() >= $this->minPurityIncrease) { if ($leftNode instanceof Split) { $stack[] = [$leftNode, $depth]; } if ($rightNode instanceof Split) { $stack[] = [$rightNode, $depth]; } } else { if ($leftNode instanceof Split) { $current->attachLeft($this->terminate($left)); } if ($rightNode instanceof Split) { $current->attachRight($this->terminate($right)); } } } } /** * Search the decision tree for a leaf node and return it. * * @internal * * @param list $sample * @return Outcome|null */ public function search(array $sample) : ?Outcome { $current = $this->root; while ($current) { if ($current instanceof Split) { $value = $current->value(); if (is_string($value)) { if ($sample[$current->column()] === $value) { $current = $current->left(); } else { $current = $current->right(); } } else { if ($sample[$current->column()] <= $value) { $current = $current->left(); } else { $current = $current->right(); } } continue; } if ($current instanceof Outcome) { return $current; } } return null; } /** * Return the importance scores of each feature column of the training set. * * @throws \RuntimeException * @return float[] */ public function featureImportances() : array { if ($this->bare() or !$this->featureCount) { throw new RuntimeException('Tree has not been constructed.'); } $importances = array_fill(0, $this->featureCount, 0.0); foreach ($this as $node) { if ($node instanceof Split) { $importances[$node->column()] += $node->purityIncrease(); } } return $importances; } /** * Return an iterator for all the nodes in the tree starting at the root and traversing depth first. * * @return \Generator */ public function getIterator() : Traversable { $stack = [$this->root]; while ($current = array_pop($stack)) { yield $current; if ($current instanceof HasBinaryChildren) { foreach ($current->children() as $child) { $stack[] = $child; } } } } /** * Print a representation of the decision tree suitable to render with the * graphviz tool. For example, writing it to graph.dot then executing: * * dot -Tpng graph.dot * * @param string[]|null $featureNames * @param int $maxDepth * @throws RuntimeException * @return Encoding */ public function exportGraphviz(?array $featureNames = null, ?int $maxDepth = null) : Encoding { if (!$this->root) { throw new RuntimeException('Tree has not been constructed, train learner first.'); } $carry = 'digraph Tree {' . PHP_EOL; $carry .= ' node [shape=box, fontname=helvetica];' . PHP_EOL; $carry .= ' edge [fontname=helvetica];' . PHP_EOL; $nodeCounter = 0; $this->_exportGraphviz($carry, $nodeCounter, $this->root, $maxDepth, $featureNames); $carry .= '}'; return new Encoding($carry); } /** * Find a split point for a given subset of the training set. * * @param Labeled $dataset * @return Split */ abstract protected function split(Labeled $dataset) : Split; /** * Terminate a branch with an outcome node. * * @param Labeled $dataset * @return Outcome */ abstract protected function terminate(Labeled $dataset); /** * Calculate the impurity of a set of labels. * * @param list $labels * @return float */ abstract protected function impurity(array $labels) : float; /** * Calculate the impurity of a given split. * * @param array{Labeled,Labeled} $subsets * @return float */ protected function splitImpurity(array $subsets) : float { $n = array_sum(array_map('count', $subsets)); $impurity = 0.0; foreach ($subsets as $dataset) { $nHat = $dataset->numSamples(); if ($nHat === 0) { continue; } $impurity += ($nHat / $n) * $this->impurity($dataset->labels()); } return $impurity; } /** * Recursive function to print out the decision rule at each node using preorder traversal. * * @param string $carry * @param int $nodesCounter * @param BinaryNode $node * @param int $maxDepth * @param string[]|null $featureNames * @param int|null $parentId * @param int|null $leftRight * @param int $depth */ protected function _exportGraphviz( string &$carry, int &$nodesCounter, BinaryNode $node, ?int $maxDepth = null, ?array $featureNames = null, ?int $parentId = null, ?int $leftRight = null, int $depth = 0 ) : void { ++$depth; $thisNode = $nodesCounter++; if ($depth === $maxDepth) { $carry .= " N$thisNode [label=\"...\"];" . PHP_EOL; } elseif ($node instanceof Split) { $column = $node->column(); $value = $node->value(); $carry .= " N$thisNode [label=\""; if ($featureNames) { $name = $featureNames[$column]; if (strlen($name) > self::MAX_NODE_LABEL_LENGTH) { $name = substr($name, 0, self::MAX_NODE_LABEL_LENGTH) . '...'; } $carry .= $name; } else { $carry .= "Feature {$column}"; } $operator = is_string($value) ? '==' : '<='; $carry .= " $operator {$value}\""; $carry .= '];' . PHP_EOL; if ($node->left() !== null) { $this->_exportGraphviz($carry, $nodesCounter, $node->left(), $maxDepth, $featureNames, $thisNode, 1, $depth); } if ($node->right() !== null) { $this->_exportGraphviz($carry, $nodesCounter, $node->right(), $maxDepth, $featureNames, $thisNode, 2, $depth); } } elseif ($node instanceof Outcome) { $outcome = $node->outcome(); $impurity = $node->impurity(); $carry .= " N$thisNode [label=\"{$outcome}"; if ($impurity > 0.0) { $carry .= "\\nImpurity: {$impurity}"; } $carry .= '"'; if (is_string($outcome)) { $fillColor = substr(hash('crc32b', $outcome), -6); if (self::brightness($fillColor) > 128) { $fontColor = '000000'; } else { $fontColor = 'ffffff'; } } else { $fillColor = 'cccccc'; $fontColor = '000000'; } $carry .= ',style="rounded,filled"'; $carry .= ",fontcolor=\"#{$fontColor}\""; $carry .= ",fillcolor=\"#{$fillColor}\""; $carry .= ']' . PHP_EOL; } if ($parentId !== null) { $carry .= " N$parentId -> N$thisNode"; if ($parentId === 0) { $carry .= ' [labeldistance=2.5'; if ($leftRight === 1) { $carry .= ',labelangle=45'; $carry .= ',headlabel="True"'; } else { $carry .= ',labelangle=-45'; $carry .= ',headlabel="False"'; } $carry .= ']'; } $carry .= ';' . PHP_EOL; } } }