brush-strokes-0.1.0.0: src/lib/Math/Roots.hs
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# OPTIONS_GHC -Wno-name-shadowing #-}
module Math.Roots
( -- * Quadratic solver
solveQuadratic
-- * Laguerre method
, roots, realRoots
, laguerre
-- * Newton–Raphson
, newtonRaphson
-- * Modified Halley's method M2
, halleyM2
-- * N-dimensional Newton's method
, newton
)
where
-- base
import Control.Monad
( unless, when )
import Control.Monad.ST
( ST, runST )
import Data.Complex
( Complex(..), conjugate, magnitude, realPart, imagPart )
import Data.Maybe
( mapMaybe )
import GHC.TypeNats
( KnownNat )
-- deepseq
import Control.DeepSeq
( NFData, force )
-- eigen
import qualified Eigen.Matrix as Eigen
( Matrix )
import qualified Eigen.Solver.LA as Eigen
( Decomposition(..), solve )
-- fp-ieee
import Numeric.Floating.IEEE.NaN
( RealFloatNaN(copySign) )
-- primitive
import Control.Monad.Primitive
( PrimMonad(PrimState) )
import Data.Primitive.PrimArray
( PrimArray, MutablePrimArray
, primArrayFromList
, getSizeofMutablePrimArray, sizeofPrimArray
, unsafeThawPrimArray
, shrinkMutablePrimArray, readPrimArray, writePrimArray
)
import Data.Primitive.Types
( Prim )
-- rounded-hw
import Numeric.Rounded.Hardware.Internal
( fusedMultiplyAdd )
-- brush-strokes
import Math.Algebra.Dual
import Math.Epsilon
( epsilon, nearZero )
import Math.Linear
import Math.Linear.Solve
( fromEigen, toEigen, withEigenSem )
import Math.Module
import Math.Monomial
--------------------------------------------------------------------------------
-- | Real solutions to a quadratic equation.
--
-- Coefficients are given in order of increasing degree.
--
-- Implementation taken from https://pbr-book.org/4ed/Utilities/Mathematical_Infrastructure#Quadratic.
solveQuadratic
:: forall a. RealFloatNaN a
=> a -- ^ constant coefficient
-> a -- ^ linear coefficient
-> a -- ^ quadratic coefficient
-> [ a ]
solveQuadratic c b a
| isNaN a || isNaN b || isNaN c
|| isInfinite a || isInfinite b || isInfinite c
= []
| otherwise
= case (a == 0, c == 0) of
-- First, handle all cases in which a or c is zero.
-- bx = 0
(True , True )
| b == 0
-> [ 0, 0.5, 1 ] -- convention
| otherwise
-> [ 0 ]
-- bx + c = 0
(True , False)
| b == 0
-> [ ]
| otherwise
-> [ -c / b ]
-- ax² + bx = 0
(False, True )
| b == 0
-> [ 0 ]
| signum a == signum b
-> [ -b / a, 0 ]
| otherwise
-> [ 0, -b / a ]
-- General case: ax² + bx + c = 0
(False, False)
| discr < 0
-> []
| otherwise
-> let rootDiscr = sqrt discr
q = -0.5 * ( b + copySign rootDiscr b )
x1 = q / a
x2 = c / q
in if x1 > x2
then [ x2, x1 ]
else [ x1, x2 ]
where discr = discriminant a b c
{-# INLINEABLE solveQuadratic #-}
{-# SPECIALISE solveQuadratic @Float #-}
{-# SPECIALISE solveQuadratic @Double #-}
-- TODO: implement the version from the paper
-- "The Ins and Outs of Solving Quadratic Equations with Floating-Point Arithmetic"
-- which is even more robust.
-- | Kahan's method for computing the discriminant \( b^2 - 4ac \),
-- using a fused multiply-add operation to avoid cancellation in the naive
-- formula (if \( b^2 \) and \( 4ac \) are close).
--
-- From "The Ins and Outs of Solving Quadratic Equations with Floating-Point Arithmetic",
-- (Frédéric Goualard, 2023).
discriminant :: RealFloat a => a -> a -> a -> a
discriminant a b c
-- b² and 4ac are different enough that b² - 4ac gives a good answer
| 3 * abs d_naive >= b² - m4ac
= d_naive
| otherwise
= let dp = fma b b -b²
dq = fma ( 4 * a ) c m4ac
in d_naive + ( dp - dq )
where
b² = b * b
m4ac = -4 * a * c
d_naive = b² + m4ac
fma = fusedMultiplyAdd
{-# INLINEABLE discriminant #-}
{-# SPECIALISE discriminant @Float #-}
{-# SPECIALISE discriminant @Double #-}
--------------------------------------------------------------------------------
-- Root finding using Laguerre's method
-- | Find real roots of a polynomial with real coefficients.
--
-- Coefficients are given in order of decreasing degree, e.g.:
-- x² + 7 is given by [ 1, 0, 7 ].
realRoots :: forall a. ( RealFloat a, Prim a, NFData a ) => Int -> [ a ] -> [ a ]
realRoots maxIters coeffs = mapMaybe isReal ( roots epsilon maxIters coeffs )
where
isReal :: Complex a -> Maybe a
isReal ( a :+ b )
| abs b < epsilon = Just a
| otherwise = Nothing
-- | Compute all roots of a polynomial with real coefficients using Laguerre's method and (forward) deflation.
--
-- Polynomial coefficients are given in order of descending degree (e.g. constant coefficient last).
--
-- N.B. The forward deflation process is only guaranteed to be numerically stable
-- if Laguerre's method finds roots in increasing order of magnitude
-- (which this function attempts to do).
roots :: forall a. ( RealFloat a, Prim a, NFData a ) => a -> Int -> [ a ] -> [ Complex a ]
roots eps maxIters coeffs = runST do
let
coeffPrimArray :: PrimArray a
coeffPrimArray = primArrayFromList coeffs
sz :: Int
!sz = sizeofPrimArray coeffPrimArray
n :: Int
!n = sz - 1
( p :: MutablePrimArray s a ) <- unsafeThawPrimArray coeffPrimArray
let
go :: Int -> [ Complex a ] -> ST s [ Complex a ]
go 0 rs = return rs
go d rs = do
-- Start at 0, attempting to find the root with smallest absolute value.
-- This improves numerical stability of the forward deflation process.
!r <- force <$> laguerre eps maxIters p 0
if d == 1
then pure ( r : rs )
else
-- real root
if abs ( imagPart r ) < epsilon
then do
deflate ( realPart r ) p
go ( d - 1 ) ( r : rs )
else do
deflateConjugatePair r p
go ( d - 2 ) ( r : conjugate r : rs )
go n []
-- | Forward deflation of a polynomial by a root: factors out the root.
--
-- Mutates the input polynomial.
deflate :: forall a m s. ( Num a, Prim a, PrimMonad m, s ~ PrimState m ) => a -> MutablePrimArray s a -> m ()
deflate r p = do
deg <- subtract 1 <$> getSizeofMutablePrimArray p
when ( deg >= 2 ) do
shrinkMutablePrimArray p deg
let
go :: a -> Int -> m ()
go !b !i = unless ( i >= deg ) do
ai <- readPrimArray p i
let
bi :: a
!bi = ai + r * b
writePrimArray p i bi
go bi ( i + 1 )
a0 <- readPrimArray p 0
go a0 1
-- | Forward deflation of a polynomial with real coefficients by a pair of complex-conjugate roots.
--
-- Mutates the input polynomial.
deflateConjugatePair :: forall a m s. ( Num a, Prim a, PrimMonad m, s ~ PrimState m ) => Complex a -> MutablePrimArray s a -> m ()
deflateConjugatePair ( x :+ y ) p = do
deg <- subtract 1 <$> getSizeofMutablePrimArray p
when ( deg >= 3 ) do
shrinkMutablePrimArray p ( deg - 1 )
let
c1, c2 :: a
!c1 = 2 * x
!c2 = x * x + y * y
a0 <- readPrimArray p 0
a1 <- readPrimArray p 1
let
b1 :: a
!b1 = a1 + c1 * a0
writePrimArray p 1 b1
let
go :: a -> a -> Int -> m ()
go !b !b' !i = unless ( i >= deg - 1 ) do
ai <- readPrimArray p i
let
bi :: a
!bi = ai + c1 * b - c2 * b'
writePrimArray p i bi
go bi b ( i + 1 )
go b1 a0 2
-- | Laguerre's method.
--
-- Does not perform any mutation.
laguerre
:: forall a m s
. ( RealFloat a, Prim a, PrimMonad m, s ~ PrimState m )
=> a -- ^ error tolerance
-> Int -- ^ max number of iterations
-> MutablePrimArray s a -- ^ polynomial
-> Complex a -- ^ initial point
-> m ( Complex a )
laguerre eps maxIters p x0 = do
let
go :: Int -> Complex a -> m ( Complex a )
go !stepNo !x = do
x' <- laguerreStep stepNo p x
if stepNo >= maxIters || magnitude ( x' - x ) < eps
then pure x'
else go ( stepNo + 1 ) x'
go 1 x0
-- | Take a single step in Laguerre's method.
--
-- Does not perform any mutation.
laguerreStep
:: forall a m s
. ( RealFloat a, Prim a, PrimMonad m, s ~ PrimState m )
=> Int -- ^ step number
-> MutablePrimArray s a -- ^ polynomial
-> Complex a -- ^ initial point
-> m ( Complex a )
laguerreStep stepNo p x = do
sz <- getSizeofMutablePrimArray p
let
n :: a
!n = fromIntegral $ sz - 1
( px, p'x, p''x ) <- evalDerivatives p x
if magnitude px == 0
then pure x
else do
let
!g = p'x / px
!g² = g * g
!h = g² - p''x / px
!m = multiplicityEstimator n ( g² / h )
!delta = sqrt $ ( ( n - m ) / m ) *: ( n *: h - g² )
!gp = g + delta
!gm = g - delta
!denom
| sqMag gm > sqMag gp
= gm
| otherwise
= gp
pure $
if sqMag denom == 0
then x + ( 0.001 :+ 0.001 ) -- random perturbation to escape saddle point
else x - ( n :+ 0 ) / denom
where
(*:) :: a -> Complex a -> Complex a
r *: (u :+ v) = ( r * u ) :+ ( r * v )
sqMag :: Complex a -> a
sqMag (u :+ v) = u * u + v * v
multiplicityEstimator :: a -> Complex a -> a
multiplicityEstimator n est
-- Burn-in period: unmodified Laguerre for the first few steps
| stepNo < 5
-- Don't use an estimator if it is NaN/Infinity
|| isNaN realEst || isInfinite realEst
|| isNaN imagEst || isInfinite imagEst
-- Only use an estimate if it is near an integer in [1..n]
|| sqMag ( est - ( rounded :+ 0 ) ) > 0.01
|| rounded >= n + 0.5
|| rounded <= 1
= 1
| otherwise
= rounded
where
realEst, imagEst, rounded :: a
realEst = realPart est
imagEst = imagPart est
rounded = fromIntegral ( round realEst :: Int )
-- | Evaluate @p(x)@, @p'(x)@ and @p''(x)@, for a polynomial with real
-- coefficients @p@, at a complex number.
--
-- Does not perform any mutation.
evalDerivatives
:: forall a m s
. ( RealFloat a, Prim a, PrimMonad m, s ~ PrimState m )
=> MutablePrimArray s a
-> Complex a
-> m ( Complex a, Complex a, Complex a )
evalDerivatives p x = do
n <- getSizeofMutablePrimArray p
a0 <- readPrimArray p 0
let
-- Generalised Horner's method
go :: Int -> Complex a -> Complex a -> Complex a -> m ( Complex a, Complex a, Complex a )
go !i !px !dpx !ddpx
| i >= n
= pure (px, dpx, ddpx * 2) -- Multiply second derivative by 2 at the end
| otherwise
= do
ai <- readPrimArray p i
let
!ddpx' = ddpx * x + dpx
!dpx' = dpx * x + px
!px' = px * x + (ai :+ 0)
go (i + 1) px' dpx' ddpx'
go 1 (a0 :+ 0) 0 0
{-# SPECIALISE maxRealFloat @Float #-}
{-# SPECIALISE maxRealFloat @Double #-}
maxRealFloat :: forall r. RealFloat r => r
maxRealFloat = encodeFloat m n
where
!b = floatRadix @r 0
!e = floatDigits @r 0
!(_, !e') = floatRange @r 0
!m = b ^ e - 1
!n = e' - e
{-
for some reason GHC isn't able to optimise maxRealFloat @Double into
maxDouble :: Double
maxDouble = D# 1.7976931348623157e308##
-}
--------------------------------------------------------------------------------
-- Newton–Raphson
-- | Newton–Raphson root-finding with Armijo backtracking line search and
-- quadratic interpolation fallback.
newtonRaphson
:: forall r
. ( RealFloat r, Show r )
=> Word -- ^ maximum number of iterations
-> Int -- ^ desired digits of precision
-> ( r -> (# r, r #) ) -- ^ function and its derivative
-> r -- ^ initial guess
-> Maybe r
newtonRaphson maxIters digits f x0 =
let (# f_x0, f'_x0 #) = f x0
in go 0 x0 f_x0 f'_x0
where
!factor = encodeFloat 1 ( 1 - digits )
go :: Word -> r -> r -> r -> Maybe r
go !iters !x !f_x !f'_x
| f_x == 0
= Just x
| iters >= maxIters || f'_x == 0 -- trapped at a local extremum
= Nothing
| otherwise
= case newtonRaphsonStep factor f x f_x f'_x of
(# done | #) -> done
(# | (# x', fx', f'x' #) #) ->
go ( iters + 1 ) x' fx' f'x'
{-# SPECIALISE newtonRaphson @Float #-}
{-# SPECIALISE newtonRaphson @Double #-}
{-# INLINEABLE newtonRaphson #-}
newtonRaphsonStep
:: ( RealFloat r, Show r )
=> r
-> ( r -> (# r, r #) )
-> r -> r -> r
-> (# Maybe r | (# r, r, r #) #)
newtonRaphsonStep !factor f !x !f_x !f'_x = lineSearch 1
where
-- Armijo "sufficient decrease" parameter
!c1 = 1e-4
!g_0 = f_x * f_x
!p = -f_x / f'_x
-- Directional derivative of f(x)^2 along the Newton step
!slope = -2 * g_0
-- Backtracking line search
lineSearch !α
-- Termination criteria:
-- 1. Close enough to a solution.
| abs δ <= max epsilon ( abs ( x' * factor ) )
= (# Just x' | #)
-- 2. α got too small, the algorithm has stalled
| α < 1e-4
= (# Nothing | #)
-- Otherwise, either accept step or shrink α.
| otherwise
= let
!(# f_x', f'_x' #) = f x'
!g_α = f_x' * f_x'
in
-- Armijo condition for accepting step
if g_α <= g_0 + c1 * α * slope
then (# | (# x', f_x', f'_x' #) #)
else
-- Quadratic interpolation fallback
let !α_tmp = ( g_0 * α * α ) / ( g_α - g_0 + 2 * g_0 * α )
-- Clamp α to [0.1 * α, 0.5 * α] to prevent numerical instability
!α' = max ( 0.1 * α ) ( min ( 0.5 * α ) α_tmp )
in lineSearch α'
where
!δ = α * p
!x' = x + δ
{-# SPECIALISE newtonRaphsonStep @Float #-}
{-# SPECIALISE newtonRaphsonStep @Double #-}
{-# INLINEABLE newtonRaphsonStep #-}
--------------------------------------------------------------------------------
-- Modified Halley method
-- | Take a single step in the M2 modified Halley method.
--
-- Taken from @Some variants of Halley’s method with memory and their applications for solving several chemical problems@
-- by A. Cordero, H. Ramos & J.R. Torregrosa, J Math Chem 58, 751–774 (2020).
--
-- @https://doi.org/10.1007/s10910-020-01108-3@
halleyM2Step
:: ( RealFloat a, Show a )
=> (# a, (# a, a #) #) -> (# a, (# a, a #) #) -> a
halleyM2Step (# x_nm1, (# f_nm1, f'_nm1 #) #) (# x_n, (# f_n, f'_n #) #)
| nearZero dx
= if nearZero f'_n
then x_n -- Stalled: Derivative is zero, cannot take a Newton step
else x_n - ( f_n / f'_n ) -- simple Newton step fallback
| nearZero num && nearZero denom
= 0.001 * signum num * signum denom
| nearZero num
= num
| otherwise
= num / denom
where
!u = f_n * f_nm1 * (f_n - f_nm1)
!dx = x_n - x_nm1
!g1 = f_nm1 ^ ( 2 :: Int ) * f'_n
!g2 = f_n ^ ( 2 :: Int ) * f'_nm1
!num = (x_n + x_nm1) * u - dx * ( g1 * x_n + g2 * x_nm1)
!denom = 2 * u - dx * ( g1 + g2 )
{-# SPECIALISE halleyM2Step @Float #-}
{-# SPECIALISE halleyM2Step @Double #-}
{-# INLINEABLE halleyM2Step #-}
{-# SPECIALISE halleyM2 @Float #-}
{-# SPECIALISE halleyM2 @Double #-}
{-# INLINEABLE halleyM2 #-}
halleyM2
:: forall r
. ( RealFloat r, Show r )
=> Word -- ^ maximum number of iterations
-> Int -- ^ desired digits of precision
-> ( r -> (# r, r #) ) -- ^ function and its derivative
-> r -- ^ initial guess
-> Maybe r
halleyM2 maxIters digits f x0 =
let
y0 = (# x0, df_x0 #)
!df_x0@(# f_x0, f'_x0 #) = f x0
!factor = encodeFloat 1 ( 1 - digits )
-- Start off with a Newton step to get the second initial x value.
in case newtonRaphsonStep factor f x0 f_x0 f'_x0 of
-- Newton converged immediately on the first step
(# Just x1 | #) -> Just x1
-- Newton stalled immediately (e.g., zero derivative)
(# Nothing | #) -> Nothing
-- Newton took a successful step, we have x1. Proceed with Halley M2.
(# | (# x1, f_x1, f'_x1 #) #) ->
let y1 = (# x1, (# f_x1, f'_x1 #) #)
in go 0 y0 y1
where
!factor = encodeFloat 1 ( 1 - digits )
go :: Word -> (# r, (# r, r #) #) -> (# r, (# r, r #) #) -> Maybe r
go i y_nm1 y_n@(# x_n, _ #) =
let !x_np1 = halleyM2Step y_nm1 y_n
in
if
| i >= maxIters
|| abs ( x_np1 - x_n ) <= abs ( x_n * factor )
-> Just x_np1
| otherwise
-> go (i+1) y_n (# x_np1, f x_np1 #)
--------------------------------------------------------------------------------
-- | @n@-dimensional Newton's method.
newton
:: forall n d
. ( KnownNat n, KnownNat d
, Show ( ℝ n ), Show ( ℝ d )
, NFData ( ℝ n )
, Module Double ( T ( ℝ n ) )
, MonomialBasis ( D 1 n ), Deg ( D 1 n ) ~ 1, Vars ( D 1 n ) ~ n
, Representable Double ( ℝ d )
, Representable Double ( ℝ n )
)
=> ( ℝ n -> D 1 n ( ℝ d ) )
-- ^ equations
-> ℝ n
-- ^ initial guess
-> ℝ n
newton f x0 = go 0 x0
where
maxIters :: Int
maxIters = 40
relError, absError :: Double
relError = 1e-9
absError = 1e-11
go :: Int -> ℝ n -> ℝ n
go !i !x
| i >= maxIters
= x
| norm ( unT δ ) < absError + relError * norm x
= x'
| otherwise
= go ( i + 1 ) x'
where
( _norm_fx, δ ) = newtonStep x
x' = unT $ T x ^-^ δ
norm :: forall i. Representable Double ( ℝ i ) => ℝ i -> Double
norm x =
sqrt $ sum ( ( \ x -> x * x ) <$> coordinates x )
{-# INLINEABLE norm #-}
newtonStep :: ℝ n -> ( Double, T ( ℝ n ) )
newtonStep x =
let df_x = f x
f_x :: ℝ d
f_x = df_x `monIndex` zeroMonomial
j_x :: Vec n ( ℝ d )
j_x = fmap ( \ i -> df_x `monIndex` linearMonomial i ) ( universe @n )
δ :: ℝ n
!δ = withEigenSem $
( \ ( Vec cols ) -> case cols of [ !c ] -> c; _ -> error "internal error in Newton's method" ) $
fromEigen $
Eigen.solve Eigen.JacobiSVD
( toEigen j_x :: Eigen.Matrix d n Double )
( toEigen ( Vec [ f_x ] ) :: Eigen.Matrix d 1 Double )
in ( norm f_x, T δ )
{-# INLINEABLE newton #-}
{-# SPECIALISE newton @1 @1 #-}
{-# SPECIALISE newton @1 @2 #-}
{-# SPECIALISE newton @1 @3 #-}
{-# SPECIALISE newton @2 @2 #-}
{-# SPECIALISE newton @2 @3 #-}
-- TODO: implement the W4 method?