*/ protected int $k; /** * The size of each mini batch in samples. * * @var positive-int */ protected int $batchSize; /** * The maximum number of iterations to run until the algorithm terminates. * * @var int */ protected int $epochs; /** * The minimum change in the inertia for training to continue. * * @var float */ protected float $minChange; /** * The number of epochs without improvement in the training loss to wait before considering an early stop. * * @var int */ protected int $window; /** * The distance function to use when computing the distances. * * @var Distance */ protected Distance $kernel; /** * The cluster centroid seeder. * * @var Seeder */ protected Seeder $seeder; /** * The computed centroid vectors of the training data. * * @var list> */ protected array $centroids = [ // ]; /** * The number of training samples contained within each cluster centroid. * * @var int[] */ protected array $sizes = [ // ]; /** * The loss at each epoch from the last training session. * * @var float[]|null */ protected ?array $losses = null; /** * @param int $k * @param int $batchSize * @param int $epochs * @param float $minChange * @param int $window * @param Distance|null $kernel * @param Seeder|null $seeder * @throws InvalidArgumentException */ public function __construct( int $k, int $batchSize = 128, int $epochs = 1000, float $minChange = 1e-4, int $window = 5, ?Distance $kernel = null, ?Seeder $seeder = null ) { if ($k < 1) { throw new InvalidArgumentException('K must be greater' . " than 0, $k given."); } if ($batchSize < 1) { throw new InvalidArgumentException('Batch size must be' . " greater than 0, $batchSize 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->k = $k; $this->batchSize = $batchSize; $this->epochs = $epochs; $this->minChange = $minChange; $this->window = $window; $this->kernel = $kernel ?? new Euclidean(); $this->seeder = $seeder ?? new PlusPlus($kernel); } /** * Return the estimator type. * * @return EstimatorType */ public function type() : EstimatorType { return EstimatorType::clusterer(); } /** * Return the data types that the estimator is compatible with. * * @return list */ public function compatibility() : array { return [ DataType::continuous(), ]; } /** * Return the settings of the hyper-parameters in an associative array. * * @return mixed[] */ public function params() : array { return [ 'k' => $this->k, 'batch size' => $this->batchSize, 'epochs' => $this->epochs, 'min change' => $this->minChange, 'window' => $this->window, 'kernel' => $this->kernel, 'seeder' => $this->seeder, ]; } /** * Has the learner been trained? * * @return bool */ public function trained() : bool { return !empty($this->centroids); } /** * Return the computed cluster centroids of the training data. * * @return list> */ public function centroids() : array { return $this->centroids; } /** * Return the number of training samples each centroid is responsible for. * * @return int[] */ public function sizes() : array { return $this->sizes; } /** * 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; } /** * 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(); /** @var list> $seeds */ $seeds = $this->seeder->seed($dataset, $this->k); $this->centroids = $seeds; $sizes = array_fill(0, $this->k, 0); $sizes[0] = $dataset->numSamples(); $this->sizes = $sizes; $this->partial($dataset); } /** * Perform a partial train on the learner. * * @param Dataset $dataset */ public function partial(Dataset $dataset) : void { if (empty($this->centroids) or empty($this->sizes)) { $this->train($dataset); return; } SpecificationChain::with([ new DatasetIsNotEmpty($dataset), new SamplesAreCompatibleWithEstimator($dataset, $this), new DatasetHasDimensionality($dataset, count(current($this->centroids))), ])->check(); if ($this->logger) { $this->logger->info("Training $this"); } $labels = array_fill(0, $dataset->numSamples(), 0); $dataset = Labeled::quick($dataset->samples(), $labels); $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 $i => &$batch) { $assignments = array_map([$this, 'predictSample'], $batch->samples()); $labels = $batch->labels(); foreach ($assignments as $j => $cluster) { $expected = $labels[$j]; if ($cluster !== $expected) { $labels[$j] = $cluster; --$this->sizes[$expected]; ++$this->sizes[$cluster]; } } $batch = Labeled::quick($batch->samples(), $labels); $loss += $this->inertia($batch->samples(), $labels); foreach ($batch->stratifyByLabel() as $cluster => $stratum) { $centroid = &$this->centroids[$cluster]; $means = array_map([Stats::class, 'mean'], $stratum->features()); $weight = 1.0 / (1 + $this->sizes[$cluster]); foreach ($centroid as $i => &$mean) { $mean = (1.0 - $weight) * $mean + $weight * $means[$i]; } } } $loss /= $dataset->numSamples(); $lossChange = abs($prevLoss - $loss); $this->losses[$epoch] = $loss; if ($this->logger) { $lossDirection = $loss < $prevLoss ? '↓' : '↑'; $message = "Epoch: $epoch, " . "Inertia: $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; } $dataset = Labeled::stack($batches); $prevLoss = $loss; } if ($this->logger) { $this->logger->info('Training complete'); } } /** * Cluster the dataset by assigning a label to each sample. * * @param Dataset $dataset * @throws RuntimeException * @return list */ public function predict(Dataset $dataset) : array { if (!$this->centroids) { throw new RuntimeException('Estimator has not been trained.'); } DatasetHasDimensionality::with($dataset, count(current($this->centroids)))->check(); return array_map([$this, 'predictSample'], $dataset->samples()); } /** * Label a given sample based on its distance from a particular centroid. * * @internal * * @param list $sample * @return int */ public function predictSample(array $sample) : int { $bestDistance = INF; $bestCluster = -1; foreach ($this->centroids as $cluster => $centroid) { $distance = $this->kernel->compute($sample, $centroid); if ($distance < $bestDistance) { $bestDistance = $distance; $bestCluster = $cluster; } } return (int) $bestCluster; } /** * Estimate the joint probabilities for each possible outcome. * * @param Dataset $dataset * @throws RuntimeException * @return list */ public function proba(Dataset $dataset) : array { if (!$this->centroids) { throw new RuntimeException('Estimator has not been trained.'); } DatasetHasDimensionality::with($dataset, count(current($this->centroids)))->check(); return array_map([$this, 'probaSample'], $dataset->samples()); } /** * Return the membership of a sample to each of the k centroids. * * @internal * * @param list $sample * @return float[] */ public function probaSample(array $sample) : array { $distances = $dist = []; foreach ($this->centroids as $centroid) { $distances[] = $this->kernel->compute($sample, $centroid) ?: EPSILON; } foreach ($distances as $distanceA) { $sigma = 0.0; foreach ($distances as $distanceB) { $sigma += $distanceA / $distanceB; } $dist[] = 1.0 / $sigma; } return $dist; } /** * Calculate the average sum of distances between all samples and their closest * centroid. * * @param list> $samples * @param list $labels * @return float */ protected function inertia(array $samples, array $labels) : float { $inertia = 0.0; foreach ($samples as $i => $sample) { $centroid = $this->centroids[$labels[$i]]; $inertia += $this->kernel->compute($sample, $centroid); } return $inertia; } /** * 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 'K Means (' . Params::stringify($this->params()) . ')'; } }