From 3818c3b006881bf4e8319c1fd6be2f2fcbc3b97c Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 6 Apr 2026 12:56:24 +0000
Subject: [PATCH 1/2] feat: expose QR decomposition with GramSchmidt and
Householder methods (closes #317)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add FSharp.Stats.LinearAlgebra module with:
- QRMethod discriminated union (Householder | GramSchmidt)
- QR.decompose – method-dispatching entry point
- QR.householder – Householder reflections (full QR, Q is m×m)
- QR.gramSchmidt – modified Gram-Schmidt (thin QR, Q is m×n)
The underlying algorithms come from FsMath.Algebra.LinearAlgebra
(qrDecompose and qrModifiedGramSchmidt). This PR simply exposes them
through a unified, discoverable FSharp.Stats API with clear doc-comments
explaining the output dimension difference between the two methods.
Added 10 tests:
- A = Q*R round-trips for both methods on a square and a rectangular matrix
- Q^T Q = I orthonormality checks
- R upper-triangular checks
- Dimension checks (thin Q for GramSchmidt, full Q for Householder)
- decompose dispatch test
1209/1209 tests pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/FSharp.Stats/FSharp.Stats.fsproj | 1 +
src/FSharp.Stats/LinearAlgebra.fs | 90 ++++++++++++++++++++++++++++
tests/FSharp.Stats.Tests/Fitting.fs | 89 +++++++++++++++++++++++++++
3 files changed, 180 insertions(+)
create mode 100644 src/FSharp.Stats/LinearAlgebra.fs
diff --git a/src/FSharp.Stats/FSharp.Stats.fsproj b/src/FSharp.Stats/FSharp.Stats.fsproj
index 633a4162..c7fc980a 100644
--- a/src/FSharp.Stats/FSharp.Stats.fsproj
+++ b/src/FSharp.Stats/FSharp.Stats.fsproj
@@ -59,6 +59,7 @@
+
diff --git a/src/FSharp.Stats/LinearAlgebra.fs b/src/FSharp.Stats/LinearAlgebra.fs
new file mode 100644
index 00000000..2ef0b374
--- /dev/null
+++ b/src/FSharp.Stats/LinearAlgebra.fs
@@ -0,0 +1,90 @@
+namespace FSharp.Stats
+
+open FsMath
+open FsMath.Algebra
+
+///
+/// Provides matrix decomposition algorithms for linear algebra operations.
+///
+module LinearAlgebra =
+
+ ///
+ /// Specifies the algorithm used for QR decomposition.
+ ///
+ ///
+ /// The two algorithms differ in the shape of the output matrices:
+ ///
+ /// -
+ /// Householder: full (uneconomised) decomposition.
+ /// For an m×n input, produces Q (m×m) and R (m×n).
+ ///
+ /// -
+ /// GramSchmidt: thin (economy) decomposition.
+ /// For an m×n input with m ≥ n, produces Q (m×n) and R (n×n).
+ /// Useful when only the column space of A is needed and memory is
+ /// important, or when you want Q to be exactly m×n rather than m×m.
+ ///
+ ///
+ /// Both satisfy A = Q * R; both produce an orthonormal Q (Q^T Q = I).
+ ///
+ type QRMethod =
+ /// Full decomposition via Householder reflections. Q is m×m, R is m×n.
+ | Householder
+ /// Thin (economy) decomposition via modified Gram-Schmidt orthogonalisation. Q is m×n, R is n×n.
+ | GramSchmidt
+
+ ///
+ /// QR decomposition of a matrix A into an orthogonal matrix Q and an upper-triangular matrix R such that A = Q * R.
+ ///
+ module QR =
+
+ ///
+ /// Decomposes matrix A into Q * R using the specified method.
+ ///
+ ///
+ /// for the full decomposition (Q is m×m);
+ /// for the thin/economy decomposition (Q is m×n).
+ ///
+ /// The input matrix to decompose. Must have at least as many rows as columns for Gram-Schmidt.
+ /// A tuple (Q, R) satisfying A = Q * R.
+ ///
+ ///
+ /// open FsMath // for matrix / vector literals
+ /// open FSharp.Stats.LinearAlgebra
+ ///
+ /// let A = matrix [[12.;-51.;4.];[6.;167.;-68.];[-4.;24.;-41.]]
+ ///
+ /// // Gram-Schmidt – thin Q (3×3 for a square input)
+ /// let (qGS, rGS) = QR.decompose GramSchmidt A
+ ///
+ /// // Householder – full Q (3×3 for a square input)
+ /// let (qHH, rHH) = QR.decompose Householder A
+ ///
+ /// // For a 4×3 input the difference is more visible:
+ /// let B = matrix [[1.;2.];[3.;4.];[5.;6.];[7.;8.]]
+ /// let (qGS4x2, rGS2x2) = QR.decompose GramSchmidt B // Q: 4×2, R: 2×2
+ /// let (qHH4x4, rHH4x2) = QR.decompose Householder B // Q: 4×4, R: 4×2
+ ///
+ ///
+ let decompose (method: QRMethod) (A: Matrix) : Matrix * Matrix =
+ match method with
+ | Householder -> LinearAlgebra.qrDecompose A
+ | GramSchmidt -> LinearAlgebra.qrModifiedGramSchmidt A
+
+ ///
+ /// Decomposes matrix A using Householder reflections (full QR).
+ /// For an m×n matrix, Q is m×m and R is m×n.
+ ///
+ /// The input matrix to decompose.
+ /// A tuple (Q, R) satisfying A = Q * R.
+ let householder (A: Matrix) : Matrix * Matrix =
+ LinearAlgebra.qrDecompose A
+
+ ///
+ /// Decomposes matrix A using modified Gram-Schmidt orthogonalisation (thin/economy QR).
+ /// For an m×n matrix with m ≥ n, Q is m×n and R is n×n.
+ ///
+ /// The input matrix to decompose. m must be ≥ n.
+ /// A tuple (Q, R) satisfying A = Q * R.
+ let gramSchmidt (A: Matrix) : Matrix * Matrix =
+ LinearAlgebra.qrModifiedGramSchmidt A
diff --git a/tests/FSharp.Stats.Tests/Fitting.fs b/tests/FSharp.Stats.Tests/Fitting.fs
index 91111c40..a4d08a8a 100644
--- a/tests/FSharp.Stats.Tests/Fitting.fs
+++ b/tests/FSharp.Stats.Tests/Fitting.fs
@@ -55,6 +55,95 @@ let leastSquaresCholeskyTests =
)
]
open FSharp.Stats.Fitting.Spline
+[]
+let qrDecompositionTests =
+ // Classic Golub-Van Loan 3×3 example: known Q and R
+ let A = matrix [[12.;-51.;4.];[6.;167.;-68.];[-4.;24.;-41.]]
+ // Rectangular tall matrix for thin-QR tests
+ let B = matrix [[1.;2.];[3.;4.];[5.;6.];[7.;8.]]
+
+ let matClose (m1: Matrix) (m2: Matrix) label =
+ Expect.equal m1.NumRows m2.NumRows (label + " – row count")
+ Expect.equal m1.NumCols m2.NumCols (label + " – col count")
+ for i in 0 .. m1.NumRows - 1 do
+ for j in 0 .. m1.NumCols - 1 do
+ Expect.floatClose Accuracy.medium m1.[i,j] m2.[i,j] (sprintf "%s [%d,%d]" label i j)
+
+ testList "QR Decomposition" [
+
+ testCase "GramSchmidt: A = Q * R (square)" (fun () ->
+ let (q, r) = LinearAlgebra.QR.gramSchmidt A
+ let qr = q * r
+ matClose qr A "GS Q*R should equal A"
+ )
+
+ testCase "Householder: A = Q * R (square)" (fun () ->
+ let (q, r) = LinearAlgebra.QR.householder A
+ let qr = q * r
+ matClose qr A "Householder Q*R should equal A"
+ )
+
+ testCase "GramSchmidt: Q is orthonormal (Q^T Q = I)" (fun () ->
+ let (q, _) = LinearAlgebra.QR.gramSchmidt A
+ let qtq = q.Transpose() * q
+ let eye = Matrix.identity q.NumCols
+ matClose qtq eye "GS Q^T Q should be identity"
+ )
+
+ testCase "Householder: Q is orthogonal (Q^T Q = I)" (fun () ->
+ let (q, _) = LinearAlgebra.QR.householder A
+ let qtq = q.Transpose() * q
+ let eye = Matrix.identity q.NumCols
+ matClose qtq eye "Householder Q^T Q should be identity"
+ )
+
+ testCase "GramSchmidt: R is upper triangular (square)" (fun () ->
+ let (_, r) = LinearAlgebra.QR.gramSchmidt A
+ for i in 1 .. r.NumRows - 1 do
+ for j in 0 .. i - 1 do
+ Expect.floatClose Accuracy.medium r.[i,j] 0. (sprintf "GS R[%d,%d] should be 0" i j)
+ )
+
+ testCase "Householder: R is upper triangular (square)" (fun () ->
+ let (_, r) = LinearAlgebra.QR.householder A
+ for i in 1 .. r.NumRows - 1 do
+ for j in 0 .. i - 1 do
+ Expect.floatClose Accuracy.medium r.[i,j] 0. (sprintf "HH R[%d,%d] should be 0" i j)
+ )
+
+ testCase "GramSchmidt thin QR: dimensions for 4×2 input" (fun () ->
+ let (q, r) = LinearAlgebra.QR.gramSchmidt B
+ Expect.equal q.NumRows 4 "GS thin Q should have 4 rows"
+ Expect.equal q.NumCols 2 "GS thin Q should have 2 cols"
+ Expect.equal r.NumRows 2 "GS thin R should have 2 rows"
+ Expect.equal r.NumCols 2 "GS thin R should have 2 cols"
+ )
+
+ testCase "Householder full QR: dimensions for 4×2 input" (fun () ->
+ let (q, r) = LinearAlgebra.QR.householder B
+ Expect.equal q.NumRows 4 "HH full Q should have 4 rows"
+ Expect.equal q.NumCols 4 "HH full Q should have 4 cols"
+ Expect.equal r.NumRows 4 "HH full R should have 4 rows"
+ Expect.equal r.NumCols 2 "HH full R should have 2 cols"
+ )
+
+ testCase "GramSchmidt thin QR: A = Q * R (rectangular)" (fun () ->
+ let (q, r) = LinearAlgebra.QR.gramSchmidt B
+ let qr = q * r
+ matClose qr B "GS thin Q*R should equal B"
+ )
+
+ testCase "decompose dispatches correctly" (fun () ->
+ let (qGS, _) = LinearAlgebra.QR.decompose LinearAlgebra.GramSchmidt A
+ let (qGS2, _) = LinearAlgebra.QR.gramSchmidt A
+ matClose qGS (qGS2 |> id) "decompose GramSchmidt should match gramSchmidt"
+ let (qHH, _) = LinearAlgebra.QR.decompose LinearAlgebra.Householder A
+ let (qHH2, _) = LinearAlgebra.QR.householder A
+ matClose qHH qHH2 "decompose Householder should match householder"
+ )
+ ]
+
+
[]
let splineTests =
testList "Fitting.Spline" [
From 6a040e7e1c65aaedb5c5ba8a267b4f82caddad10 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 6 Apr 2026 12:56:27 +0000
Subject: [PATCH 2/2] ci: trigger checks