tasty-bench-fit-0.1: src/Test/Tasty/Bench/Fit/Complexity.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE PostfixOperators #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ViewPatterns #-}
-- | Guess complexity from data.
module Test.Tasty.Bench.Fit.Complexity (
Complexity (..),
Measurement (..),
guessComplexity,
evalComplexity,
-- * Predicates
isConstant,
isLogarithmic,
isLinear,
isLinearithmic,
isQuadratic,
isCubic,
) where
import Control.DeepSeq (NFData)
import Data.List (intercalate, minimumBy)
import Data.List.Infinite (Infinite (..), (...))
import qualified Data.List.NonEmpty as NE
import Data.Map (Map)
import qualified Data.Map as M
import Data.Ord (comparing)
import GHC.Generics (Generic)
import Math.Regression.Simple (
Fit (..),
V2 (..),
levenbergMarquardt1WithYerrors,
levenbergMarquardt2WithYerrors,
linear,
)
import Text.Printf (printf)
import Prelude hiding (log)
import qualified Prelude as P
#ifdef DEBUG
import Debug.Trace
#endif
log :: Word -> Double
log x = if x >= 1 then P.log (d x) else 0
-- | 'Complexity' @a@ @b@ @k@ represents a time complexity
-- \( k \, x^a \log^b x \), where \( x \) is problem's size.
data Complexity = Complexity
{ cmplVarPower :: !Double
, cmplLogPower :: !Word
, cmplMultiplier :: !Double
}
deriving (Eq, Ord, Generic)
instance NFData Complexity
-- | Is the complexity \( f(x) = k \)?
isConstant :: Complexity -> Bool
isConstant = \case
Complexity {cmplVarPower = 0, cmplLogPower = 0} -> True
_ -> False
-- | Is the complexity \( f(x) = k \log x \)?
isLogarithmic :: Complexity -> Bool
isLogarithmic = \case
Complexity {cmplVarPower = 0, cmplLogPower = 1} -> True
_ -> False
-- | Is the complexity \( f(x) = k \, x \)?
isLinear :: Complexity -> Bool
isLinear = \case
Complexity {cmplVarPower = 1, cmplLogPower = 0} -> True
_ -> False
-- | Is the complexity \( f(x) = k \, x \log x \)?
isLinearithmic :: Complexity -> Bool
isLinearithmic = \case
Complexity {cmplVarPower = 1, cmplLogPower = 1} -> True
_ -> False
-- | Is the complexity \( f(x) = k \, x^2 \)?
isQuadratic :: Complexity -> Bool
isQuadratic = \case
Complexity {cmplVarPower = 2, cmplLogPower = 0} -> True
_ -> False
-- | Is the complexity \( f(x) = k \, x^3 \)?
isCubic :: Complexity -> Bool
isCubic = \case
Complexity {cmplVarPower = 3, cmplLogPower = 0} -> True
_ -> False
instance Show Complexity where
show Complexity {..} =
intercalate " * " $
filter
(not . null)
[ case cmplMultiplier of
1 -> ""
_ -> printf "%.2g" cmplMultiplier
, case cmplVarPower of
0 -> ""
1 -> "x"
_ -> "x ^ " <> round3 cmplVarPower
, case cmplLogPower of
0 -> ""
1 -> "log x"
_ -> "(log x) ^ " <> show cmplLogPower
]
where
round3 :: Double -> String
round3 x = if x == d x' then show x' else printf "%.3f" x
where
x' :: Word
x' = round x
-- | Evaluate time complexity for a given size of the problem.
evalComplexity :: Complexity -> Word -> Double
evalComplexity Complexity {..} x =
cmplMultiplier * d x ** cmplVarPower * log x ^ cmplLogPower
bestOf :: [(Complexity, Double)] -> Complexity
bestOf = fst . minimumBy (comparing weigh)
where
weigh (Complexity {..}, wssr) =
wssr
* powPenalty
-- Penalty for high power of logarithm.
* d (max 1 cmplLogPower)
where
-- Penalty for non-integer power.
powPenalty :: Double
powPenalty = case abs (cmplVarPower - d (round cmplVarPower)) of
0 -> 1
-- Severe penalty for almost integer powers
diff ->
if diff < 0.05
then 100
else (if diff < 0.15 then 32 else 10)
-- | Represents a time measurement for a given problem's size.
data Measurement = Measurement
{ measTime :: !Double
, measStDev :: !Double
}
deriving (Eq, Ord, Generic)
instance Show Measurement where
show (Measurement t err) = printf "%.3g ± %.3g" t err
instance NFData Measurement
-- | Guess time complexity from a map where keys
-- are problem's sizes and values are time measurements (or instruction counts).
--
-- >>> :set -XNumDecimals
-- >>> guessComplexity $ Data.Map.fromList $ map (\(x, t) -> (x, Measurement t 1)) [(2, 4), (3, 10), (4, 15), (5, 25)]
-- 0.993 * x ^ 2
-- >>> guessComplexity $ Data.Map.fromList $ map (\(x, t) -> (x, Measurement t 1)) [(1e2, 2.1), (1e3, 2.9), (1e4, 4.1), (1e5, 4.9)]
-- 0.433 * log x
--
-- This function uses following simplifying assumptions:
--
-- * All coefficients are non-negative.
-- * The power of \( \log x \) ('cmplLogPower') is unlikely to be \( > 1 \).
-- * The power of \( x \) ('cmplVarPower') is unlikely to be fractional.
--
-- This function is unsuitable to guess
-- [superpolynomial](https://en.wikipedia.org/wiki/Time_complexity#Superpolynomial_time)
-- and higher classes of complexity.
guessComplexity :: Map Word Measurement -> Complexity
guessComplexity xys =
trace'
("guessComplexity " ++ show (M.assocs xys))
bestOf
(takeUntilLocalMin cmpls)
where
cmpls :: Infinite ((Complexity, Double), (Complexity, Double))
cmpls = fmap (guessComplexityForFixedLog xys) (0 ...)
takeUntilLocalMin
:: Infinite ((Complexity, Double), (Complexity, Double))
-> [(Complexity, Double)]
takeUntilLocalMin ((c1, c2) :< (c3, c4) :< cs)
| snd c1 > snd c3 || snd c2 > snd c4 =
c1 : c2 : takeUntilLocalMin ((c3, c4) :< cs)
| otherwise =
[c1, c2]
guessComplexityForFixedLog
:: Map Word Measurement
-> Word
-> ((Complexity, Double), (Complexity, Double))
guessComplexityForFixedLog xys logPow = trace' msg res
where
-- varPow might be negative here, so always pass it through mkCmpl
V2 _ varPow = guessComplexityWithoutLog xys logPow
mkCmpl varPow' = guessComplexityForFixedPowAndLog xys varPow' logPow
res@((res1, wssr1), (res2, wssr2)) =
(mkCmpl (max 0 varPow), mkCmpl (d (round varPow)))
msg =
printf
"forFixedLog:\n\t%s, RSS %.4g\n\t%s, RSS %.4g"
(show res1)
wssr1
(show res2)
wssr2
guessComplexityWithoutLog :: Map Word Measurement -> Word -> V2
guessComplexityWithoutLog (M.assocs -> xys) logPow = finish
where
-- Fit y_i ~ a x_i^b, which is equivalent to log y_i ~ log a + b log x_i.
-- This is not ideal, because minimizing the sum of (log y_i - log a - b log x_i) ^ 2
-- is not equivalent to minimizing the sum of (y_i - a * x_i^b) ^ 2, but close enough,
-- so we are going to use it as a starting point for Levenberg-Marquardt.
V2 b0 la0 =
linear (\(x, Measurement y _) -> (log x, P.log (y / log x ^ logPow))) xys
start = V2 (exp la0) (max 0 b0)
Fit {fitParams = finish} =
NE.last $
levenbergMarquardt2WithYerrors
( \(V2 mult varPow) (x, Measurement y err) ->
( y
, mult * d x ** varPow * log x ^ logPow
, V2
(d x ** varPow * log x ^ logPow)
(mult * d x ** varPow * log x ^ (logPow + 1))
, err
)
)
start
xys
guessComplexityForFixedPowAndLog
:: Map Word Measurement
-> Double
-> Word
-> (Complexity, Double)
guessComplexityForFixedPowAndLog (M.assocs -> xys) varPow logPow = (res, wssr)
where
-- We want to find a which minimizes \sum_i (y_i - a f(x_i))^2 for f(x) = x^b * log^c x.
-- Then d/da = 0 means that \sum_i (2 a f(x_i)^2 - 2 f(x_i) y_i) = 0
-- or equivalently a = \sum_i f(x_i) y_i / \sum_i x_i^2.
eval x = d x ** varPow * log x ^ logPow
sumXY = sum $ map (\(x, Measurement y _) -> eval x * y) xys
sumX2 = sum $ map (\(x, _) -> eval x ** 2) xys
start = sumXY / sumX2
ft =
NE.last $
levenbergMarquardt1WithYerrors
( \mult (x, Measurement y err) ->
( y
, mult * d x ** varPow * log x ^ logPow
, d x ** varPow * log x ^ logPow
, err
)
)
start
xys
res =
Complexity
{ cmplMultiplier = fitParams ft
, cmplVarPower = varPow
, cmplLogPower = logPow
}
wssr = fitWSSR ft
d :: Word -> Double
d = fromIntegral
trace' :: String -> b -> b
#ifdef DEBUG
trace' = trace
#else
trace' = const id
#endif