<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\library\classifiers;

/**
 * Implements the logistic regression text classification algorithm using lasso
 * regression and a cyclic coordinate descent optimization step.
 *
 * This algorithm is rather slow to converge for large datasets or a large
 * number of features, but it does provide regularization in order to combat
 * over-fitting, and out-performs Naive-Bayes in tests on the same data set.
 * The algorithm augments a standard cyclic coordinate descent approach by
 * ``sleeping'' features that don't significantly change during a single step.
 * Each time an optimization step for a feature doesn't change the feature
 * weight beyond some threshold, that feature is forced to sit out the next
 * optimization round. The threshold increases over successive rounds,
 * effectively placing an upper limit on the number of iterations over all
 * features, while simultaneously limiting the number of features updated on
 * each round. This optimization speeds up convergence, but at the cost of some
 * accuracy.
 *
 * @author Shawn Tice
 */
class LassoRegression extends ClassifierAlgorithm
{
    /**
     * Level of detail to be used for logging. Higher values mean more detail.
     * @var int
     */
    public $debug = 0;
    /**
     * Threshold used to determine convergence.
     * @var float
     */
    public $epsilon = 0.001;
    /**
     * Lambda parameter to CLG algorithm.
     * @var float
     */
    public $lambda = 1.0;
    /**
     * Beta vector of feature weights resulting from the training phase. The
     * dot product of this vector with a feature vector yields the log
     * likelihood that the feature vector describes a document belonging to the
     * trained-for class.
     * @var array
     */
    public $beta;
    /**
     * An adaptation of the Zhang-Oles 2001 CLG algorithm by Genkin et al. to
     * use the Laplace prior for parameter regularization. On completion,
     * optimizes the beta vector to maximize the likelihood of the data set.
     *
     * @param object $X SparseMatrix representing the training dataset
     * @param array $y array of known labels corresponding to the rows of $X
     */
    public function train($X, $y)
    {
        $invX = new InvertedData($X);
        $this->lambda = $this->estimateLambdaNorm($invX);
        $m = $invX->rows();
        $n = $invX->columns();
        $this->beta = array_fill(0, $n, 0.0);
        $beta =& $this->beta;
        $lambda = $this->lambda;
        $d = array_fill(0, $n, 1.0);
        $r = array_fill(0, $m, 0.0);
        $converged = false;
        $drSum = 0.0;
        $rSum = 0.0;
        $change = 0.0;
        $score = 0.0;
        $minDrj = $this->epsilon;
        $prevDrj = $this->epsilon;
        $schedule = new \SplMaxHeap();
        $nextSchedule = new \SplMaxHeap();
        for ($j = 0; $j < $n; $j++)
            $schedule->insert(array($this->epsilon, $j));
        for ($k = 0; !$converged; $k++) {
            $prevR = $r;
            $var = 1;
            while (!$schedule->isEmpty()) {
                list($drj, $j) = $schedule->top();
                if ($drj < $minDrj) {
                    break;
                } else {
                    $schedule->extract();
                    $prevDrj = $drj;
                }
                $Xj = $invX->iterateColumn($j);
                list($numer, $denom) = $this->computeApproxLikelihood(
                    $Xj, $y, $r, $d[$j]);
                // Compute tentative step $dvj
                if ($beta[$j] == 0) {
                    $dvj = ($numer - $lambda) / $denom;
                    if ($dvj <= 0) {
                        $dvj = ($numer + $lambda) / $denom;
                        if ($dvj >= 0)
                            $dvj = 0;
                    }
                } else {
                    $s = $beta[$j] > 0 ? 1 : -1;
                    $dvj = ($numer - ($s * $lambda)) / $denom;
                    if ($s * ($beta[$j] + $dvj) < 0)
                        $dvj = -$beta[$j];
                }
                if ($dvj == 0) {
                    $d[$j] /= 2;
                    $nextSchedule->insert(array($this->epsilon, $j, $k));
                } else {
                    // Compute delta for beta[j], constrained to trust region.
                    $dbetaj = min(max($dvj, -$d[$j]), $d[$j]);
                    // Update our cached dot product by the delta.
                    $drj = 0.0;
                    foreach ($Xj as $cell) {
                        list($_, $i, $Xij) = $cell;
                        $dr = $dbetaj * $Xij;
                        $drj += $dr;
                        $r[$i] += $dr;
                    }
                    $drj = abs($drj);
                    $nextSchedule->insert(array($drj, $j, $k));
                    $beta[$j] += $dbetaj;
                    // Update the trust region.
                    $d[$j] = max(2 * abs($dbetaj), $d[$j] / 2);
                }
                if ($this->debug > 1) {
                    $score = 0;
                    foreach ($r as $i => $ri) {
                        $score += -log(1 + exp(-$ri * $y[$i]));
                    }
                    $score = $score - array_sum($beta);
                }
                $this->log(sprintf(
                    "itr = %3d, j = %4d (#%d), score = %6.2f, change = %6.4f",
                    $k + 1, $j, $var, $score, $change));
                $var++;
            }
            // Update $converged
            $drSum = 0.0;
            $rSum = 0.0;
            for ($i = 0; $i < $m; $i++) {
                $drSum += abs($r[$i] - $prevR[$i]);
                $rSum += abs($r[$i]);
            }
            $change = $drSum / (1 + $rSum);
            $converged = $change <= $this->epsilon;
            while (!$schedule->isEmpty()) {
                list($drj, $j) = $schedule->extract();
                $nextSchedule->insert(array($drj * 4, $j));
            }
            $tmp = $schedule;
            $schedule = $nextSchedule;
            $nextSchedule = $tmp;
            $minDrj *= 2;
        }
    }
    /**
     * Returns the pseudo-probability that a new instance is a positive example
     * of the class the beta vector was trained to recognize. It only makes
     * sense to try classification after at least some training
     * has been done on a dataset that includes both positive and negative
     * examples of the target class.
     *
     * @param array $x feature vector represented by an associative array
     * mapping features to their weights
     * @return float probability in [0, 1] that $x is a positive
     *      example of the target class, computed via the sigmoid of
     *      the dot product beta . x
     */
    public function classify($x)
    {
        $l = 0.0;
        foreach ($x as $j => $xj) {
            $l += $xj * $this->beta[$j];
        }
        return 1.0 / (1.0 + exp(-$l));
    }
    /* PRIVATE INTERFACE */
    /**
     * Computes the approximate likelihood of y given a single feature, and
     * returns it as a pair <numerator, denominator>.
     *
     * @param object $Xj iterator over the non-zero entries in column j of the
     * data
     * @param array $y labels corresponding to entries in $Xj; each label is 1
     * if example i has the target label, and -1 otherwise
     * @param array $r cached dot products of the beta vector and feature
     * weights for each example i
     * @param float $d trust region for feature j
     * @return array two-element array containing the numerator and denominator
     * of the likelihood
     */
    public function computeApproxLikelihood($Xj, $y, $r, $d)
    {
        $numer = 0.0;
        $denom = 0.0;
        foreach ($Xj as $cell) {
            list($j, $i, $Xij) = $cell;
            $yi = $y[$i];
            $ri = $yi * $r[$i];
            $a = abs($ri);
            $b = abs($d * $Xij);
            if ($a <= $b) {
                $F = 0.25;
            } else {
                $e = exp($a - $b);
                $F = 1.0 / (2.0 + $e + (1.0/$e));
            }
            $numer += $Xij * $yi / (1 + exp($ri));
            $denom += $Xij * $Xij * $F;
        }
        return [$numer, $denom];
    }
    /**
     * Estimates the lambda parameter from the dataset.
     *
     * @param object $invX inverted X matrix for dataset (essentially a posting
     * list of features in X)
     * @return float lambda estimate
     */
    public function estimateLambdaNorm($invX)
    {
        $sqNorm = 0;
        foreach (new \ArrayIterator($invX->data) as $entry) {
            $Xij = $entry[2];
            $sqNorm += $Xij * $Xij;
        }
        $m = $invX->rows();
        $n = $invX->columns();
        $sigmaSq = $n * $m / $sqNorm;
        return sqrt(2) / sqrt($sigmaSq);
    }
}
/**
 * Stores a data matrix in an inverted index on columns with non-zero entries.
 *
 * The index is just an array of entries <j, i, X[i][j]> sorted first by j and
 * then by i, where all X[i][j] > 0. Provides a method to iterate over all rows
 * which have a non-zero entry for a particular column (feature) j. There is
 * no efficient way to iterate over rows in order.
 *
 * @author Shawn Tice
 */
class InvertedData
{
    /**
     * Number of rows in the matrix.
     * @var int
     */
    public $rows;
    /**
     * Number of columns in the matrix.
     * @var int
     */
    public $columns;
    /**
     * Array of non-zero matrix entries.
     * @var array
     */
    public $data;
    /**
     * Array of offsets into the $data array, where each offset gives the start
     * of the entries for a particular feature.
     * @var array
     */
    public $index;
    /**
     * Converts a SparseMatrix into an InvertedData instance. The data is
     * duplicated.
     *
     * @param object $X SparseMatrix instance to convert
     */
    public function __construct(SparseMatrix $X)
    {
        $this->rows = $X->rows();
        $this->columns = $X->columns();
        $this->data = [];
        $this->index = [];

        foreach ($X as $i => $row) {
            foreach ($row as $j => $Xij) {
                $this->data[] = [$j, $i, $Xij];
            }
        }
        sort($this->data);
        $lastVar = -1;
        foreach ($this->data as $dataOffset => $x) {
            $currVar = $x[0];
            if ($currVar != $lastVar) {
                for ($var = $lastVar + 1; $var <= $currVar; $var++)
                    $this->index[$var] = $dataOffset;
                $lastVar = $currVar;
            }
        }
    }
    /**
     * Accessor method which the number of rows in the matrix
     * @return number of rows
     */
    public function rows()
    {
        return $this->rows;
    }
    /**
     * Accessor method which the number of columns in the matrix
     * @return number of columns
     */
    public function columns()
    {
        return $this->columns;
    }
    /**
     * Returns an iterator over the values for a particular column of the
     * matrix. If no matrix entry in the column is non-zero then an empty
     * iterator is returned.
     *
     * @param into $j feature index (column) to iterate over
     * @return object iterator over values in the column
     */
    public function iterateColumn($j)
    {
        $start = $this->index[$j];
        if ($j < count($this->index) - 1)
            $count = $this->index[$j + 1] - $start;
        else
            $count = -1;
        if ($count != 0) {
            $arr_itr = new \ArrayIterator($this->data);
            return new \LimitIterator($arr_itr, $start, $count);
        }
        return new \EmptyIterator();
    }
}
