Skip to content

Commit ae08b58

Browse files
authored
Fix: Allow QR decomposition (and therefore eigenvalue finding) of singular matrices. Fixes #129 (#132)
1 parent 66a9ee1 commit ae08b58

5 files changed

Lines changed: 64 additions & 12 deletions

File tree

src/decompositions/QRDecomposition.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,7 @@ export function calculateQRDecomposition<S>(A: Matrix<S>): QRDecomposition<S> {
5050
// If any columns are the zero vector, then A was not full-rank to begin with.
5151
const qColumns: Vector<S>[] = [];
5252
for (let i = 0; i < dim; i++) {
53-
const qi = normalize(uColumns[i]);
54-
if (qi === undefined) {
55-
throw Error('A is singular; no QR decomposition exists');
56-
}
53+
const qi = normalize(uColumns[i]) ?? uColumns[i]; // Default to the zero-vector
5754
qColumns.push(qi);
5855
}
5956
const Q = matrixBuilder.fromColumnVectors(qColumns);

src/decompositions/__tests__/QRDecomposition.spec.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,29 @@ describe('QRDecomposition', () => {
2222
[0, 0, 35],
2323
]);
2424

25-
const result = calculateQRDecomposition(A);
26-
expect(result.Q.equals(expectedQ)).toBe(true);
27-
expect(result.R.equals(expectedR)).toBe(true);
25+
const { Q, R } = calculateQRDecomposition(A);
26+
expect(Q.equals(expectedQ)).toBe(true);
27+
expect(R.equals(expectedR)).toBe(true);
2828

2929
// Check that QR equals A
30-
expect(result.Q.multiply(result.R).equals(A)).toBe(true);
30+
expect(Q.multiply(R).equals(A)).toBe(true);
3131
});
3232

3333
test('rejects a non-square matrix', () => {
3434
const A = mat([[1, 2]]);
3535
expect(() => calculateQRDecomposition(A)).toThrow();
3636
});
3737

38-
test('rejects a matrix with linearly dependent columns', () => {
38+
test('handles a singular matrix', () => {
3939
const A = mat([
4040
[1, 1],
4141
[1, 1],
4242
]);
43-
expect(() => calculateQRDecomposition(A)).toThrow();
43+
44+
const { Q, R } = calculateQRDecomposition(A);
45+
46+
// The decomposition is not unique in this case, so just verify that it reconstitutes A
47+
expect(Q.multiply(R).equals(A)).toBe(true);
4448
});
4549
});
4650
});

src/eigenvalues/__tests__/Eigenvalues.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ describe('Eigenvalues', () => {
3838
expect(pair.eigenvector.equals(expectedVectors[i])).toBe(true);
3939
});
4040
});
41+
42+
test('calculates eigenvalue-eigenvector pairs of a singular matrix', () => {
43+
const A = mat([
44+
[2, -1, -1],
45+
[-1, 2, -1],
46+
[-1, -1, 2],
47+
]);
48+
const expectedValues = [3, 3, 0];
49+
const expectedVectors = [vec([-2, 1, 1]), vec([-2, 1, 1]), vec([1, 1, 1])];
50+
51+
const pairs = eig(A);
52+
pairs.forEach((pair, i) => {
53+
expect(pair.eigenvalue).toBeCloseTo(expectedValues[i], 5);
54+
expect(pair.eigenvector.equals(expectedVectors[i])).toBe(true);
55+
});
56+
});
4157
});
4258

4359
describe('calculateEigenvalues', () => {

src/operations/RowOperations.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export interface RowOperationResult<S> {
1212
operator: Matrix<S>;
1313
}
1414

15+
type VectorComparator<S> = (v1: Vector<S>, v2: Vector<S>) => number;
16+
1517
/**
1618
* A wrapper for static methods representing the elementary row operations
1719
* @public
@@ -118,7 +120,7 @@ export class RowOperations {
118120
const ops = matrix.ops();
119121
// We will sort the rows of an identity matrix according to the number
120122
// of leading zeros in the corresponding row of `matrix`
121-
const comparator: (r1: Vector<S>, r2: Vector<S>) => number = (iRow1, iRow2) => {
123+
const comparator: VectorComparator<S> = (iRow1, iRow2) => {
122124
const rowIndex1 = getNumberOfLeadingZeros(iRow1);
123125
const rowIndex2 = getNumberOfLeadingZeros(iRow2);
124126
const mRow1 = matrix.getRow(rowIndex1);
@@ -127,6 +129,11 @@ export class RowOperations {
127129
const leadingZeros2 = getNumberOfLeadingZeros(mRow2);
128130

129131
if (leadingZeros1 === leadingZeros2) {
132+
const rowsAreAllZero = leadingZeros1 === mRow1.getDimension();
133+
if (rowsAreAllZero) {
134+
// The rows are equivalent - the sort order does not matter
135+
return 0;
136+
}
130137
// If they have the same number of leading zeros, put
131138
// the entry with the greatest magnitude on top
132139
const firstEntry1 = mRow1.getEntry(leadingZeros1);

src/operations/__tests__/RowOperations.spec.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mat } from '../../utilities/aliases';
1+
import { mat, zeros } from '../../utilities/aliases';
22
import { RowOperations } from '../RowOperations';
33

44
describe('RowOperations', () => {
@@ -74,5 +74,33 @@ describe('RowOperations', () => {
7474
expect(result.result).toStrictEqual(sorted);
7575
expect(result.operator).toStrictEqual(permutation);
7676
});
77+
78+
test('sorts by the first entry when the number of leading zeros is the same', () => {
79+
const unsorted = mat([
80+
[0, 5, 5],
81+
[5, 5, 5],
82+
[0, 4, 5],
83+
]);
84+
const sorted = mat([
85+
[5, 5, 5],
86+
[0, 5, 5],
87+
[0, 4, 5],
88+
]);
89+
const permutation = mat([
90+
[0, 1, 0],
91+
[1, 0, 0],
92+
[0, 0, 1],
93+
]);
94+
95+
const result = RowOperations.pivot(unsorted);
96+
expect(result.result).toStrictEqual(sorted);
97+
expect(result.operator).toStrictEqual(permutation);
98+
});
99+
100+
test('handles all-zero rows', () => {
101+
const unsorted = zeros([3, 3]);
102+
const { result } = RowOperations.pivot(unsorted);
103+
expect(result).toStrictEqual(unsorted);
104+
});
77105
});
78106
});

0 commit comments

Comments
 (0)