*/ protected int $epochs; /** * The minimum change in the training loss necessary to continue training. * * @var float */ protected float $minChange; /** * The number of epochs without improvement in the training loss to wait before considering an early stop. * * @var positive-int */ protected int $window; /** * The function that computes the loss associated with an erroneous * activation during training. * * @var RegressionLoss */ protected RegressionLoss $costFn; /** * The underlying neural network instance. * * @var FeedForward|null */ protected ?FeedForward $network = null; /** * The loss at each epoch from the last training session. * * @var float[]|null */ protected ?array $losses = null; /** * @param int $batchSize * @param Optimizer|null $optimizer * @param float $l2Penalty * @param int $epochs * @param float $minChange * @param int $window * @param RegressionLoss|null $costFn * @throws InvalidArgumentException */ public function __construct( int $batchSize = 128, ?Optimizer $optimizer = null, float $l2Penalty = 1e-4, int $epochs = 1000, float $minChange = 1e-4, int $window = 5, ?RegressionLoss $costFn = null ) { if ($batchSize < 1) { throw new InvalidArgumentException('Batch size must be' . " greater than 0, $batchSize given."); } if ($l2Penalty < 0.0) { throw new InvalidArgumentException('L2 Penalty must be' . " greater than 0, $l2Penalty given."); } if ($epochs < 0) { throw new InvalidArgumentException('Number of epochs' . " must be greater than 0, $epochs given."); } if ($minChange < 0.0) { throw new InvalidArgumentException('Minimum change must be' . " greater than 0, $minChange given."); } if ($window < 1) { throw new InvalidArgumentException('Window must be' . " greater than 0, $window given."); } $this->batchSize = $batchSize; $this->optimizer = $optimizer ?? new Adam(); $this->l2Penalty = $l2Penalty; $this->epochs = $epochs; $this->minChange = $minChange; $this->window = $window; $this->costFn = $costFn ?? new LeastSquares(); } /** * Return the estimator type. * * @internal * * @return EstimatorType */ public function type() : EstimatorType { return EstimatorType::regressor(); } /** * Return the data types that the estimator is compatible with. * * @internal * * @return list */ 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 [ 'batch size' => $this->batchSize, 'optimizer' => $this->optimizer, 'l2 penalty' => $this->l2Penalty, 'epochs' => $this->epochs, 'min change' => $this->minChange, 'window' => $this->window, 'cost fn' => $this->costFn, ]; } /** * Has the learner been trained? * * @return bool */ public function trained() : bool { return isset($this->network); } /** * Return an iterable progress table with the steps from the last training session. * * @return Generator */ public function steps() : Generator { if (!$this->losses) { return; } foreach ($this->losses as $epoch => $loss) { yield [ 'epoch' => $epoch, 'loss' => $loss, ]; } } /** * Return the loss for each epoch from the last training session. * * @return float[]|null */ public function losses() : ?array { return $this->losses; } /** * Return the underlying neural network instance or null if not trained. * * @return FeedForward|null */ public function network() : ?FeedForward { return $this->network; } /** * Train the estimator with a dataset. * * @param \Rubix\ML\Datasets\Labeled $dataset */ public function train(Dataset $dataset) : void { DatasetIsNotEmpty::with($dataset)->check(); $this->network = new FeedForward( new Placeholder1D($dataset->numFeatures()), [new Dense(1, $this->l2Penalty, true, new Xavier2())], new Continuous($this->costFn), $this->optimizer ); $this->network->initialize(); $this->partial($dataset); } /** * Perform a partial train on the learner. * * @param \Rubix\ML\Datasets\Labeled $dataset */ public function partial(Dataset $dataset) : void { if (!$this->network) { $this->train($dataset); return; } SpecificationChain::with([ new DatasetIsLabeled($dataset), new DatasetIsNotEmpty($dataset), new SamplesAreCompatibleWithEstimator($dataset, $this), new LabelsAreCompatibleWithLearner($dataset, $this), new DatasetHasDimensionality($dataset, $this->network->input()->width()), ])->check(); if ($this->logger) { $this->logger->info("Training $this"); $numParams = number_format($this->network->numParams()); $this->logger->info("{$numParams} trainable parameters"); } $prevLoss = $bestLoss = INF; $numWorseEpochs = 0; $this->losses = []; for ($epoch = 1; $epoch <= $this->epochs; ++$epoch) { $batches = $dataset->randomize()->batch($this->batchSize); $loss = 0.0; foreach ($batches as $batch) { $loss += $this->network->roundtrip($batch); } $loss /= count($batches); $lossChange = abs($prevLoss - $loss); $this->losses[$epoch] = $loss; if ($this->logger) { $lossDirection = $loss < $prevLoss ? '↓' : '↑'; $message = "Epoch: $epoch, " . "{$this->costFn}: $loss, " . "Loss Change: {$lossDirection}{$lossChange}"; $this->logger->info($message); } if (is_nan($loss)) { if ($this->logger) { $this->logger->warning('Numerical under/overflow detected'); } break; } if ($loss <= 0.0) { break; } if ($lossChange < $this->minChange) { break; } if ($loss < $bestLoss) { $bestLoss = $loss; $numWorseEpochs = 0; } else { ++$numWorseEpochs; } if ($numWorseEpochs >= $this->window) { break; } $prevLoss = $loss; } if ($this->logger) { $this->logger->info('Training complete'); } } /** * Make predictions from a dataset. * * @param Dataset $dataset * @throws RuntimeException * @return list */ public function predict(Dataset $dataset) : array { if (!$this->network) { throw new RuntimeException('Estimator has not been trained.'); } DatasetHasDimensionality::with($dataset, $this->network->input()->width())->check(); $activations = $this->network->infer($dataset); $activations = array_column($activations->asArray(), 0); return $activations; } /** * Return the importance scores of each feature column of the training set. * * @throws RuntimeException * @return float[] */ public function featureImportances() : array { if (!$this->network) { throw new RuntimeException('Estimator has not been trained.'); } $layer = current($this->network->hidden()); if (!$layer instanceof Dense) { throw new RuntimeException('Weight layer is missing.'); } return $layer->weights() ->rowAsVector(0) ->abs() ->asArray(); } /** * Return an associative array containing the data used to serialize the object. * * @return mixed[] */ public function __serialize() : array { $properties = get_object_vars($this); unset($properties['losses'], $properties['logger']); return $properties; } /** * Return the string representation of the object. * * @internal * * @return string */ public function __toString() : string { return 'Adaline (' . Params::stringify($this->params()) . ')'; } }