batchSize = $batchSize; $this->optimizer = $optimizer ?? new Adam(); $this->l2Penalty = $l2Penalty; $this->epochs = $epochs; $this->minChange = $minChange; $this->window = $window; $this->costFn = $costFn ?? new CrossEntropy(); } /** * Return the estimator type. * * @internal * * @return EstimatorType */ public function type() : EstimatorType { return EstimatorType::classifier(); } /** * 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 $this->network and $this->classes; } /** * 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 learner with a dataset. * * @param \Rubix\ML\Datasets\Labeled $dataset */ public function train(Dataset $dataset) : void { SpecificationChain::with([ new DatasetIsLabeled($dataset), new DatasetIsNotEmpty($dataset), new LabelsAreCompatibleWithLearner($dataset, $this), ])->check(); $classes = $dataset->possibleOutcomes(); $this->network = new FeedForward( new Placeholder1D($dataset->numFeatures()), [new Dense(1, $this->l2Penalty, true, new Xavier1())], new Binary($classes, $this->costFn), $this->optimizer ); $this->network->initialize(); $this->classes = $classes; $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 instability 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 * @return list */ public function predict(Dataset $dataset) : array { return array_map('Rubix\ML\argmax', $this->proba($dataset)); } /** * Estimate the joint probabilities for each possible outcome. * * @param Dataset $dataset * @throws RuntimeException * @return list> */ public function proba(Dataset $dataset) : array { if (!$this->network or !$this->classes) { throw new RuntimeException('Estimator has not been trained.'); } DatasetHasDimensionality::with($dataset, $this->network->input()->width())->check(); [$classA, $classB] = $this->classes; $activations = $this->network->infer($dataset); $activations = array_column($activations->asArray(), 0); $probabilities = []; foreach ($activations as $activation) { $probabilities[] = [ $classA => 1.0 - $activation, $classB => $activation, ]; } return $probabilities; } /** * 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 not found.'); } 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 'Logistic Regression (' . Params::stringify($this->params()) . ')'; } }