diff --git a/src/LinearAlgebra/Decomposition/SVD.php b/src/LinearAlgebra/Decomposition/SVD.php index 3bb807833..01d5ee7b4 100644 --- a/src/LinearAlgebra/Decomposition/SVD.php +++ b/src/LinearAlgebra/Decomposition/SVD.php @@ -2,7 +2,9 @@ namespace MathPHP\LinearAlgebra\Decomposition; +use MathPHP\Arithmetic; use MathPHP\Exception; +use MathPHP\Exception\MatrixException; use MathPHP\LinearAlgebra\NumericMatrix; use MathPHP\LinearAlgebra\MatrixFactory; use MathPHP\LinearAlgebra\Vector; @@ -107,6 +109,19 @@ public static function decompose(NumericMatrix $M): SVD // A rectangular diagonal matrix $S = $U->transpose()->multiply($M)->multiply($V); + // If S is non-diagonal, try permuting S to be diagonal + if (!$S->isRectangularDiagonal()) { + ['sort'=>$sort, 'P'=>$P] = self::diagonalize($S); + // Depending on the value of $sort, we either permute the rows or columns of $S + if ($sort === 'm') { + $S = $P->multiply($S); // Permute rows of S + $U = $U->multiply($P->inverse()); // Permute corresponding columns of U + } elseif ($sort === 'n') { + $S = $S->multiply($P); // Permute columns of S + $V = $P->inverse()->multiply($V); // Permute corresponding rows of V + } + } + $diag = $S->getDiagonalElements(); // If there is a negative singular value, we need to adjust the signs of columns in U @@ -120,9 +135,226 @@ public static function decompose(NumericMatrix $M): SVD $S = $signature->multiply($S); } + // Check the elements are in descending order + if (!self::isDiagonalDescending($S)) { + $P = self::sortDiagonal($S); + /** @var NumericMatrix */ + $Pᵀ = $P->transpose(); + + $S = $Pᵀ->multiply($S)->multiply($P); + $U = $P->multiply($U)->multiply($Pᵀ); + } + return new SVD($U, $S, $V); } + /** + * Returns a permutation matrix, P, such that the product of SP is diagonal + * + * @param NumericMatrix $S the matrix to diagonalize + * + * @return array{'sort': string, 'P': NumericMatrix} a matrix, P, that will diagonalize S. Multiplication order defined by sort + * If 'm', then pre-multiply + * If 'n', then post-multiply + */ + private static function diagonalize(NumericMatrix $S): array + { + if ($S->isRectangularDiagonal()) { + return MatrixFactory::identity($S->getN()); + } + + $sort = ''; + $vecMethod = ''; + $max = 0; + $min = 0; + + if ($S->getM() >= $S->getN()) { + $sort = 'm'; // rows + $vecMethod = 'asRowVectors'; + $max = $S->getM(); + $min = $S->getN(); + } else { + $sort = 'n'; // columns + $vecMethod = 'asVectors'; + $max = $S->getN(); + $min = $S->getM(); + } + + // Create an identity matrix to store permutations in + $P = MatrixFactory::identity($max)->{$vecMethod}(); + + // Push all zero-columns to the right + $vectors = $S->{$vecMethod}(); + + $zeroCols = []; + + foreach ($vectors as $i => $vector) + { + // Each column should contain 1 non-zero element + $isZero = Arithmetic::almostEqual((float) $vector->l2Norm(), 0); + + $zeroCols[$i] = $isZero ? 0 : 1; + } + + arsort($zeroCols, SORT_NUMERIC); + + $zeroMap = array_keys($zeroCols); + + uksort($P, function ($left, $right) use ($zeroMap) { + $leftPos = $zeroMap[$left]; + $rightPos = $zeroMap[$right]; + + return $leftPos >= $rightPos; + }); + + // Only check the columns that contain diagonal entries + $vectors = $S->submatrix(0,0, $min-1, $min-1)->{$vecMethod}(); + + $nonDiagonalValues = []; + + /** @var Vector */ + foreach ($vectors as $i => $vector) + { + $ε = $S->getError(); + + // Each column should contain up to 1 non-zero element + if (Arithmetic::almostEqual($vector->l2Norm(), 0, $ε)) { + $j = $i; + } else { + $j = self::getStandardBasisIndex($vector, $ε); + } + + if ($j === -1) { + throw new MatrixException("S Matrix in SVD is not orthogonal:\n" . (string) $S); + } + + if ($i === $j) { + continue; + } else { + $nonDiagonalValues[$i] = ['value' => $vector[$j], 'j' => $j]; + } + } + + // Now create a sort order + $order = range(0, $min - 1); + + foreach ($nonDiagonalValues as $i => $elem) + { + $entry = $elem['j']; + $order[$entry] = $i; + } + + $map = array_flip($order); + + // Need to make column ($i of $nonDiagonalValues) = row ($j) + // order = [1=>2, 2=>1, 3=>3] + uksort($P, function ($left, $right) use ($map) { + $leftPos = isset($map[$left]) ? $map[$left] : INF; // sorts in ascending order, so just use inf + $rightPos = isset($map[$right]) ? $map[$right] : INF; + + return $leftPos <=> $rightPos; + }); + + $P = MatrixFactory::createFromVectors($P); + + // fromVectors treats the array as column vectors, so the matrix might need to be transposed + if ($sort === 'm') { + $P = $P->transpose(); + } + + return ['sort'=>$sort, 'P' => $P]; + } + + /** + * Checks that a vector has a single non-zero entry and returns its index + * + * @param Vector $v + * + * @return int The index of the non-zero entry or -1 if either: + * 1. There are multiple non-zero entries + * 2. The vector is a zero vector + */ + private static function getStandardBasisIndex(Vector $v, float $ε): int + { + if ($v->l2Norm() === 0) { + return false; + } + + // Vectors don't have negative indices + $index = -1; + + foreach ($v->getVector() as $i => $component) + { + if (!Arithmetic::almostEqual($component, 0, $ε)) { + if ($index === -1) { + $index = $i; + } else { // If we already found a non-zero component, then return -1 + return -1; + } + } + } + + return $index; + } + + /** + * Returns a permutation matrix that sorts its diagonal values in descending order + * + * @param NumericMatrix $S singular matrix + * + * @return NumericMatrix a permutation matrix such that PᵀSP is diagonal + */ + private static function sortDiagonal(NumericMatrix $S): NumericMatrix + { + // Get diagonal, pad it by columns, and sort it + $diagonal = $S->getDiagonalElements(); + + // Pad + $padLength = $S->getN() - count($diagonal); + + $diagonal = array_merge($diagonal, array_fill(0, $padLength, 0)); // Pick 0 because the numbers should all be positive + + // arsort preserves the indices + arsort($diagonal, SORT_NUMERIC); + + // ... so we can create a position map from the keys + $map = array_keys($diagonal); + + $P = MatrixFactory::identity($S->getM())->asVectors(); + + uksort($P, function ($left, $right) use ($map) { + $leftPos = $map[$left]; + $rightPos = $map[$right]; + + return $leftPos >= $rightPos; + }); + + return MatrixFactory::createFromVectors($P); + } + + /** + * Checks if the elements of a diagonal matrix are in descending order + * + * @param NumericMatrix $S the matrix to check + * + * @return bool + */ + private static function isDiagonalDescending(NumericMatrix $S): bool + { + $diagonal = $S->getDiagonalElements(); + $sorted = array_values($diagonal); rsort($sorted, SORT_NUMERIC); + + // Compare sorted using matrix error (in case duplicate, floating-point eigenvalues) + $n = count($diagonal); + for ($i = 0; $i < $n; $i++) { + if (!Arithmetic::almostEqual($diagonal[$i], $sorted[$i], $S->getError())) { + return false; + } + } + + return true; + } + /** * Get U, S, or V matrix, or D vector * diff --git a/src/LinearAlgebra/Eigenvector.php b/src/LinearAlgebra/Eigenvector.php index abed66e87..de9c8de63 100644 --- a/src/LinearAlgebra/Eigenvector.php +++ b/src/LinearAlgebra/Eigenvector.php @@ -2,6 +2,7 @@ namespace MathPHP\LinearAlgebra; +use MathPHP\Arithmetic; use MathPHP\Exception; use MathPHP\Exception\MatrixException; use MathPHP\Functions\Map\Single; @@ -66,8 +67,14 @@ public static function eigenvectors(NumericMatrix $A, array $eigenvalues = []): foreach ($eigenvalues as $eigenvalue) { // If this is a duplicate eigenvalue, and this is the second instance, the first // pass already found all the vectors. - $key = \array_search($eigenvalue, \array_column($solution_array, 'eigenvalue')); - if (!$key) { + $key = false; + foreach (\array_column($solution_array, 'eigenvalue') as $i => $v) { + if (Arithmetic::almostEqual($v, $eigenvalue, $A->getError())) { + $key = $i; + break; + } + } + if ($key === false) { $Iλ = MatrixFactory::identity($number)->scalarMultiply($eigenvalue); $T = $A->subtract($Iλ); diff --git a/src/LinearAlgebra/MatrixCatalog.php b/src/LinearAlgebra/MatrixCatalog.php index d015003b5..9e5dfa45c 100644 --- a/src/LinearAlgebra/MatrixCatalog.php +++ b/src/LinearAlgebra/MatrixCatalog.php @@ -15,6 +15,9 @@ class MatrixCatalog /** @var Matrix inverse */ private $A⁻¹; + /** @var NumericMatrix pseudoInverse */ + private $A⁺; + /** @var Reduction\RowEchelonForm */ private $REF; @@ -43,6 +46,7 @@ class MatrixCatalog * DERIVED MATRICES * - transpose * - inverse + * - pseudo-inverse **************************************************************************/ // TRANSPOSE @@ -99,6 +103,32 @@ public function getInverse(): Matrix return $this->A⁻¹; } + // PSEUDO-INVERSE + + /** + * @param Matrix $A⁺ + */ + public function addPseudoInverse(Matrix $A⁺): void + { + $this->A⁺ = $A⁺; + } + + /** + * @return bool + */ + public function hasPseudoInverse(): bool + { + return isset($this->A⁺); + } + + /** + * @return Matrix + */ + public function getPseudoInverse(): Matrix + { + return $this->A⁺; + } + /************************************************************************** * MATRIX REDUCTIONS * - ref (row echelon form) diff --git a/src/LinearAlgebra/NumericDiagonalMatrix.php b/src/LinearAlgebra/NumericDiagonalMatrix.php index dc1e424f7..1d16cba85 100644 --- a/src/LinearAlgebra/NumericDiagonalMatrix.php +++ b/src/LinearAlgebra/NumericDiagonalMatrix.php @@ -80,4 +80,14 @@ public function inverse(): NumericMatrix { return MatrixFactory::diagonal(Single::reciprocal($this->getDiagonalElements())); } + + /** + * pseudoInverse identical to inverse + * + * @return NumericMatrix + */ + public function pseudoInverse(): NumericMatrix + { + return $this->inverse(); + } } diff --git a/src/LinearAlgebra/NumericMatrix.php b/src/LinearAlgebra/NumericMatrix.php index 52970dced..e8982761b 100644 --- a/src/LinearAlgebra/NumericMatrix.php +++ b/src/LinearAlgebra/NumericMatrix.php @@ -542,7 +542,7 @@ public function isRectangularDiagonal(): bool $n = $this->n; for ($i = 0; $i < $m; $i++) { for ($j = 0; $j < $n; $j++) { - if ($i !== $j && !Support::isZero($this->A[$i][$j])) { + if ($i !== $j && !Support::isZero($this->A[$i][$j], $this->getError())) { return false; } } @@ -1599,6 +1599,65 @@ public function inverse(): NumericMatrix return $A⁻¹; } + /** + * Moore-Penrose inverse, A⁺ + * Used for non-square or singular matrices + * + * Uses the SVD method of construction + * Given SVD of A = USVᵀ + * A⁺ = VS⁻¹Uᵀ + * + * @return NumericMatrix A⁺ + */ + public function pseudoInverse(): NumericMatrix + { + if ($this->catalog->hasPseudoInverse()) { + return $this->catalog->getPseudoInverse(); + } + + $SVD = $this->svd(); + + $U = $SVD->U; + $S = $SVD->S; + $V = $SVD->V; // already transposed + $D = $SVD->D; + + // Manually construct the inverse of S (in case it's singular) + $D⁻¹ = []; + + foreach ($D->getVector() as $element) + { + $D⁻¹[] = abs($element) < 0.0001 ? 0 : 1 / $element; + } + + $m = $S->getM(); + $n = $S->getN(); + + $s = MatrixFactory::zero($m, $n)->transpose()->getMatrix(); + + for ($i = 0; $i < $n; $i++) + { + for ($j = 0; $j < $m; $j++) + { + if ($i === $j) { + $s[$i][$j] = array_shift($D⁻¹); + } else { + $s[$i][$j] = 0; + } + } + } + + $S⁻¹ = MatrixFactory::createNumeric($s); + + $Uᵀ = $U->transpose(); + + $A⁺ = $V->multiply($S⁻¹)->multiply($Uᵀ); + + $this->catalog->addPseudoInverse($A⁺); + + return $A⁺; + } + /** * Cofactor matrix * A matrix where each element is a cofactor. diff --git a/tests/LinearAlgebra/Decomposition/SVDTest.php b/tests/LinearAlgebra/Decomposition/SVDTest.php index da9037d90..b1ff2da66 100644 --- a/tests/LinearAlgebra/Decomposition/SVDTest.php +++ b/tests/LinearAlgebra/Decomposition/SVDTest.php @@ -5,6 +5,7 @@ use MathPHP\Functions\Support; use MathPHP\LinearAlgebra\MatrixFactory; use MathPHP\Exception; +use MathPHP\LinearAlgebra\Eigenvalue; use MathPHP\LinearAlgebra\NumericMatrix; use MathPHP\LinearAlgebra\Vector; use MathPHP\Tests\LinearAlgebra\Fixture\MatrixDataProvider; @@ -211,6 +212,59 @@ public function dataProviderForSVD(): array ], ], ], + // Test SVD columnar sort + [ + [ + [0,1,0,0], + [1,0,0,0], + [0,0,1,0] + ], + [ + 'S' => [ + [1,0,0,0], + [0,1,0,0], + [0,0,1,0] + ] + ] + ], + // Test SVD tabular sort + [ + [ + [0, 1, 0], + [1, 0, 0], + [0, 0, 1], + [0, 0, 0], + ], + [ + 'S' => [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [0, 0, 0] + ] + ] + ], + // Test Real Failing Matrix + [ + [ + [1,0,0,1,0,0], + [0,1,0,0,1,0], + [0,0,1,0,0,1], + [0,0,0,0,0,0], + [0,0,-48.5,0,0,-1681.2], + [0,48.5,0,0,1681.2,0] + ], + [ + 'S' => [ + [1681.89974,0,0,0,0,0], + [0,1681.89974,0,0,0,0], + [0,0,1.41421,0,0,0], + [0,0,0,0.970748,0,0], + [0,0,0,0,0.970748,0], + [0,0,0,0,0,0] + ] + ] + ], // Idempotent [ [ @@ -302,6 +356,10 @@ public function testSVDProperties(array $A) $V = $svd->V; $D = $svd->D; + if (!$svd->getV()->isOrthogonal()) { + $p = true; + } + // Then U and V are orthogonal $this->assertTrue($svd->getU()->isOrthogonal()); $this->assertTrue($svd->getV()->isOrthogonal()); diff --git a/tests/LinearAlgebra/Eigen/EigenvectorTest.php b/tests/LinearAlgebra/Eigen/EigenvectorTest.php index 0a6e52817..b57cddd86 100644 --- a/tests/LinearAlgebra/Eigen/EigenvectorTest.php +++ b/tests/LinearAlgebra/Eigen/EigenvectorTest.php @@ -131,6 +131,63 @@ public function dataProviderForEigenvector(): array ]; } + /** + * @test eigenvector can handle numerical precision errors + * @dataProvider dataProviderForPerturbedEigenvalues + * @param array $A + * @param array $E + * @param array $S + */ + public function testEigenvectorsPerturbedEigenvalues(array $A, array $E, array $S) + { + // Perturb E + foreach ($E as $i => $component) { + $E[$i] = $component + (random_int(-1, 1) * 10**-12); + } + + // Given + $A = MatrixFactory::create($A); + $S = MatrixFactory::create($S); + + // When + $eigenvectors = Eigenvector::eigenvectors($A, $E); + + // Then + $this->assertEqualsWithDelta($S, $eigenvectors, 0.0001); + } + + public function dataProviderForPerturbedEigenvalues(): array + { + return [ + [ // Matrix has duplicate eigenvalues. One vector is on an axis. + [ + [2, 0, 1], + [2, 1, 2], + [3, 0, 4], + ], + [5, 1, 1], + [ + [1 / \sqrt(14), 0, \M_SQRT1_2], + [2 / \sqrt(14), 1, 0], + [3 / \sqrt(14), 0, -1 * \M_SQRT1_2], + ] + ], + [ // Matrix has duplicate eigenvalues. no solution on the axis + [ + [2, 2, -3], + [2, 5, -6], + [3, 6, -8], + ], + [-3, 1, 1], + [ + [1 / \sqrt(14), 1 / \M_SQRT3, 5 / \sqrt(42)], + [2 / \sqrt(14), 1 / \M_SQRT3, -4 / \sqrt(42)], + [3 / \sqrt(14), 1 / \M_SQRT3, -1 / \sqrt(42)], + ] + ], + ]; + } + /** * @test eigenvectors throws a BadDataException when the matrix is not square */ diff --git a/tests/LinearAlgebra/Matrix/Numeric/MatrixOperationsTest.php b/tests/LinearAlgebra/Matrix/Numeric/MatrixOperationsTest.php index 5024d942d..4140ac778 100644 --- a/tests/LinearAlgebra/Matrix/Numeric/MatrixOperationsTest.php +++ b/tests/LinearAlgebra/Matrix/Numeric/MatrixOperationsTest.php @@ -367,6 +367,104 @@ public function dataProviderForInverseExceptionDetIsZero(): array ]; } + /** + * Should return the inverse when the matrix is square, so reuse the inverse test dataset + * + * @test pseudoInverse + * @dataProvider dataProviderForInverse + * @dataProvider dataProviderForPseudoInverse + * @param array $A + * @param array $A⁺ + * @throws \Exception + */ + public function testPseudoInverse(array $A, array $A⁺) + { + // Given + $A = MatrixFactory::createNumeric($A); + $A⁺ = MatrixFactory::createNumeric($A⁺); + + // When + $pseudo = $A->pseudoInverse(); + $pseudoAgain = $A->pseudoInverse(); + + // Then + $this->assertEqualsWithDelta($A⁺, $pseudo, 0.001); // Test calculation + $this->assertEqualsWithDelta($A⁺, $pseudoAgain, 0.001); // Test class attribute + } + + /** + * @return array + */ + public function dataProviderForPseudoInverse(): array + { + return [ + [ + [ + [0, 0, 0, 1,0, 0], + [0, 1,0, 0, 1,0], + [0, 0, 1,0, 0, 1], + [0, 0, 0, 0, 0, 0], + [0, 0, -40, 0, 0, -160], + [0, 40, 0, 0, 160, 0] + ], + [ + [0, 0, 0, 0, 0, 0], + [0, 4/3, 0, 0, 0, -1/120], + [0, 0, 4/3, 0, 1/120, 0], + [1, 0, 0, 0, 0, 0], + [0, -1/3, 0, 0, 0, 1/120], + [0, 0, -1/3, 0, -1/120, 0] + ] + ], + [ + [ + [0, 1, 0, 0], + [1, 0, 0, 0], + [0, 0, 1, 0], + ], + [ + [0, 1, 0], + [1, 0, 0], + [0, 0, 1], + [0, 0, 0], + ], + ], + [ + [ + [0, 1, 0, 0], + [1, 1, 0, 0], + [0, 0, 1, 0], + ], + [ + [-1, 1, 0], + [1, 0, 0], + [0, 0, 1], + [0, 0, 0], + ], + ], + + // Test Real Failing Matrix + [ + [ + [1,0,0,1,0,0], + [0,1,0,0,1,0], + [0,0,1,0,0,1], + [0,0,0,0,0,0], + [0,0,-48.5,0,0,-1681.2], + [0,48.5,0,0,1681.2,0] + ], + [ + [0.5,0,0,0,0,0.], + [0,1.02971,0,0,0,-0.000612482], + [0,0,1.02971,0,0.000612482,0.], + [0.5,0,0,0,0,0.], + [0,-0.0297054,0,0,0,0.000612482], + [0,0,-0.0297054,0,-0.000612482,0.] + ] + ] + ]; + } + /** * @test cofactor * @dataProvider dataProviderForCofactor