*/ 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 validation score to wait before considering an early stop. * * @var positive-int */ protected int $window; /** * The proportion of training samples to use for validation and progress monitoring. * * @var float */ protected float $holdOut; /** * The function that computes the loss associated with an erroneous activation during training. * * @var RegressionLoss */ protected RegressionLoss $costFn; /** * The metric used to score the generalization performance of the model during training. * * @var Metric */ protected Metric $metric; /** * The underlying neural network instance. * * @var FeedForward|null */ protected ?FeedForward $network = null; /** * The validation scores at each epoch from the last training session. * * @var float[]|null */ protected ?array $scores = null; /** * The loss at each epoch from the last training session. * * @var float[]|null */ protected ?array $losses = null; /** * @param Hidden[] $hiddenLayers * @param int $batchSize * @param Optimizer|null $optimizer * @param float $l2Penalty * @param int $epochs * @param float $minChange * @param int $window * @param float $holdOut * @param RegressionLoss|null $costFn * @param Metric|null $metric * @throws InvalidArgumentException */ public function __construct( array $hiddenLayers = [], int $batchSize = 128, ?Optimizer $optimizer = null, float $l2Penalty = 1e-4, int $epochs = 1000, float $minChange = 1e-4, int $window = 5, float $holdOut = 0.1, ?RegressionLoss $costFn = null, ?Metric $metric = null ) { foreach ($hiddenLayers as $layer) { if (!$layer instanceof Hidden) { throw new InvalidArgumentException('Hidden layer' . ' must implement the Hidden interface.'); } } 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."); } if ($holdOut < 0.0 or $holdOut > 0.5) { throw new InvalidArgumentException('Hold out ratio must be' . " between 0 and 0.5, $holdOut given."); } if ($metric) { EstimatorIsCompatibleWithMetric::with($this, $metric)->check(); } $this->hiddenLayers = $hiddenLayers; $this->batchSize = $batchSize; $this->optimizer = $optimizer ?? new Adam(); $this->l2Penalty = $l2Penalty; $this->epochs = $epochs; $this->minChange = $minChange; $this->window = $window; $this->holdOut = $holdOut; $this->costFn = $costFn ?? new LeastSquares(); $this->metric = $metric ?? new RMSE(); } /** * 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 [ 'hidden layers' => $this->hiddenLayers, 'batch size' => $this->batchSize, 'optimizer' => $this->optimizer, 'l2 penalty' => $this->l2Penalty, 'epochs' => $this->epochs, 'min change' => $this->minChange, 'window' => $this->window, 'hold out' => $this->holdOut, 'cost fn' => $this->costFn, 'metric' => $this->metric, ]; } /** * 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, 'score' => $this->scores[$epoch] ?? null, 'loss' => $loss, ]; } } /** * Return the validation score at each epoch. * * @return float[]|null */ public function scores() : ?array { return $this->scores; } /** * Return the training loss at each epoch. * * @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(); $hiddenLayers = $this->hiddenLayers; $hiddenLayers[] = new Dense(1, $this->l2Penalty, true, new Xavier2()); $this->network = new FeedForward( new Placeholder1D($dataset->numFeatures()), $hiddenLayers, new Continuous($this->costFn), $this->optimizer ); $this->network->initialize(); $this->partial($dataset); } /** * Train the network using mini-batch gradient descent with backpropagation. * * @param \Rubix\ML\Datasets\Labeled $dataset * @throws RuntimeException */ 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"); } [$testing, $training] = $dataset->randomize()->split($this->holdOut); [$minScore, $maxScore] = $this->metric->range()->list(); $bestScore = $minScore; $bestEpoch = $numWorseEpochs = 0; $loss = 0.0; $score = $snapshot = null; $prevLoss = INF; $this->scores = $this->losses = []; for ($epoch = 1; $epoch <= $this->epochs; ++$epoch) { $batches = $training->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 (is_nan($loss)) { if ($this->logger) { $this->logger->warning('Numerical instability detected'); } break; } if (!$testing->empty()) { $predictions = $this->predict($testing); $score = $this->metric->score($predictions, $testing->labels()); $this->scores[$epoch] = $score; } if ($this->logger) { $lossDirection = $loss < $prevLoss ? '↓' : '↑'; $message = "Epoch: $epoch, " . "{$this->costFn}: $loss, " . "Loss Change: {$lossDirection}{$lossChange}, " . "{$this->metric}: " . ($score ?? 'N/A'); $this->logger->info($message); } if (isset($score)) { if ($score >= $maxScore) { break; } if ($score > $bestScore) { $bestScore = $score; $bestEpoch = $epoch; $snapshot = Snapshot::take($this->network); $numWorseEpochs = 0; } else { ++$numWorseEpochs; } if ($numWorseEpochs >= $this->window) { break; } } if ($lossChange < $this->minChange) { break; } $prevLoss = $loss; } if ($snapshot and (end($this->scores) < $bestScore or is_nan($loss))) { $snapshot->restore(); if ($this->logger) { $this->logger->info("Model state restored to epoch $bestEpoch"); } } if ($this->logger) { $this->logger->info('Training complete'); } } /** * Feed a sample through the network and make a prediction based on the * activation of the output neuron. * * @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; } /** * Export the network architecture as a graph in dot format. * * @throws RuntimeException * @return Encoding */ public function exportGraphviz() : Encoding { if (!$this->network) { throw new RuntimeException('Must train network first.'); } return $this->network->exportGraphviz(); } /** * 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['scores'], $properties['logger']); return $properties; } /** * Return the string representation of the object. * * @internal * * @return string */ public function __toString() : string { return 'MLP Regressor (' . Params::stringify($this->params()) . ')'; } }