tdigest 0.1 → 0.2
raw patch · 18 files changed
+1644/−1139 lines, 18 filesdep +transformersdep ~basedep ~base-compatdep ~doctestsetup-changed
Dependencies added: transformers
Dependency ranges changed: base, base-compat, doctest, reducers, semigroupoids, semigroups, tasty, tasty-quickcheck, vector
Files
- CHANGELOG.md +4/−0
- Setup.hs +33/−0
- Setup.lhs +0/−182
- src/Data/TDigest.hs +2/−127
- src/Data/TDigest/Internal.hs +8/−487
- src/Data/TDigest/NonEmpty.hs +2/−169
- src/Data/TDigest/Postprocess.hs +26/−158
- src/Data/TDigest/Postprocess/Internal.hs +209/−0
- src/Data/TDigest/Tree.hs +116/−0
- src/Data/TDigest/Tree/Internal.hs +492/−0
- src/Data/TDigest/Tree/NonEmpty.hs +166/−0
- src/Data/TDigest/Tree/Postprocess.hs +76/−0
- src/Data/TDigest/Vector.hs +106/−0
- src/Data/TDigest/Vector/Internal.hs +284/−0
- src/Data/TDigest/Vector/NonEmpty.hs +1/−0
- src/Data/TDigest/Vector/Postprocess.hs +77/−0
- tdigest.cabal +40/−16
- tests/Tests.hs +2/−0
CHANGELOG.md view
@@ -1,3 +1,7 @@+## 0.2++- Add `Data.TDigest.Vector` module.+ ## 0.1 - Add `validateHistogram` and `debugPrint`
+ Setup.hs view
@@ -0,0 +1,33 @@+{-# LANGUAGE CPP #-}+{-# OPTIONS_GHC -Wall #-}+module Main (main) where++#ifndef MIN_VERSION_cabal_doctest+#define MIN_VERSION_cabal_doctest(x,y,z) 0+#endif++#if MIN_VERSION_cabal_doctest(1,0,0)++import Distribution.Extra.Doctest ( defaultMainWithDoctests )+main :: IO ()+main = defaultMainWithDoctests "doctests"++#else++#ifdef MIN_VERSION_Cabal+-- If the macro is defined, we have new cabal-install,+-- but for some reason we don't have cabal-doctest in package-db+--+-- Probably we are running cabal sdist, when otherwise using new-build+-- workflow+#warning You are configuring this package without cabal-doctest installed. \+ The doctests test-suite will not work as a result. \+ To fix this, install cabal-doctest before configuring.+#endif++import Distribution.Simple++main :: IO ()+main = defaultMain++#endif
− Setup.lhs
@@ -1,182 +0,0 @@-\begin{code}-{-# LANGUAGE CPP #-}-{-# LANGUAGE OverloadedStrings #-}-module Main (main) where--#ifndef MIN_VERSION_cabal_doctest-#define MIN_VERSION_cabal_doctest(x,y,z) 0-#endif---#if MIN_VERSION_cabal_doctest(1,0,0)-import Distribution.Extra.Doctest ( defaultMainWithDoctests )-#else---- Otherwise we provide a shim--#ifndef MIN_VERSION_Cabal-#define MIN_VERSION_Cabal(x,y,z) 0-#endif-#ifndef MIN_VERSION_directory-#define MIN_VERSION_directory(x,y,z) 0-#endif-#if MIN_VERSION_Cabal(1,24,0)-#define InstalledPackageId UnitId-#endif--import Control.Monad ( when )-import Data.List ( nub )-import Data.String ( fromString )-import Distribution.Package ( InstalledPackageId )-import Distribution.Package ( PackageId, Package (..), packageVersion )-import Distribution.PackageDescription ( PackageDescription(), TestSuite(..) , Library (..), BuildInfo (..))-import Distribution.Simple ( defaultMainWithHooks, UserHooks(..), simpleUserHooks )-import Distribution.Simple.Utils ( rewriteFile, createDirectoryIfMissingVerbose )-import Distribution.Simple.BuildPaths ( autogenModulesDir )-import Distribution.Simple.Setup ( BuildFlags(buildDistPref, buildVerbosity), fromFlag)-import Distribution.Simple.LocalBuildInfo ( withPackageDB, withLibLBI, withTestLBI, LocalBuildInfo(), ComponentLocalBuildInfo(componentPackageDeps), compiler )-import Distribution.Simple.Compiler ( showCompilerId , PackageDB (..))-import Distribution.Text ( display , simpleParse )-import System.FilePath ( (</>) )--#if MIN_VERSION_Cabal(1,25,0)-import Distribution.Simple.BuildPaths ( autogenComponentModulesDir )-#endif--#if MIN_VERSION_directory(1,2,2)-import System.Directory (makeAbsolute)-#else-import System.Directory (getCurrentDirectory)-import System.FilePath (isAbsolute)--makeAbsolute :: FilePath -> IO FilePath-makeAbsolute p | isAbsolute p = return p- | otherwise = do- cwd <- getCurrentDirectory- return $ cwd </> p-#endif--generateBuildModule :: String -> BuildFlags -> PackageDescription -> LocalBuildInfo -> IO ()-generateBuildModule testsuiteName flags pkg lbi = do- let verbosity = fromFlag (buildVerbosity flags)- let distPref = fromFlag (buildDistPref flags)-- -- Package DBs- let dbStack = withPackageDB lbi ++ [ SpecificPackageDB $ distPref </> "package.conf.inplace" ]- let dbFlags = "-hide-all-packages" : packageDbArgs dbStack-- withLibLBI pkg lbi $ \lib libcfg -> do- let libBI = libBuildInfo lib-- -- modules- let modules = exposedModules lib ++ otherModules libBI- -- it seems that doctest is happy to take in module names, not actual files!- let module_sources = modules-- -- We need the directory with library's cabal_macros.h!-#if MIN_VERSION_Cabal(1,25,0)- let libAutogenDir = autogenComponentModulesDir lbi libcfg-#else- let libAutogenDir = autogenModulesDir lbi-#endif-- -- Lib sources and includes- iArgs <- mapM (fmap ("-i"++) . makeAbsolute) $ libAutogenDir : hsSourceDirs libBI- includeArgs <- mapM (fmap ("-I"++) . makeAbsolute) $ includeDirs libBI-- -- CPP includes, i.e. include cabal_macros.h- let cppFlags = map ("-optP"++) $- [ "-include", libAutogenDir ++ "/cabal_macros.h" ]- ++ cppOptions libBI-- withTestLBI pkg lbi $ \suite suitecfg -> when (testName suite == fromString testsuiteName) $ do-- -- get and create autogen dir-#if MIN_VERSION_Cabal(1,25,0)- let testAutogenDir = autogenComponentModulesDir lbi suitecfg-#else- let testAutogenDir = autogenModulesDir lbi-#endif- createDirectoryIfMissingVerbose verbosity True testAutogenDir-- -- write autogen'd file- rewriteFile (testAutogenDir </> "Build_doctests.hs") $ unlines- [ "module Build_doctests where"- , ""- -- -package-id etc. flags- , "pkgs :: [String]"- , "pkgs = " ++ (show $ formatDeps $ testDeps libcfg suitecfg)- , ""- , "flags :: [String]"- , "flags = " ++ show (iArgs ++ includeArgs ++ dbFlags ++ cppFlags)- , ""- , "module_sources :: [String]"- , "module_sources = " ++ show (map display module_sources)- ]- where- -- we do this check in Setup, as then doctests don't need to depend on Cabal- isOldCompiler = maybe False id $ do- a <- simpleParse $ showCompilerId $ compiler lbi- b <- simpleParse "7.5"- return $ packageVersion (a :: PackageId) < b-- formatDeps = map formatOne- formatOne (installedPkgId, pkgId)- -- The problem is how different cabal executables handle package databases- -- when doctests depend on the library- | packageId pkg == pkgId = "-package=" ++ display pkgId- | otherwise = "-package-id=" ++ display installedPkgId-- -- From Distribution.Simple.Program.GHC- packageDbArgs :: [PackageDB] -> [String]- packageDbArgs | isOldCompiler = packageDbArgsConf- | otherwise = packageDbArgsDb-- -- GHC <7.6 uses '-package-conf' instead of '-package-db'.- packageDbArgsConf :: [PackageDB] -> [String]- packageDbArgsConf dbstack = case dbstack of- (GlobalPackageDB:UserPackageDB:dbs) -> concatMap specific dbs- (GlobalPackageDB:dbs) -> ("-no-user-package-conf")- : concatMap specific dbs- _ -> ierror- where- specific (SpecificPackageDB db) = [ "-package-conf=" ++ db ]- specific _ = ierror- ierror = error $ "internal error: unexpected package db stack: "- ++ show dbstack-- -- GHC >= 7.6 uses the '-package-db' flag. See- -- https://ghc.haskell.org/trac/ghc/ticket/5977.- packageDbArgsDb :: [PackageDB] -> [String]- -- special cases to make arguments prettier in common scenarios- packageDbArgsDb dbstack = case dbstack of- (GlobalPackageDB:UserPackageDB:dbs)- | all isSpecific dbs -> concatMap single dbs- (GlobalPackageDB:dbs)- | all isSpecific dbs -> "-no-user-package-db"- : concatMap single dbs- dbs -> "-clear-package-db"- : concatMap single dbs- where- single (SpecificPackageDB db) = [ "-package-db=" ++ db ]- single GlobalPackageDB = [ "-global-package-db" ]- single UserPackageDB = [ "-user-package-db" ]- isSpecific (SpecificPackageDB _) = True- isSpecific _ = False--testDeps :: ComponentLocalBuildInfo -> ComponentLocalBuildInfo -> [(InstalledPackageId, PackageId)]-testDeps xs ys = nub $ componentPackageDeps xs ++ componentPackageDeps ys--defaultMainWithDoctests :: String -> IO ()-defaultMainWithDoctests testSuiteName = defaultMainWithHooks simpleUserHooks- { buildHook = \pkg lbi hooks flags -> do- generateBuildModule testSuiteName flags pkg lbi- buildHook simpleUserHooks pkg lbi hooks flags- }--#endif--main :: IO ()-main = defaultMainWithDoctests "doctests"--\end{code}
src/Data/TDigest.hs view
@@ -1,127 +1,2 @@-{-# LANGUAGE ScopedTypeVariables #-}--- |--- A new data structure for accurate on-line accumulation of rank-based--- statistics such as quantiles and trimmed means.--- .--- See original paper: "Computing extremely accurate quantiles using t-digest"--- by Ted Dunning and Otmar Ertl for more details--- <https://github.com/tdunning/t-digest/blob/master/docs/t-digest-paper/histo.pdf>.------ === Examples------ >>> quantile 0.99 (tdigest [1..1000] :: TDigest 25)--- Just 990.5------ >>> quantile 0.99 (tdigest [1..1000] :: TDigest 3)--- Just 989.0...------ t-Digest is more precise in tails, especially median is imprecise:------ >>> median (forceCompress $ tdigest [1..1000] :: TDigest 25)--- Just 497.6...------ === Semigroup------ This operation isn't strictly associative, but statistical--- variables shouldn't be affected.------ >>> let td xs = tdigest xs :: TDigest 10------ >>> median (td [1..500] <> (td [501..1000] <> td [1001..1500]))--- Just 802...------ >>> median ((td [1..500] <> td [501..1000]) <> td [1001..1500])--- Just 726...------ The linear is worst-case scenario:------ >>> let td' xs = tdigest (fairshuffle xs) :: TDigest 10------ >>> median (td' [1..500] <> (td' [501..1000] <> td' [1001..1500]))--- Just 750.3789...------ >>> median ((td' [1..500] <> td' [501..1000]) <> td' [1001..1500])--- Just 750.3789...----module Data.TDigest (- -- * Construction- TDigest,- tdigest,-- -- ** Population- singleton,- insert,- insert',-- -- * Compression- --- -- |- --- -- >>> let digest = foldl' (flip insert') mempty [0..1000] :: TDigest 10- -- >>> (size digest, size $ compress digest)- -- (1001,52)- --- -- >>> (quantile 0.1 digest, quantile 0.1 $ compress digest)- -- (Just 99.6...,Just 89.7...)- --- -- /Note:/ when values are inserted in more random order,- -- t-Digest self-compresses on the fly:- --- -- >>> let digest = foldl' (flip insert') mempty (fairshuffle [0..1000]) :: TDigest 10- -- >>> (size digest, size $ compress digest, size $ forceCompress digest)- -- (78,78,48)- --- -- >>> quantile 0.1 digest- -- Just 98.9...- --- compress,- forceCompress,-- -- * Statistics- totalWeight,- minimumValue,- maximumValue,- -- ** Histogram- histogram,- HistBin (..),- -- ** Percentile- median,- quantile,- -- ** Mean & Variance- mean,- variance,- stddev,- -- ** CDF- cdf,- icdf,-- -- * Debug- valid,- validate,- debugPrint,- validateHistogram,- ) where--import Prelude ()-import Prelude.Compat--import Data.TDigest.Internal-import Data.TDigest.Postprocess---- | Standard deviation, square root of variance.------ >>> stddev (tdigest $ fairshuffle [0..100] :: TDigest 10)--- Just 29.1...----stddev :: TDigest comp -> Maybe Double-stddev = fmap sqrt . variance---- $setup--- >>> :set -XDataKinds--- >>> import Prelude.Compat--- >>> import Data.List.Compat (foldl')--- >>> import Data.Semigroup ((<>))------ >>> let merge [] ys = []; merge xs [] = xs; merge (x:xs) (y:ys) = x : y : merge xs ys--- >>> let fairshuffle' xs = uncurry merge (splitAt (length xs `div` 2) xs)--- >>> let fairshuffle xs = iterate fairshuffle' xs !! 5+module Data.TDigest (module Data.TDigest.Tree) where+import Data.TDigest.Tree
src/Data/TDigest/Internal.hs view
@@ -1,458 +1,22 @@-{-# LANGUAGE DataKinds #-}-{-# LANGUAGE KindSignatures #-}-{-# LANGUAGE MultiParamTypeClasses #-}-{-# LANGUAGE ScopedTypeVariables #-}--- | Internals of 'TDigest'.------ Tree implementation is based on /Adams’ Trees Revisited/ by Milan Straka--- <http://fox.ucw.cz/papers/bbtree/bbtree.pdf> module Data.TDigest.Internal where -import Prelude ()-import Prelude.Compat-import Control.DeepSeq (NFData (..))-import Control.Monad.ST (ST, runST)-import Data.Binary (Binary (..))-import Data.Either (isRight)-import Data.Foldable (toList)-import Data.List.Compat (foldl')-import Data.Ord (comparing)-import Data.Proxy (Proxy (..))-import Data.Semigroup (Semigroup (..))-import Data.Semigroup.Reducer (Reducer (..))-import GHC.TypeLits (KnownNat, Nat, natVal)--import qualified Data.Vector.Algorithms.Heap as VHeap-import qualified Data.Vector.Unboxed as VU-import qualified Data.Vector.Unboxed.Mutable as MVU--{-# INLINE assert #-}-assert :: Bool -> String -> a -> a-assert _ _ = \x -> x-{--assert False msg _ = error msg-assert True _ x = x--}------------------------------------------------------------------------------------ TDigest--------------------------------------------------------------------------------- -- TODO: make newtypes type Mean = Double type Weight = Double type Centroid = (Mean, Weight) type Size = Int --- | 'TDigest' is a tree of centroids.------ @compression@ is a @1/δ@. The greater the value of @compression@ the less--- likely value merging will happen.-data TDigest (compression :: Nat)- -- | Tree node- = Node- {-# UNPACK #-} !Size -- size of this tree/centroid- {-# UNPACK #-} !Mean -- mean of the centroid- {-# UNPACK #-} !Weight -- weight of the centrod- {-# UNPACK #-} !Weight -- total weight of the tree- !(TDigest compression) -- left subtree- !(TDigest compression) -- right subtree- -- | Empty tree- | Nil- deriving (Show)---- [Note: keep min & max in the tree]------ We tried it, but it seems the alloc/update cost is bigger than--- re-calculating them on need (it's O(log n) - calculation!)---- [Note: singleton node]--- We tried to add one, but haven't seen change in performance---- [Note: inlining balanceR and balanceL]--- We probably can squueze some performance by making--- 'balanceL' and 'balanceR' check arguments only once (like @containers@ do)--- and not use 'node' function.--- *But*, the benefit vs. code explosion is not yet worth.--instance KnownNat comp => Semigroup (TDigest comp) where- (<>) = combineDigest---- | Both 'cons' and 'snoc' are 'insert'-instance KnownNat comp => Reducer Double (TDigest comp) where- cons = insert- snoc = flip insert- unit = singleton--instance KnownNat comp => Monoid (TDigest comp) where- mempty = emptyTDigest- mappend = combineDigest---- | 'TDigest' has only strict fields.-instance NFData (TDigest comp) where- rnf x = x `seq` ()---- | 'TDigest' isn't compressed after de-serialisation,--- but it can be still smaller.-instance KnownNat comp => Binary (TDigest comp) where- put = put . getCentroids- get = foldl' (flip insertCentroid) emptyTDigest . lc <$> get- where- lc :: [Centroid] -> [Centroid]- lc = id--getCentroids :: TDigest comp -> [Centroid]-getCentroids = ($ []) . go- where- go Nil = id- go (Node _ x w _ l r) = go l . ((x,w) : ) . go r---- | Total count of samples.------ >>> totalWeight (tdigest [1..100] :: TDigest 5)--- 100.0----totalWeight :: TDigest comp -> Weight-totalWeight Nil = 0-totalWeight (Node _ _ _ tw _ _) = tw--size :: TDigest comp -> Int-size Nil = 0-size (Node s _ _ _ _ _) = s---- | Center of left-most centroid. Note: may be different than min element inserted.------ >>> minimumValue (tdigest [1..100] :: TDigest 3)--- 1.0----minimumValue :: TDigest comp -> Mean-minimumValue = go posInf- where- go acc Nil = acc- go _acc (Node _ x _ _ l _) = go x l---- | Center of right-most centroid. Note: may be different than max element inserted.------ >>> maximumValue (tdigest [1..100] :: TDigest 3)--- 99.0----maximumValue :: TDigest comp -> Mean-maximumValue = go negInf- where- go acc Nil = acc- go _acc (Node _ x _ _ _ r) = go x r- ---------------------------------------------------------------------------------- Implementation----------------------------------------------------------------------------------emptyTDigest :: TDigest comp-emptyTDigest = Nil--combineDigest- :: KnownNat comp- => TDigest comp- -> TDigest comp- -> TDigest comp-combineDigest a Nil = a-combineDigest Nil b = b-combineDigest a@(Node n _ _ _ _ _) b@(Node m _ _ _ _ _)- -- TODO: merge first, then shuffle and insert (part of compress)- | n < m = compress $ foldl' (flip insertCentroid) b (getCentroids a)- | otherwise = compress $ foldl' (flip insertCentroid) a (getCentroids b)--insertCentroid- :: forall comp. KnownNat comp- => Centroid- -> TDigest comp- -> TDigest comp-insertCentroid (x, w) Nil = singNode x w-insertCentroid (mean, weight) td = go 0 mean weight False td- where- -- New weight of the tree- n :: Weight- n = totalWeight td + weight-- -- 1/delta- compression :: Double- compression = fromInteger $ natVal (Proxy :: Proxy comp)-- go- :: Weight -- weight to the left of this tree- -> Mean -- mean to insert- -> Weight -- weight to insert- -> Bool -- should insert everything.- -- if we merged somewhere on top, rest is inserted as is- -> TDigest comp -- subtree to insert/merge centroid into- -> TDigest comp- go _ newX newW _ Nil = singNode newX newW- go cum newX newW e (Node s x w tw l r) = case compare newX x of- -- Exact match, insert here- EQ -> Node s x (w + newW) (tw + newW) l r -- node x (w + newW) l r-- -- there is *no* room to insert into this node- LT | thr <= w -> balanceL x w (go cum newX newW e l) r- GT | thr <= w -> balanceR x w l (go (cum + totalWeight l + w) newX newW e r)-- -- otherwise go left ... or later right- LT | e -> balanceL x w (go cum newX newW e l) r- LT -> case l of- -- always create a new node- Nil -> case mrw of- Nothing -> node' s nx nw (tw + newW) Nil r- Just rw -> balanceL nx nw (go cum newX rw True Nil) r- Node _ _ _ _ _ _- | lmax < newX && abs (newX - x) < abs (newX - lmax) {- && newX < x -} -> case mrw of- Nothing -> node' s nx nw (tw + nw - w) l r- -- in this two last LT cases, we have to recalculate size- Just rw -> balanceL nx nw (go cum newX rw True l) r- | otherwise -> balanceL x w (go cum newX newW e l) r- where- lmax = maximumValue l-- -- ... or right- GT | e -> balanceR x w l (go (cum + totalWeight l + w) newX newW True r)- GT -> case r of- Nil -> case mrw of- Nothing -> node' s nx nw (tw + newW) l Nil- Just rw -> balanceR nx nw l (go (cum + totalWeight l + nw) newX rw True Nil)- Node _ _ _ _ _ _- | rmin > newX && abs (newX - x) < abs (newX - rmin) {- && newX > x -} -> case mrw of- Nothing -> node' s nx nw (tw + newW) l r- -- in this two last GT cases, we have to recalculate size- Just rw -> balanceR nx nw l (go (cum + totalWeight l + nw) newX rw True r)- | otherwise -> balanceR x w l (go (cum + totalWeight l + w) newX newW e r)- where- rmin = minimumValue r- where- -- quantile approximation of current node- cum' = cum + totalWeight l- q = (w / 2 + cum') / n-- -- threshold, max size of current node/centroid- thr = {- traceShowId $ traceShow (n, q) $ -} threshold n q compression-- -- We later use nx, nw and mrw:-- -- max size of current node- dw :: Weight- mrw :: Maybe Weight- (dw, mrw) =- let diff = assert (thr > w) "threshold should be larger than current node weight"- $ w + newW - thr- in if diff < 0 -- i.e. there is room- then (newW, Nothing)- else (thr - w, Just $ diff)-- -- the change of current node- (nx, nw) = {- traceShowId $ traceShow (newX, newW, x, dw, mrw) $ -} combinedCentroid x w x dw---- | Constructor which calculates size and total weight.-node :: Mean -> Weight -> TDigest comp -> TDigest comp -> TDigest comp-node x w l r = Node- (1 + size l + size r)- x w- (w + totalWeight l + totalWeight r)- l r---- | Balance after right insertion.-balanceR :: Mean -> Weight -> TDigest comp -> TDigest comp -> TDigest comp-balanceR x w l r- | size l + size r <= 1 = node x w l r- | size r > balOmega * size l = case r of- Nil -> error "balanceR: impossible happened"- (Node _ rx rw _ Nil rr) ->- -- assert (0 < balAlpha * size rr) "balanceR" $- -- single left rotation- node rx rw (node x w l Nil) rr- (Node _ rx rw _ rl rr)- | size rl < balAlpha * size rr ->- -- single left rotation- node rx rw (node x w l rl) rr- (Node _ rx rw _ (Node _ rlx rlw _ rll rlr) rr) ->- -- double left rotation- node rlx rlw (node x w l rll) (node rx rw rlr rr)- | otherwise = node x w l r---- | Balance after left insertion.-balanceL :: Mean -> Weight -> TDigest comp -> TDigest comp -> TDigest comp-balanceL x w l r- | size l + size r <= 1 = node x w l r- | size l > balOmega * size r = case l of- Nil -> error "balanceL: impossible happened"- (Node _ lx lw _ ll Nil) ->- -- assert (0 < balAlpha * size ll) "balanceL" $- -- single right rotation- node lx lw ll (node x w Nil r)- (Node _ lx lw _ ll lr)- | size lr < balAlpha * size ll ->- -- single right rotation- node lx lw ll (node x w lr r)- (Node _ lx lw _ ll (Node _ lrx lrw _ lrl lrr)) ->- -- double left rotation- node lrx lrw (node lx lw ll lrl) (node x w lrr r)- | otherwise = node x w l r---- | Alias to 'Node'-node' :: Int -> Mean -> Weight -> Weight -> TDigest comp -> TDigest comp -> TDigest comp-node' = Node---- | Create singular node.-singNode :: Mean -> Weight -> TDigest comp-singNode x w = Node 1 x w w Nil Nil---- | Add two weighted means together.-combinedCentroid- :: Mean -> Weight- -> Mean -> Weight- -> Centroid-combinedCentroid x w x' w' =- ( (x * w + x' * w') / w'' -- this is probably not num. stable- , w''- )- where- w'' = w + w'---- | Calculate the threshold, i.e. maximum weight of centroid.-threshold- :: Double -- ^ total weight- -> Double -- ^ quantile- -> Double -- ^ compression (1/δ)- -> Double-threshold n q compression = 4 * n * q * (1 - q) / compression------------------------------------------------------------------------------------ Compression------------------------------------------------------------------------------------ | Compress 'TDigest'.------ Reinsert the centroids in "better" order (in original paper: in random)--- so they have opportunity to merge.------ Compression will happen only if size is both:--- bigger than @'relMaxSize' * comp@ and bigger than 'absMaxSize'.----compress :: forall comp. KnownNat comp => TDigest comp -> TDigest comp-compress Nil = Nil-compress td- | size td > relMaxSize * compression && size td > absMaxSize- = forceCompress td- | otherwise- = td- where- compression = fromInteger $ natVal (Proxy :: Proxy comp)---- | Perform compression, even if current size says it's not necessary.-forceCompress :: forall comp. KnownNat comp => TDigest comp -> TDigest comp-forceCompress Nil = Nil-forceCompress td =- foldl' (flip insertCentroid) emptyTDigest $ fmap fst $ VU.toList centroids- where- -- Centroids are shuffled based on space- centroids :: VU.Vector (Centroid, Double)- centroids = runST $ do- v <- toMVector td- -- sort by cumulative weight- VHeap.sortBy (comparing snd) v- f <- VU.unsafeFreeze v- pure f--toMVector- :: forall comp s. KnownNat comp- => TDigest comp -- ^ t-Digest- -> ST s (VU.MVector s (Centroid, Double)) -- ^ return also a "space left in the centroid" value for "shuffling"-toMVector td = do- v <- MVU.new (size td)- (i, cum) <- go v (0 :: Int) (0 :: Double) td- pure $ assert (i == size td && abs (cum - totalWeight td) < 1e-6) "traversal in toMVector:" v- where- go _ i cum Nil = pure (i, cum)- go v i cum (Node _ x w _ l r) = do- (i', cum') <- go v i cum l- MVU.unsafeWrite v i' ((x, w), space w cum')- go v (i' + 1) (cum' + w) r-- n = totalWeight td- compression = fromInteger $ natVal (Proxy :: Proxy comp)-- space w cum = thr - w- where- q = (w / 2 + cum) / n- thr = threshold n q compression------------------------------------------------------------------------------------ Params------------------------------------------------------------------------------------ | Relative size parameter. Hard-coded value: 25.-relMaxSize :: Int-relMaxSize = 25---- | Absolute size parameter. Hard-coded value: 1000.-absMaxSize :: Int-absMaxSize = 1000------------------------------------------------------------------------------------ Tree balance parameters----------------------------------------------------------------------------------balOmega :: Int-balOmega = 3--balAlpha :: Int-balAlpha = 2---- balDelta = 0------------------------------------------------------------------------------------ Debug+-- Assert ------------------------------------------------------------------------------- --- | Output the 'TDigest' tree.-debugPrint :: TDigest comp -> IO ()-debugPrint td = go 0 td- where- go i Nil = putStrLn $ replicate (i * 3) ' ' ++ "Nil"- go i (Node s m w tw l r) = do- go (i + 1) l- putStrLn $ replicate (i * 3) ' ' ++ "Node " ++ show (s,m,w,tw)- go (i + 1) r---- | @'isRight' . 'validate'@-valid :: TDigest comp -> Bool-valid = isRight . validate---- | Check various invariants in the 'TDigest' tree.-validate :: TDigest comp -> Either String (TDigest comp)-validate td- | not (all sizeValid centroids) = Left "invalid sizes"- | not (all weightValid centroids) = Left "invalid weights"- | not (all orderValid centroids) = Left "invalid ordering"- | not (all balanced centroids) = Left "tree is ill-balanced"- | otherwise = Right td- where- centroids = goc td-- goc Nil = []- goc n@(Node _ _ _ _ l r) = n : goc l ++ goc r-- sizeValid Nil = True- sizeValid (Node s _ _ _ l r) = s == size l + size r + 1-- weightValid Nil = True- weightValid (Node _ _ w tw l r) = eq tw $ w + totalWeight l + totalWeight r-- orderValid Nil = True- orderValid (Node _ _ _ _ Nil Nil) = True- orderValid (Node _ x _ _ (Node _ lx _ _ _ _) Nil) = lx < x- orderValid (Node _ x _ _ Nil (Node _ rx _ _ _ _)) = x < rx- orderValid (Node _ x _ _ (Node _ lx _ _ _ _) (Node _ rx _ _ _ _)) = lx < x && x < rx-- balanced Nil = True- balanced (Node _ _ _ _ l r) =- size l <= max 1 (balOmega * size r) &&- size r <= max 1 (balOmega * size l)+{-# INLINE assert #-}+assert :: Bool -> String -> a -> a+assert _ _ = \x -> x+{-+assert False msg _ = error msg+assert True _ x = x+-} ------------------------------------------------------------------------------- -- Double helpers@@ -466,46 +30,3 @@ posInf :: Double posInf = 1/0------------------------------------------------------------------------------------ Higher level helpers------------------------------------------------------------------------------------ | Insert single value into 'TDigest'.-insert- :: KnownNat comp- => Double -- ^ element- -> TDigest comp- -> TDigest comp-insert x = compress . insert' x---- | Insert single value, don't compress 'TDigest' even if needed.------ For sensibly bounded input, it makes sense to let 'TDigest' grow (it might--- grow linearly in size), and after that compress it once.-insert'- :: KnownNat comp- => Double -- ^ element- -> TDigest comp- -> TDigest comp-insert' x = insertCentroid (x, 1)---- | Make a 'TDigest' of a single data point.-singleton :: KnownNat comp => Double -> TDigest comp-singleton x = insert x emptyTDigest---- | Strict 'foldl'' over 'Foldable' structure.-tdigest :: (Foldable f, KnownNat comp) => f Double -> TDigest comp-tdigest = foldl' insertChunk emptyTDigest . chunks . toList- where- -- compress after each chunk, forceCompress at the very end.- insertChunk td xs =- compress (foldl' (flip insert') td xs)-- chunks [] = []- chunks xs =- let (a, b) = splitAt 1000 xs -- 1000 is totally arbitrary.- in a : chunks b---- $setup--- >>> :set -XDataKinds
src/Data/TDigest/NonEmpty.hs view
@@ -1,169 +1,2 @@-{-# LANGUAGE DataKinds #-}-{-# LANGUAGE KindSignatures #-}-{-# LANGUAGE MultiParamTypeClasses #-}-{-# LANGUAGE ScopedTypeVariables #-}--- | This is non empty version of 'Data.TDigest.TDigest', i.e. this is not a 'Monoid',--- but on the other hand, 'quantile' returns 'Double' not @'Maybe' 'Double'@.------ See "Data.TDigest" for documentation. The exports should be similar,--- sans non-'Maybe' results.------ === Examples------ >>> quantile 0.99 (tdigest (1 :| [2..1000]) :: TDigest 25)--- 990.5------ >>> quantile 0.99 (tdigest (1 :| [2..1000]) :: TDigest 3)--- 989.0...------ t-Digest is more precise in tails, especially median is imprecise:------ >>> median (forceCompress $ tdigest (1 :| [2..1000]) :: TDigest 25)--- 497.6...----module Data.TDigest.NonEmpty (- -- * Construction- TDigest,- tdigest,-- -- ** Population- singleton,- insert,- insert',-- -- * Compression- compress,- forceCompress,-- -- * Statistics- totalWeight,- minimumValue,- maximumValue,- -- ** Histogram- histogram,- T.HistBin (..),- -- ** Percentile- median,- quantile,- -- ** Mean & variance- mean,- variance,- stddev,- -- ** CDF- cdf,- icdf,- ) where--import Prelude ()-import Prelude.Compat--import Control.DeepSeq (NFData (..))-import Control.Monad (when)-import Data.Binary (Binary (..))-import Data.List.NonEmpty (NonEmpty)-import Data.Maybe (fromMaybe)-import Data.Semigroup (Semigroup (..))-import Data.Semigroup.Foldable (Foldable1)-import Data.Semigroup.Reducer (Reducer (..))-import GHC.TypeLits (KnownNat)--import qualified Data.TDigest as T-import qualified Data.TDigest.Internal as T-import qualified Data.TDigest.Postprocess as T--newtype TDigest comp = TDigest { unEmpty :: T.TDigest comp }------------------------------------------------------------------------------------ Instances----------------------------------------------------------------------------------instance NFData (TDigest comp) where- rnf (TDigest t) = rnf t--instance Show (TDigest comp) where- showsPrec d (TDigest t) = showsPrec d t--instance KnownNat comp => Semigroup (TDigest comp) where- TDigest a <> TDigest b = TDigest (a <> b)--instance KnownNat comp => Reducer Double (TDigest comp) where- cons = insert- snoc = flip insert- unit = singleton--instance KnownNat comp => Binary (TDigest comp) where- get = do- t <- get- when (T.size t <= 0) $ fail "empty TDigest.NonEmpty"- return (TDigest t)-- put (TDigest t) = put t------------------------------------------------------------------------------------ Functions----------------------------------------------------------------------------------overTDigest :: (T.TDigest c -> T.TDigest c) -> TDigest c -> TDigest c-overTDigest f = TDigest . f . unEmpty--singleton :: KnownNat comp => Double -> TDigest comp-singleton = TDigest . T.singleton--insert :: KnownNat comp => Double -> TDigest comp -> TDigest comp-insert x = TDigest . T.insert x . unEmpty--insert' :: KnownNat comp => Double -> TDigest comp -> TDigest comp-insert' x = overTDigest $ T.insert' x--compress :: forall comp. KnownNat comp => TDigest comp -> TDigest comp-compress = overTDigest T.compress--forceCompress :: forall comp. KnownNat comp => TDigest comp -> TDigest comp-forceCompress = overTDigest T.forceCompress--minimumValue :: TDigest comp -> T.Mean-minimumValue = T.minimumValue . unEmpty--maximumValue :: TDigest comp -> T.Mean-maximumValue = T.maximumValue . unEmpty--totalWeight :: TDigest comp -> T.Weight-totalWeight = T.totalWeight . unEmpty--histogram :: TDigest comp -> NonEmpty T.HistBin-histogram = fromMaybe (error "NonEmpty.histogram") . T.histogram . unEmpty--median :: TDigest comp -> Double-median = quantile 0.5--quantile :: Double -> TDigest comp -> Double-quantile q td = T.quantile' q (totalWeight td) $ histogram td--mean :: TDigest comp -> Double-mean td = T.mean' $ histogram td--variance :: TDigest comp -> Double-variance td = T.variance' $ histogram td--stddev :: TDigest comp -> Double-stddev = sqrt . variance---- | Alias of 'quantile'.-icdf :: Double -> TDigest comp -> Double-icdf = quantile--cdf :: Double -> TDigest comp -> Double-cdf x = T.cdf x . unEmpty--tdigest :: (Foldable1 f, KnownNat comp) => f Double -> TDigest comp-tdigest = TDigest . T.tdigest---- $setup--- >>> :set -XDataKinds--- >>> import Prelude.Compat--- >>> import Data.List.NonEmpty (NonEmpty (..))--- >>> import Data.List.Compat (foldl')------ >>> let merge [] ys = []; merge xs [] = xs; merge (x:xs) (y:ys) = x : y : merge xs ys--- >>> let fairshuffle' xs = uncurry merge (splitAt (length xs `div` 2) xs)--- >>> let fairshuffle xs = iterate fairshuffle' xs !! 5+module Data.TDigest.NonEmpty (module Data.TDigest.Tree.NonEmpty) where+import Data.TDigest.Tree.NonEmpty
src/Data/TDigest/Postprocess.hs view
@@ -1,11 +1,7 @@--- | 'TDigest' postprocessing functions.------ These are re-exported from "Data.TDigest" module.--- module Data.TDigest.Postprocess ( -- * Histogram- histogram,- HistBin (..),+ I.HasHistogram (..),+ I.HistBin (..), -- * Quantiles median, quantile,@@ -14,187 +10,59 @@ -- | As we have "full" histogram, we can calculate other statistical -- variables. mean,- mean', variance,- variance',+ stddev, -- * CDF cdf, icdf,- -- * NonEmpty- histogram',- quantile',- -- * Debug- validateHistogram,+ -- * Affine+ I.Affine (..) ) where import Prelude () import Prelude.Compat-import Data.Foldable (toList)-import Data.List.NonEmpty (NonEmpty (..), nonEmpty)-import Data.Semigroup (Semigroup (..))-import Data.Semigroup.Foldable (foldMap1)--import Data.TDigest.Internal------------------------------------------------------------------------------------ Histogram------------------------------------------------------------------------------------ | Histogram bin-data HistBin = HistBin- { hbMin :: !Double -- ^ lower bound- , hbMax :: !Double -- ^ upper bound- , hbValue :: !Double -- ^ original value: @(mi + ma) / 2@- , hbWeight :: !Double -- ^ weight ("area" of the bar)- , hbCumWeight :: !Double -- ^ weight from the right- }- deriving (Show)---- | Calculate histogram based on the 'TDigest'.-histogram :: TDigest comp -> Maybe (NonEmpty HistBin)-histogram = fmap histogram' . nonEmpty . getCentroids---- | Histogram from centroids-histogram' :: NonEmpty (Mean,Weight) -> NonEmpty HistBin-histogram' = make- where- make :: NonEmpty (Mean, Weight) -> NonEmpty HistBin- -- one- make ((x, w) :| []) = HistBin x x x w 0 :| []- -- first- make (c1@(x1, w1) :| rest@((x2, _) : _))- = HistBin x1 (mid x1 x2) x1 w1 0 :| iter c1 w1 rest-- -- zero- iter :: (Mean, Weight) -> Weight -> [(Mean, Weight)] -> [HistBin]- iter _ _ [] = []- -- middle- iter (x0, _) t (c1@(x1, w1) : rest@((x2, _) : _))- = HistBin (mid x0 x1) (mid x1 x2) x1 w1 t: iter c1 (t + w1) rest- -- last- iter (x0, _) t [(x1, w1)]- = [HistBin (mid x0 x1) x1 x1 w1 t]-- mid a b = (a + b) / 2+import qualified Data.List.NonEmpty as NE ----------------------------------------------------------------------------------- Quantile--------------------------------------------------------------------------------+import qualified Data.TDigest.Postprocess.Internal as I -- | Median, i.e. @'quantile' 0.5@.-median :: TDigest comp -> Maybe Double+median :: I.HasHistogram a f => a -> f Double median = quantile 0.5 -- | Calculate quantile of a specific value.-quantile :: Double -> TDigest comp -> Maybe Double-quantile q td = quantile' q (totalWeight td) <$> histogram td---- | Quantile from the histogram.-quantile' :: Double -> Weight -> NonEmpty HistBin -> Double-quantile' q tw = iter . toList- where- q' = q * tw-- iter [] = error "quantile: empty NonEmpty"- iter [HistBin a b _ w t] = a + (b - a) * (q' - t) / w- iter (HistBin a b _ w t : rest)- | {- t < q' && -} q' < t + w = a + (b - a) * (q' - t) / w- | otherwise = iter rest---- | Alias of 'quantile'.-icdf :: Double -> TDigest comp -> Maybe Double-icdf = quantile------------------------------------------------------------------------------------ Mean--------------------------------------------------------------------------------+quantile :: I.HasHistogram a f => Double -> a -> f Double+quantile q x = I.quantile q (I.totalWeight x) <$> I.histogram x -- | Mean. ----- >>> mean (tdigest [1..100] :: TDigest 10)+-- >>> mean (Tree.tdigest [1..100] :: Tree.TDigest 10) -- Just 50.5 -- -- /Note:/ if you only need the mean, calculate it directly. ---mean :: TDigest comp -> Maybe Double-mean td = mean' <$> histogram td---- | Mean from the histogram.-mean' :: NonEmpty HistBin -> Double-mean' = getMean . foldMap1 toMean- where- toMean (HistBin _ _ x w _) = Mean w x--data Mean' = Mean !Double !Double--getMean :: Mean' -> Double-getMean (Mean _ x) = x--instance Semigroup Mean' where- Mean w1 x1 <> Mean w2 x2 = Mean w x- where- w = w1 + w2- x = (x1 * w1 + x2 * w2) / w-+mean :: I.HasHistogram a f => a -> f Double+mean x = I.mean <$> I.histogram x -- | Variance. ---variance :: TDigest comp -> Maybe Double-variance td = variance' <$> histogram td---- | Variance from the histogram.-variance' :: NonEmpty HistBin -> Double-variance' = getVariance . foldMap1 toVariance- where- toVariance (HistBin _ _ x w _) = Variance w x 0--data Variance = Variance !Double !Double !Double--getVariance :: Variance -> Double-getVariance (Variance w _ d) = d / (w - 1)---- See: https://izbicki.me/blog/gausian-distributions-are-monoids-instance Semigroup Variance where- Variance w1 x1 d1 <> Variance w2 x2 d2 = Variance w x d- where- w = w1 + w2- x = (x1 * w1 + x2 * w2) / w- d = d1 + d2 + w1 * (x1 * x1) + w2 * (x2 * x2) - w * x * x+variance :: I.HasHistogram a f => a -> f Double+variance x = I.variance <$> I.histogram x ----------------------------------------------------------------------------------- CDF - cumulative distribution function--------------------------------------------------------------------------------+-- | Standard deviation, square root of variance.+stddev :: I.HasHistogram a f => a -> f Double+stddev = fmap sqrt . variance -- | Cumulative distribution function. -- -- /Note:/ if this is the only thing you need, it's more efficient to count -- this directly.-cdf :: Double -> TDigest comp -> Double-cdf x td =- iter $ foldMap toList $ histogram td- where- n = totalWeight td-- iter [] = 1- iter (HistBin a b _ w t : rest)- | x < a = 0- | x < b = (t + w * (x - a) / (b - a)) / n- | otherwise = iter rest------------------------------------------------------------------------------------ Debug------------------------------------------------------------------------------------ | Validate that list of 'HistBin' is a valid "histogram".-validateHistogram :: Foldable f => f HistBin -> Either String (f HistBin)-validateHistogram bs = traverse validPair (pairs $ toList bs) >> pure bs- where- validPair (lb@(HistBin _ lmax _ lwt lcw), rb@(HistBin rmin _ _ _ rcw)) = do- check (lmax == rmin) "gap between bins"- check (lcw + lwt == rcw) "mismatch in weight cumulation"- where- check False err = Left $ err ++ " " ++ show (lb, rb)- check True _ = Right ()- pairs xs = zip xs $ tail xs+cdf :: I.HasHistogram a f => Double -> a -> Double+cdf q x = I.affine 1 (I.cdf q (I.totalWeight x) . NE.toList) $ I.histogram x +-- | An alias for 'quantile'.+icdf :: I.HasHistogram a f => Double -> a -> f Double+icdf = quantile +-- $setup+-- >>> :set -XDataKinds+-- >>> import qualified Data.TDigest.Tree as Tree
+ src/Data/TDigest/Postprocess/Internal.hs view
@@ -0,0 +1,209 @@+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE FunctionalDependencies #-}+{-# LANGUAGE GADTs #-}+-- | 'TDigest' postprocessing functions.+--+-- These are re-exported from "Data.TDigest" module.+--+module Data.TDigest.Postprocess.Internal (+ -- * Histogram+ HasHistogram (..),+ HistBin (..),+ histogramFromCentroids,+ -- * Quantiles+ quantile,+ -- * Mean & variance+ --+ -- | As we have "full" histogram, we can calculate other statistical+ -- variables.+ mean,+ variance,+ -- * CDF+ cdf,+ -- * Debug+ validateHistogram,+ -- * Affine - internal+ Affine (..),+ ) where++import Data.Foldable (toList)+import Data.Functor.Compose (Compose (..))+import Data.Functor.Identity (Identity (..))+import Data.List.NonEmpty (NonEmpty (..), nonEmpty)+import Data.Proxy (Proxy (..))+import Data.Semigroup (Semigroup (..))+import Data.Semigroup.Foldable (foldMap1)+import Prelude ()+import Prelude.Compat++import qualified Data.List.NonEmpty as NE++import Data.TDigest.Internal++-------------------------------------------------------------------------------+-- Histogram+-------------------------------------------------------------------------------++-- | Histogram bin+data HistBin = HistBin+ { hbMin :: !Mean -- ^ lower bound+ , hbMax :: !Mean -- ^ upper bound+ , hbValue :: !Mean -- ^ original value: @(mi + ma) / 2@+ , hbWeight :: !Weight -- ^ weight ("area" of the bar)+ , hbCumWeight :: !Weight -- ^ weight from the right, excludes this bin+ }+ deriving (Show)++-- | Types from which we can extract histogram.+class Affine f => HasHistogram a f | a -> f where+ histogram :: a -> f (NonEmpty HistBin)+ totalWeight :: a -> Weight++instance (HistBin ~ e) => HasHistogram (NonEmpty HistBin) Identity where+ histogram = Identity+ totalWeight = tw . NE.last where+ tw hb = hbWeight hb + hbCumWeight hb++instance (HistBin ~ e) => HasHistogram [HistBin] Maybe where+ histogram = nonEmpty+ totalWeight = affine 0 totalWeight . histogram++-- | Histogram from centroids+histogramFromCentroids :: NonEmpty Centroid -> NonEmpty HistBin+histogramFromCentroids = make+ where+ make :: NonEmpty Centroid -> NonEmpty HistBin+ -- one+ make ((x, w) :| []) = HistBin x x x w 0 :| []+ -- first+ make (c1@(x1, w1) :| rest@((x2, _) : _))+ = HistBin x1 (mid x1 x2) x1 w1 0 :| iter c1 w1 rest++ -- zero+ iter :: (Mean, Weight) -> Weight -> [(Mean, Weight)] -> [HistBin]+ iter _ _ [] = []+ -- middle+ iter (x0, _) t (c1@(x1, w1) : rest@((x2, _) : _))+ = HistBin (mid x0 x1) (mid x1 x2) x1 w1 t: iter c1 (t + w1) rest+ -- last+ iter (x0, _) t [(x1, w1)]+ = [HistBin (mid x0 x1) x1 x1 w1 t]++ mid a b = (a + b) / 2++-------------------------------------------------------------------------------+-- Quantile+-------------------------------------------------------------------------------++-- | Quantile from the histogram.+quantile :: Double -> Weight -> NonEmpty HistBin -> Double+quantile q tw = iter . toList+ where+ q' = q * tw++ iter [] = error "quantile: empty NonEmpty"+ iter [HistBin a b _ w t] = a + (b - a) * (q' - t) / w+ iter (HistBin a b _ w t : rest)+ | {- t < q' && -} q' < t + w = a + (b - a) * (q' - t) / w+ | otherwise = iter rest++-------------------------------------------------------------------------------+-- Mean+-------------------------------------------------------------------------------++-- | Mean from the histogram.+mean :: NonEmpty HistBin -> Double+mean = getMean . foldMap1 toMean+ where+ toMean (HistBin _ _ x w _) = Mean w x++data Mean' = Mean !Double !Double++getMean :: Mean' -> Double+getMean (Mean _ x) = x++instance Semigroup Mean' where+ Mean w1 x1 <> Mean w2 x2 = Mean w x+ where+ w = w1 + w2+ x = (x1 * w1 + x2 * w2) / w++-- | Variance from the histogram.+variance :: NonEmpty HistBin -> Double+variance = getVariance . foldMap1 toVariance+ where+ toVariance (HistBin _ _ x w _) = Variance w x 0++data Variance = Variance !Double !Double !Double++getVariance :: Variance -> Double+getVariance (Variance w _ d) = d / (w - 1)++-- See: https://izbicki.me/blog/gausian-distributions-are-monoids+instance Semigroup Variance where+ Variance w1 x1 d1 <> Variance w2 x2 d2 = Variance w x d+ where+ w = w1 + w2+ x = (x1 * w1 + x2 * w2) / w+ d = d1 + d2 + w1 * (x1 * x1) + w2 * (x2 * x2) - w * x * x++-------------------------------------------------------------------------------+-- CDF - cumulative distribution function+-------------------------------------------------------------------------------++-- | Cumulative distribution function.+cdf :: Double+ -> Double -- ^ total weight+ -> [HistBin] -> Double+cdf x n = iter+ where+ iter [] = 1+ iter (HistBin a b _ w t : rest)+ | x < a = 0+ | x < b = (t + w * (x - a) / (b - a)) / n+ | otherwise = iter rest++-------------------------------------------------------------------------------+-- Debug+-------------------------------------------------------------------------------++-- | Validate that list of 'HistBin' is a valid "histogram".+validateHistogram :: Foldable f => f HistBin -> Either String (f HistBin)+validateHistogram bs = traverse validPair (pairs $ toList bs) >> pure bs+ where+ validPair (lb@(HistBin _ lmax _ lwt lcw), rb@(HistBin rmin _ _ _ rcw)) = do+ check (lmax == rmin) "gap between bins"+ check (lcw + lwt == rcw) "mismatch in weight cumulation"+ where+ check False err = Left $ err ++ " " ++ show (lb, rb)+ check True _ = Right ()+ pairs xs = zip xs $ tail xs++-------------------------------------------------------------------------------+-- Affine+-------------------------------------------------------------------------------++-- | Affine containers, i.e. containing at most 1 element+--+-- This class doesn't have 'traverse' analogie+-- as it would require using 'Pointed' which is disputed type class.+--+-- > traverseAff :: Pointed f => (a -> f b) -> t a -> f (t b)+--+class Traversable t => Affine t where+ -- | Like `foldMap`+ affine :: b -> (a -> b) -> t a -> b+ affine x f = fromAffine x . fmap f++ fromAffine :: a -> t a -> a+ fromAffine x = affine x id++ {-# MINIMAL fromAffine | affine #-}++instance Affine Identity where fromAffine _ = runIdentity+instance Affine Maybe where affine = maybe+instance Affine Proxy where affine x _ _ = x++-- | Composition of 'Affine' containers is 'Affine'+instance (Affine f, Affine g) => Affine (Compose f g) where+ affine x f (Compose c) = affine x (affine x f) c
+ src/Data/TDigest/Tree.hs view
@@ -0,0 +1,116 @@+{-# LANGUAGE ScopedTypeVariables #-}+-- |+-- A new data structure for accurate on-line accumulation of rank-based+-- statistics such as quantiles and trimmed means.+-- .+-- See original paper: "Computing extremely accurate quantiles using t-digest"+-- by Ted Dunning and Otmar Ertl for more details+-- <https://github.com/tdunning/t-digest/blob/master/docs/t-digest-paper/histo.pdf>.+--+-- === Examples+--+-- >>> quantile 0.99 (tdigest [1..1000] :: TDigest 25)+-- Just 990.5+--+-- >>> quantile 0.99 (tdigest [1..1000] :: TDigest 3)+-- Just 989.0...+--+-- t-Digest is more precise in tails, especially median is imprecise:+--+-- >>> median (forceCompress $ tdigest [1..1000] :: TDigest 25)+-- Just 497.6...+--+-- === Semigroup+--+-- This operation isn't strictly associative, but statistical+-- variables shouldn't be affected.+--+-- >>> let td xs = tdigest xs :: TDigest 10+--+-- >>> median (td [1..500] <> (td [501..1000] <> td [1001..1500]))+-- Just 802...+--+-- >>> median ((td [1..500] <> td [501..1000]) <> td [1001..1500])+-- Just 726...+--+-- The linear is worst-case scenario:+--+-- >>> let td' xs = tdigest (fairshuffle xs) :: TDigest 10+--+-- >>> median (td' [1..500] <> (td' [501..1000] <> td' [1001..1500]))+-- Just 750.3789...+--+-- >>> median ((td' [1..500] <> td' [501..1000]) <> td' [1001..1500])+-- Just 750.3789...+--+module Data.TDigest.Tree (+ -- * Construction+ TDigest,+ tdigest,++ -- ** Population+ singleton,+ insert,+ insert',++ -- * Compression+ --+ -- |+ --+ -- >>> let digest = foldl' (flip insert') mempty [0..1000] :: TDigest 10+ -- >>> (size digest, size $ compress digest)+ -- (1001,52)+ --+ -- >>> (quantile 0.1 digest, quantile 0.1 $ compress digest)+ -- (Just 99.6...,Just 89.7...)+ --+ -- /Note:/ when values are inserted in more random order,+ -- t-Digest self-compresses on the fly:+ --+ -- >>> let digest = foldl' (flip insert') mempty (fairshuffle [0..1000]) :: TDigest 10+ -- >>> (size digest, size $ compress digest, size $ forceCompress digest)+ -- (78,78,48)+ --+ -- >>> quantile 0.1 digest+ -- Just 98.9...+ --+ compress,+ forceCompress,++ -- * Statistics+ minimumValue,+ maximumValue,+ -- ** Percentile+ median,+ quantile,+ -- ** Mean & Variance+ --+ -- |+ -- -- >>> stddev (tdigest $ fairshuffle [0..100] :: TDigest 10)+ -- Just 29.1...+ mean,+ variance,+ stddev,+ -- ** CDF+ cdf,+ icdf,++ -- * Debug+ size,+ valid,+ validate,+ debugPrint,+ ) where++import Data.TDigest.Tree.Internal+import Data.TDigest.Tree.Postprocess++-- $setup+-- >>> :set -XDataKinds+-- >>> import Prelude.Compat+-- >>> import Data.List.Compat (foldl')+-- >>> import Data.Semigroup ((<>))+--+-- >>> let merge [] ys = []; merge xs [] = xs; merge (x:xs) (y:ys) = x : y : merge xs ys+-- >>> let fairshuffle' xs = uncurry merge (splitAt (Prelude.Compat.length xs `div` 2) xs)+-- >>> let fairshuffle xs = iterate fairshuffle' xs !! 5
+ src/Data/TDigest/Tree/Internal.hs view
@@ -0,0 +1,492 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE KindSignatures #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE ScopedTypeVariables #-}+-- | Internals of 'TDigest'.+--+-- Tree implementation is based on /Adams’ Trees Revisited/ by Milan Straka+-- <http://fox.ucw.cz/papers/bbtree/bbtree.pdf>+module Data.TDigest.Tree.Internal where++import Control.DeepSeq (NFData (..))+import Control.Monad.ST (ST, runST)+import Data.Binary (Binary (..))+import Data.Either (isRight)+import Data.Foldable (toList)+import Data.List.Compat (foldl')+import Data.List.NonEmpty (nonEmpty)+import Data.Ord (comparing)+import Data.Proxy (Proxy (..))+import Data.Semigroup (Semigroup (..))+import Data.Semigroup.Reducer (Reducer (..))+import GHC.TypeLits (KnownNat, Nat, natVal)+import Prelude ()+import Prelude.Compat++import qualified Data.Vector.Algorithms.Heap as VHeap+import qualified Data.Vector.Unboxed as VU+import qualified Data.Vector.Unboxed.Mutable as MVU++import Data.TDigest.Internal+import qualified Data.TDigest.Postprocess.Internal as PP++-------------------------------------------------------------------------------+-- TDigest+-------------------------------------------------------------------------------++-- | 'TDigest' is a tree of centroids.+--+-- @compression@ is a @1/δ@. The greater the value of @compression@ the less+-- likely value merging will happen.+data TDigest (compression :: Nat)+ -- | Tree node+ = Node+ {-# UNPACK #-} !Size -- size of this tree/centroid+ {-# UNPACK #-} !Mean -- mean of the centroid+ {-# UNPACK #-} !Weight -- weight of the centrod+ {-# UNPACK #-} !Weight -- total weight of the tree+ !(TDigest compression) -- left subtree+ !(TDigest compression) -- right subtree+ -- | Empty tree+ | Nil+ deriving (Show)++-- [Note: keep min & max in the tree]+--+-- We tried it, but it seems the alloc/update cost is bigger than+-- re-calculating them on need (it's O(log n) - calculation!)++-- [Note: singleton node]+-- We tried to add one, but haven't seen change in performance++-- [Note: inlining balanceR and balanceL]+-- We probably can squueze some performance by making+-- 'balanceL' and 'balanceR' check arguments only once (like @containers@ do)+-- and not use 'node' function.+-- *But*, the benefit vs. code explosion is not yet worth.++instance KnownNat comp => Semigroup (TDigest comp) where+ (<>) = combineDigest++-- | Both 'cons' and 'snoc' are 'insert'+instance KnownNat comp => Reducer Double (TDigest comp) where+ cons = insert+ snoc = flip insert+ unit = singleton++instance KnownNat comp => Monoid (TDigest comp) where+ mempty = emptyTDigest+ mappend = combineDigest++-- | 'TDigest' has only strict fields.+instance NFData (TDigest comp) where+ rnf x = x `seq` ()++-- | 'TDigest' isn't compressed after de-serialisation,+-- but it can be still smaller.+instance KnownNat comp => Binary (TDigest comp) where+ put = put . getCentroids+ get = foldl' (flip insertCentroid) emptyTDigest . lc <$> get+ where+ lc :: [Centroid] -> [Centroid]+ lc = id++instance PP.HasHistogram (TDigest comp) Maybe where+ histogram = fmap PP.histogramFromCentroids . nonEmpty . getCentroids+ totalWeight = totalWeight++getCentroids :: TDigest comp -> [Centroid]+getCentroids = ($ []) . go+ where+ go Nil = id+ go (Node _ x w _ l r) = go l . ((x,w) : ) . go r++-- | Total count of samples.+--+-- >>> totalWeight (tdigest [1..100] :: TDigest 5)+-- 100.0+--+totalWeight :: TDigest comp -> Weight+totalWeight Nil = 0+totalWeight (Node _ _ _ tw _ _) = tw++size :: TDigest comp -> Int+size Nil = 0+size (Node s _ _ _ _ _) = s++-- | Center of left-most centroid. Note: may be different than min element inserted.+--+-- >>> minimumValue (tdigest [1..100] :: TDigest 3)+-- 1.0+--+minimumValue :: TDigest comp -> Mean+minimumValue = go posInf+ where+ go acc Nil = acc+ go _acc (Node _ x _ _ l _) = go x l++-- | Center of right-most centroid. Note: may be different than max element inserted.+--+-- >>> maximumValue (tdigest [1..100] :: TDigest 3)+-- 99.0+--+maximumValue :: TDigest comp -> Mean+maximumValue = go negInf+ where+ go acc Nil = acc+ go _acc (Node _ x _ _ _ r) = go x r++-------------------------------------------------------------------------------+-- Implementation+-------------------------------------------------------------------------------++emptyTDigest :: TDigest comp+emptyTDigest = Nil++combineDigest+ :: KnownNat comp+ => TDigest comp+ -> TDigest comp+ -> TDigest comp+combineDigest a Nil = a+combineDigest Nil b = b+combineDigest a@(Node n _ _ _ _ _) b@(Node m _ _ _ _ _)+ -- TODO: merge first, then shuffle and insert (part of compress)+ | n < m = compress $ foldl' (flip insertCentroid) b (getCentroids a)+ | otherwise = compress $ foldl' (flip insertCentroid) a (getCentroids b)++insertCentroid+ :: forall comp. KnownNat comp+ => Centroid+ -> TDigest comp+ -> TDigest comp+insertCentroid (x, w) Nil = singNode x w+insertCentroid (mean, weight) td = go 0 mean weight False td+ where+ -- New weight of the tree+ n :: Weight+ n = totalWeight td + weight++ -- 1/delta+ compression :: Double+ compression = fromInteger $ natVal (Proxy :: Proxy comp)++ go+ :: Weight -- weight to the left of this tree+ -> Mean -- mean to insert+ -> Weight -- weight to insert+ -> Bool -- should insert everything.+ -- if we merged somewhere on top, rest is inserted as is+ -> TDigest comp -- subtree to insert/merge centroid into+ -> TDigest comp+ go _ newX newW _ Nil = singNode newX newW+ go cum newX newW e (Node s x w tw l r) = case compare newX x of+ -- Exact match, insert here+ EQ -> Node s x (w + newW) (tw + newW) l r -- node x (w + newW) l r++ -- there is *no* room to insert into this node+ LT | thr <= w -> balanceL x w (go cum newX newW e l) r+ GT | thr <= w -> balanceR x w l (go (cum + totalWeight l + w) newX newW e r)++ -- otherwise go left ... or later right+ LT | e -> balanceL x w (go cum newX newW e l) r+ LT -> case l of+ -- always create a new node+ Nil -> case mrw of+ Nothing -> node' s nx nw (tw + newW) Nil r+ Just rw -> balanceL nx nw (go cum newX rw True Nil) r+ Node _ _ _ _ _ _+ | lmax < newX && abs (newX - x) < abs (newX - lmax) {- && newX < x -} -> case mrw of+ Nothing -> node' s nx nw (tw + nw - w) l r+ -- in this two last LT cases, we have to recalculate size+ Just rw -> balanceL nx nw (go cum newX rw True l) r+ | otherwise -> balanceL x w (go cum newX newW e l) r+ where+ lmax = maximumValue l++ -- ... or right+ GT | e -> balanceR x w l (go (cum + totalWeight l + w) newX newW True r)+ GT -> case r of+ Nil -> case mrw of+ Nothing -> node' s nx nw (tw + newW) l Nil+ Just rw -> balanceR nx nw l (go (cum + totalWeight l + nw) newX rw True Nil)+ Node _ _ _ _ _ _+ | rmin > newX && abs (newX - x) < abs (newX - rmin) {- && newX > x -} -> case mrw of+ Nothing -> node' s nx nw (tw + newW) l r+ -- in this two last GT cases, we have to recalculate size+ Just rw -> balanceR nx nw l (go (cum + totalWeight l + nw) newX rw True r)+ | otherwise -> balanceR x w l (go (cum + totalWeight l + w) newX newW e r)+ where+ rmin = minimumValue r+ where+ -- quantile approximation of current node+ cum' = cum + totalWeight l+ q = (w / 2 + cum') / n++ -- threshold, max size of current node/centroid+ thr = {- traceShowId $ traceShow (n, q) $ -} threshold n q compression++ -- We later use nx, nw and mrw:++ -- max size of current node+ dw :: Weight+ mrw :: Maybe Weight+ (dw, mrw) =+ let diff = assert (thr > w) "threshold should be larger than current node weight"+ $ w + newW - thr+ in if diff < 0 -- i.e. there is room+ then (newW, Nothing)+ else (thr - w, Just $ diff)++ -- the change of current node+ (nx, nw) = {- traceShowId $ traceShow (newX, newW, x, dw, mrw) $ -} combinedCentroid x w x dw++-- | Constructor which calculates size and total weight.+node :: Mean -> Weight -> TDigest comp -> TDigest comp -> TDigest comp+node x w l r = Node+ (1 + size l + size r)+ x w+ (w + totalWeight l + totalWeight r)+ l r++-- | Balance after right insertion.+balanceR :: Mean -> Weight -> TDigest comp -> TDigest comp -> TDigest comp+balanceR x w l r+ | size l + size r <= 1 = node x w l r+ | size r > balOmega * size l = case r of+ Nil -> error "balanceR: impossible happened"+ (Node _ rx rw _ Nil rr) ->+ -- assert (0 < balAlpha * size rr) "balanceR" $+ -- single left rotation+ node rx rw (node x w l Nil) rr+ (Node _ rx rw _ rl rr)+ | size rl < balAlpha * size rr ->+ -- single left rotation+ node rx rw (node x w l rl) rr+ (Node _ rx rw _ (Node _ rlx rlw _ rll rlr) rr) ->+ -- double left rotation+ node rlx rlw (node x w l rll) (node rx rw rlr rr)+ | otherwise = node x w l r++-- | Balance after left insertion.+balanceL :: Mean -> Weight -> TDigest comp -> TDigest comp -> TDigest comp+balanceL x w l r+ | size l + size r <= 1 = node x w l r+ | size l > balOmega * size r = case l of+ Nil -> error "balanceL: impossible happened"+ (Node _ lx lw _ ll Nil) ->+ -- assert (0 < balAlpha * size ll) "balanceL" $+ -- single right rotation+ node lx lw ll (node x w Nil r)+ (Node _ lx lw _ ll lr)+ | size lr < balAlpha * size ll ->+ -- single right rotation+ node lx lw ll (node x w lr r)+ (Node _ lx lw _ ll (Node _ lrx lrw _ lrl lrr)) ->+ -- double left rotation+ node lrx lrw (node lx lw ll lrl) (node x w lrr r)+ | otherwise = node x w l r++-- | Alias to 'Node'+node' :: Int -> Mean -> Weight -> Weight -> TDigest comp -> TDigest comp -> TDigest comp+node' = Node++-- | Create singular node.+singNode :: Mean -> Weight -> TDigest comp+singNode x w = Node 1 x w w Nil Nil++-- | Add two weighted means together.+combinedCentroid+ :: Mean -> Weight+ -> Mean -> Weight+ -> Centroid+combinedCentroid x w x' w' =+ ( (x * w + x' * w') / w'' -- this is probably not num. stable+ , w''+ )+ where+ w'' = w + w'++-- | Calculate the threshold, i.e. maximum weight of centroid.+threshold+ :: Double -- ^ total weight+ -> Double -- ^ quantile+ -> Double -- ^ compression (1/δ)+ -> Double+threshold n q compression = 4 * n * q * (1 - q) / compression++-------------------------------------------------------------------------------+-- Compression+-------------------------------------------------------------------------------++-- | Compress 'TDigest'.+--+-- Reinsert the centroids in "better" order (in original paper: in random)+-- so they have opportunity to merge.+--+-- Compression will happen only if size is both:+-- bigger than @'relMaxSize' * comp@ and bigger than 'absMaxSize'.+--+compress :: forall comp. KnownNat comp => TDigest comp -> TDigest comp+compress Nil = Nil+compress td+ | size td > relMaxSize * compression && size td > absMaxSize+ = forceCompress td+ | otherwise+ = td+ where+ compression = fromInteger $ natVal (Proxy :: Proxy comp)++-- | Perform compression, even if current size says it's not necessary.+forceCompress :: forall comp. KnownNat comp => TDigest comp -> TDigest comp+forceCompress Nil = Nil+forceCompress td =+ foldl' (flip insertCentroid) emptyTDigest $ fmap fst $ VU.toList centroids+ where+ -- Centroids are shuffled based on space+ centroids :: VU.Vector (Centroid, Double)+ centroids = runST $ do+ v <- toMVector td+ -- sort by cumulative weight+ VHeap.sortBy (comparing snd) v+ f <- VU.unsafeFreeze v+ pure f++toMVector+ :: forall comp s. KnownNat comp+ => TDigest comp -- ^ t-Digest+ -> ST s (VU.MVector s (Centroid, Double)) -- ^ return also a "space left in the centroid" value for "shuffling"+toMVector td = do+ v <- MVU.new (size td)+ (i, cum) <- go v (0 :: Int) (0 :: Double) td+ pure $ assert (i == size td && abs (cum - totalWeight td) < 1e-6) "traversal in toMVector:" v+ where+ go _ i cum Nil = pure (i, cum)+ go v i cum (Node _ x w _ l r) = do+ (i', cum') <- go v i cum l+ MVU.unsafeWrite v i' ((x, w), space w cum')+ go v (i' + 1) (cum' + w) r++ n = totalWeight td+ compression = fromInteger $ natVal (Proxy :: Proxy comp)++ space w cum = thr - w+ where+ q = (w / 2 + cum) / n+ thr = threshold n q compression++-------------------------------------------------------------------------------+-- Params+-------------------------------------------------------------------------------++-- | Relative size parameter. Hard-coded value: 25.+relMaxSize :: Int+relMaxSize = 25++-- | Absolute size parameter. Hard-coded value: 1000.+absMaxSize :: Int+absMaxSize = 1000++-------------------------------------------------------------------------------+-- Tree balance parameters+-------------------------------------------------------------------------------++balOmega :: Int+balOmega = 3++balAlpha :: Int+balAlpha = 2++-- balDelta = 0++-------------------------------------------------------------------------------+-- Debug+-------------------------------------------------------------------------------++-- | Output the 'TDigest' tree.+debugPrint :: TDigest comp -> IO ()+debugPrint td = go 0 td+ where+ go i Nil = putStrLn $ replicate (i * 3) ' ' ++ "Nil"+ go i (Node s m w tw l r) = do+ go (i + 1) l+ putStrLn $ replicate (i * 3) ' ' ++ "Node " ++ show (s,m,w,tw)+ go (i + 1) r++-- | @'isRight' . 'validate'@+valid :: TDigest comp -> Bool+valid = isRight . validate++-- | Check various invariants in the 'TDigest' tree.+validate :: TDigest comp -> Either String (TDigest comp)+validate td+ | not (all sizeValid centroids) = Left "invalid sizes"+ | not (all weightValid centroids) = Left "invalid weights"+ | not (all orderValid centroids) = Left "invalid ordering"+ | not (all balanced centroids) = Left "tree is ill-balanced"+ | otherwise = Right td+ where+ centroids = goc td++ goc Nil = []+ goc n@(Node _ _ _ _ l r) = n : goc l ++ goc r++ sizeValid Nil = True+ sizeValid (Node s _ _ _ l r) = s == size l + size r + 1++ weightValid Nil = True+ weightValid (Node _ _ w tw l r) = eq tw $ w + totalWeight l + totalWeight r++ orderValid Nil = True+ orderValid (Node _ _ _ _ Nil Nil) = True+ orderValid (Node _ x _ _ (Node _ lx _ _ _ _) Nil) = lx < x+ orderValid (Node _ x _ _ Nil (Node _ rx _ _ _ _)) = x < rx+ orderValid (Node _ x _ _ (Node _ lx _ _ _ _) (Node _ rx _ _ _ _)) = lx < x && x < rx++ balanced Nil = True+ balanced (Node _ _ _ _ l r) =+ size l <= max 1 (balOmega * size r) &&+ size r <= max 1 (balOmega * size l)++-------------------------------------------------------------------------------+-- Higher level helpers+-------------------------------------------------------------------------------++-- | Insert single value into 'TDigest'.+insert+ :: KnownNat comp+ => Double -- ^ element+ -> TDigest comp+ -> TDigest comp+insert x = compress . insert' x++-- | Insert single value, don't compress 'TDigest' even if needed.+--+-- For sensibly bounded input, it makes sense to let 'TDigest' grow (it might+-- grow linearly in size), and after that compress it once.+insert'+ :: KnownNat comp+ => Double -- ^ element+ -> TDigest comp+ -> TDigest comp+insert' x = insertCentroid (x, 1)++-- | Make a 'TDigest' of a single data point.+singleton :: KnownNat comp => Double -> TDigest comp+singleton x = insert x emptyTDigest++-- | Strict 'foldl'' over 'Foldable' structure.+tdigest :: (Foldable f, KnownNat comp) => f Double -> TDigest comp+tdigest = foldl' insertChunk emptyTDigest . chunks . toList+ where+ -- compress after each chunk, forceCompress at the very end.+ insertChunk td xs =+ compress (foldl' (flip insert') td xs)++ chunks [] = []+ chunks xs =+ let (a, b) = splitAt 1000 xs -- 1000 is totally arbitrary.+ in a : chunks b++-- $setup+-- >>> :set -XDataKinds
+ src/Data/TDigest/Tree/NonEmpty.hs view
@@ -0,0 +1,166 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE KindSignatures #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE ScopedTypeVariables #-}+-- | This is non empty version of 'Data.TDigest.TDigest', i.e. this is not a 'Monoid',+-- but on the other hand, 'quantile' returns 'Double' not @'Maybe' 'Double'@.+--+-- See "Data.TDigest" for documentation. The exports should be similar,+-- sans non-'Maybe' results.+--+-- === Examples+--+-- >>> quantile 0.99 (tdigest (1 :| [2..1000]) :: TDigest 25)+-- 990.5+--+-- >>> quantile 0.99 (tdigest (1 :| [2..1000]) :: TDigest 3)+-- 989.0...+--+-- t-Digest is more precise in tails, especially median is imprecise:+--+-- >>> median (forceCompress $ tdigest (1 :| [2..1000]) :: TDigest 25)+-- 497.6...+--+module Data.TDigest.Tree.NonEmpty (+ -- * Construction+ TDigest,+ tdigest,++ -- ** Population+ singleton,+ insert,+ insert',++ -- * Compression+ compress,+ forceCompress,++ -- * Statistics+ totalWeight,+ minimumValue,+ maximumValue,+ -- ** Percentile+ median,+ quantile,+ -- ** Mean & variance+ mean,+ variance,+ stddev,+ -- ** CDF+ cdf,+ icdf,+ ) where++import Prelude ()+import Prelude.Compat++import Control.DeepSeq (NFData (..))+import Control.Monad (when)+import Data.Binary (Binary (..))+import Data.Functor.Identity (Identity (..))+import Data.Semigroup (Semigroup (..))+import Data.Semigroup.Foldable (Foldable1)+import Data.Semigroup.Reducer (Reducer (..))+import GHC.TypeLits (KnownNat)++import Data.TDigest.Internal+import qualified Data.TDigest.Postprocess as PP+import qualified Data.TDigest.Tree.Internal as T++newtype TDigest comp = TDigest { unEmpty :: T.TDigest comp }++-------------------------------------------------------------------------------+-- Instances+-------------------------------------------------------------------------------++instance NFData (TDigest comp) where+ rnf (TDigest t) = rnf t++instance Show (TDigest comp) where+ showsPrec d (TDigest t) = showsPrec d t++instance KnownNat comp => Semigroup (TDigest comp) where+ TDigest a <> TDigest b = TDigest (a <> b)++instance KnownNat comp => Reducer Double (TDigest comp) where+ cons = insert+ snoc = flip insert+ unit = singleton++instance KnownNat comp => Binary (TDigest comp) where+ get = do+ t <- get+ when (T.size t <= 0) $ fail "empty TDigest.NonEmpty"+ return (TDigest t)++ put (TDigest t) = put t++instance PP.HasHistogram (TDigest comp) Identity where+ histogram = maybe (error "NonEmpty.histogram") Identity . PP.histogram . unEmpty+ totalWeight = PP.totalWeight . unEmpty++-------------------------------------------------------------------------------+-- Functions+-------------------------------------------------------------------------------++overTDigest :: (T.TDigest c -> T.TDigest c) -> TDigest c -> TDigest c+overTDigest f = TDigest . f . unEmpty++singleton :: KnownNat comp => Double -> TDigest comp+singleton = TDigest . T.singleton++insert :: KnownNat comp => Double -> TDigest comp -> TDigest comp+insert x = TDigest . T.insert x . unEmpty++insert' :: KnownNat comp => Double -> TDigest comp -> TDigest comp+insert' x = overTDigest $ T.insert' x++compress :: forall comp. KnownNat comp => TDigest comp -> TDigest comp+compress = overTDigest T.compress++forceCompress :: forall comp. KnownNat comp => TDigest comp -> TDigest comp+forceCompress = overTDigest T.forceCompress++minimumValue :: TDigest comp -> Mean+minimumValue = T.minimumValue . unEmpty++maximumValue :: TDigest comp -> Mean+maximumValue = T.maximumValue . unEmpty++totalWeight :: TDigest comp -> Weight+totalWeight = T.totalWeight . unEmpty++median :: TDigest comp -> Double+median = runIdentity . PP.median++quantile :: Double -> TDigest comp -> Double+quantile q = runIdentity . PP.quantile q++mean :: TDigest comp -> Double+mean = runIdentity . PP.mean++variance :: TDigest comp -> Double+variance = runIdentity . PP.variance++stddev :: TDigest comp -> Double+stddev = runIdentity . PP.variance++-- | Alias of 'quantile'.+icdf :: Double -> TDigest comp -> Double+icdf = quantile++cdf :: Double -> TDigest comp -> Double+cdf = PP.cdf++tdigest :: (Foldable1 f, KnownNat comp) => f Double -> TDigest comp+tdigest = TDigest . T.tdigest++-- $setup+-- >>> :set -XDataKinds+-- >>> import Prelude.Compat+-- >>> import Data.List.NonEmpty (NonEmpty (..))+-- >>> import Data.List.Compat (foldl')+--+-- >>> let merge [] ys = []; merge xs [] = xs; merge (x:xs) (y:ys) = x : y : merge xs ys+-- >>> let fairshuffle' xs = uncurry merge (splitAt (length xs `div` 2) xs)+-- >>> let fairshuffle xs = iterate fairshuffle' xs !! 5
+ src/Data/TDigest/Tree/Postprocess.hs view
@@ -0,0 +1,76 @@+-- | 'TDigest' postprocessing functions.+--+-- These are re-exported from "Data.TDigest" module.+--+module Data.TDigest.Tree.Postprocess (+ -- * Quantiles+ median,+ quantile,+ -- * Mean & variance+ --+ -- | As we have "full" histogram, we can calculate other statistical+ -- variables.+ mean,+ variance,+ stddev,+ -- * CDF+ cdf,+ icdf,+ ) where++import Prelude ()+import Prelude.Compat++import Data.TDigest.Tree.Internal++import qualified Data.TDigest.Postprocess as PP++-------------------------------------------------------------------------------+-- Quantile+-------------------------------------------------------------------------------++-- | Median, i.e. @'quantile' 0.5@.+median :: TDigest comp -> Maybe Double+median = PP.median++-- | Calculate quantile of a specific value.+quantile :: Double -> TDigest comp -> Maybe Double+quantile = PP.quantile++-------------------------------------------------------------------------------+-- Mean+-------------------------------------------------------------------------------++-- | Mean.+--+-- >>> mean (tdigest [1..100] :: TDigest 10)+-- Just 50.5+--+-- /Note:/ if you only need the mean, calculate it directly.+--+mean :: TDigest comp -> Maybe Double+mean = PP.mean++-- | Variance.+--+variance :: TDigest comp -> Maybe Double+variance = PP.variance++-- | Standard deviation, square root of variance.+stddev :: TDigest comp -> Maybe Double+stddev = PP.stddev++-------------------------------------------------------------------------------+-- CDF - cumulative distribution function+-------------------------------------------------------------------------------++-- | Cumulative distribution function.+--+-- /Note:/ if this is the only thing you need, it's more efficient to count+-- this directly.+cdf :: Double -> TDigest comp -> Double+cdf = PP.cdf++-- | An alias for 'quantile'+icdf :: Double -> TDigest comp -> Maybe Double+icdf = quantile
+ src/Data/TDigest/Vector.hs view
@@ -0,0 +1,106 @@+{-# LANGUAGE ScopedTypeVariables #-}+-- |+-- A new data structure for accurate on-line accumulation of rank-based+-- statistics such as quantiles and trimmed means.+-- .+-- See original paper: "Computing extremely accurate quantiles using t-digest"+-- by Ted Dunning and Otmar Ertl for more details+-- <https://github.com/tdunning/t-digest/blob/master/docs/t-digest-paper/histo.pdf>.+--+-- === Examples+--+-- >>> quantile 0.99 (tdigest [1..1000] :: TDigest 25)+-- Just 990.5+--+-- >>> quantile 0.99 (tdigest [1..1000] :: TDigest 3)+-- Just 990.3...+--+-- t-Digest is more precise in tails, especially median is imprecise:+--+-- >>> median (forceCompress $ tdigest [1..1000] :: TDigest 10)+-- Just 500.5+--+-- === Semigroup+--+-- This operation isn't strictly associative, but statistical+-- variables shouldn't be affected.+--+-- >>> let td xs = tdigest xs :: TDigest 10+--+-- >>> median (td [1..500] <> (td [501..1000] <> td [1001..1500]))+-- Just 750.5+--+-- >>> median ((td [1..500] <> td [501..1000]) <> td [1001..1500])+-- Just 750.5+--+-- The linear is worst-case scenario:+--+-- >>> let td' xs = tdigest (fairshuffle xs) :: TDigest 10+--+-- >>> median (td' [1..500] <> (td' [501..1000] <> td' [1001..1500]))+-- Just 750.5+--+-- >>> median ((td' [1..500] <> td' [501..1000]) <> td' [1001..1500])+-- Just 750.5+--+module Data.TDigest.Vector (+ -- * Construction+ TDigest,+ tdigest,++ -- ** Population+ singleton,+ insert,+ insert',++ -- * Compression+ --+ -- |+ --+ -- >>> let digest = foldl' (flip insert') mempty [0..1000] :: TDigest 5+ -- >>> (size digest, size $ compress digest)+ -- (1001,173)+ --+ -- >>> (quantile 0.1 digest, quantile 0.1 $ compress digest)+ -- (Just 99.6...,Just 99.6...)+ --+ compress,+ forceCompress,++ -- * Statistics+ minimumValue,+ maximumValue,+ -- ** Percentile+ median,+ quantile,+ -- ** Mean & Variance+ --+ -- |+ --+ -- >>> stddev (tdigest $ fairshuffle [0..100] :: TDigest 10)+ -- Just 29.0...+ mean,+ variance,+ stddev,+ -- ** CDF+ cdf,+ icdf,++ -- * Debug+ size,+ valid,+ validate,+ ) where++import Data.TDigest.Vector.Internal+import Data.TDigest.Vector.Postprocess++-- $setup+-- >>> :set -XDataKinds+-- >>> import Prelude.Compat+-- >>> import Data.List.Compat (foldl')+-- >>> import Data.Semigroup ((<>))+--+-- >>> let merge [] ys = []; merge xs [] = xs; merge (x:xs) (y:ys) = x : y : merge xs ys+-- >>> let fairshuffle' xs = uncurry merge (splitAt (Prelude.Compat.length xs `div` 2) xs)+-- >>> let fairshuffle xs = iterate fairshuffle' xs !! 5
+ src/Data/TDigest/Vector/Internal.hs view
@@ -0,0 +1,284 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE KindSignatures #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE ScopedTypeVariables #-}+module Data.TDigest.Vector.Internal where++import Control.DeepSeq (NFData (..))+import Data.Either (isRight)+import Data.Foldable (toList)+import Data.List (foldl', sortBy)+import Data.List.NonEmpty (nonEmpty)+import Data.Ord (comparing)+import Data.Proxy (Proxy (..))+import Data.Semigroup (Semigroup (..))+import Data.Semigroup.Reducer (Reducer (..))+import GHC.TypeLits (KnownNat, Nat, natVal)+import Prelude ()+import Prelude.Compat++import qualified Data.Vector.Unboxed as VU++import Data.TDigest.Internal+import qualified Data.TDigest.Postprocess.Internal as PP++-- import Debug.Trace+--+-- | 'TDigest' is a vector of centroids plus not yet merged elements.+--+-- The size of structure is dictated by @compression@, *𝛿*. And is *O(𝛿)*.+--+data TDigest (compression :: Nat) = TDigest+ { tdigestTotalWeight :: !Size -- ^ sum of vector and buffer size+ , tdigestData :: !(VU.Vector Centroid) -- ^ actual data. *Invariants:* sorted by mean; length <= 2 𝛿 (soft)+ , tdigestBufferSize :: !Size+ , tdigestBuffer :: [Double] -- ^ addition buffer, elements with weight 1. *Invariants:* length 2 <= 𝛿+ , tdigestDirection :: !Bool -- ^ direction is a hack, so we merge from left and right. *TODO* remove?+ }+ deriving Show++instance KnownNat comp => Semigroup (TDigest comp) where+ (<>) = combineTDigest++instance KnownNat comp => Monoid (TDigest comp) where+ mempty = emptyTDigest+ mappend = (<>)++-- | Both 'cons' and 'snoc' are 'insert'+instance KnownNat comp => Reducer Double (TDigest comp) where+ cons = insert+ snoc = flip insert+ unit = singleton++instance NFData (TDigest comp) where+ rnf (TDigest _ _ _ b _) = rnf b++instance KnownNat comp => PP.HasHistogram (TDigest comp) Maybe where+ histogram = fmap PP.histogramFromCentroids . nonEmpty . VU.toList . tdigestData . finalize+ totalWeight = totalWeight++-- | Size of structure+size :: TDigest comp -> Int+size td = VU.length (tdigestData td) + tdigestBufferSize td++totalWeight :: TDigest comp -> Weight+totalWeight = fromIntegral . tdigestTotalWeight++-- | Center of left-most centroid. Note: may be different than min element inserted.+--+-- >>> minimumValue (tdigest [1..100] :: TDigest 3)+-- 1.0+--+minimumValue :: KnownNat comp => TDigest comp -> Mean+minimumValue td+ | VU.null d = posInf+ | otherwise = fst (VU.head d)+ where+ d = tdigestData (finalize td)++-- | Center of right-most centroid. Note: may be different than max element inserted.+--+-- >>> maximumValue (tdigest [1..100] :: TDigest 3)+-- 100.0+--+maximumValue :: KnownNat comp => TDigest comp -> Mean+maximumValue td+ | VU.null d = posInf+ | otherwise = fst (VU.last d)+ where+ d = tdigestData (finalize td)++-------------------------------------------------------------------------------+-- Mapping function+-------------------------------------------------------------------------------++-- | Mapping from quantile *q* to notional index *k* with compression parameter *𝛿*.+--+-- >>> ksize 42 0+-- 0.0+--+-- >>> ksize 42 1+-- 42.0+--+-- *q@ is clamped.:+--+-- >>> ksize 42 2+-- 42.0+--+ksize+ :: Double -- ^ compression parameter, 𝛿+ -> Double -- ^ quantile, q+ -> Double -- ^ notional index, k+ksize comp q = comp * (asin (2 * clamp q - 1) / pi + 0.5)++clamp :: Double -> Double+clamp x+ | x < 0.0 = 0.0+ | x > 1.0 = 1.0+ | otherwise = x++-- | Inverse of 'ksize'.+--+-- >>> ksizeInv 42 0+-- 0.0+--+-- >>> ksizeInv 42 42+-- 1.0+--+-- >>> ksizeInv 42 (ksize 42 0.3)+-- 0.3+--+ksizeInv+ :: Double -- ^ compression parameter, 𝛿+ -> Double -- ^ notional index, k+ -> Double -- ^ quantile, q+ksizeInv comp k+ | k > comp = 1+ | k < 0 = 0+ | otherwise = 0.5 * (sin ((k / comp - 0.5) * pi) + 1)++-------------------------------------------------------------------------------+-- Merging+-------------------------------------------------------------------------------++merge :: Int -> Double -> [(Mean, Weight)] -> [(Mean, Weight)]+merge _ _ [] = []+merge tw' comp (y:ys) = go 0 (qLimit' 0) y ys+ where+ -- total weight+ tw = fromIntegral tw'++ qLimit' :: Double -> Double+ qLimit' q0 = ksizeInv comp (ksize comp q0 + 1) -- k⁻¹ (k (q₀, 𝛿) + 1, 𝛿)++ go :: Double -- q0+ -> Double -- qLimit+ -> (Mean, Weight) -- sigma+ -> [(Mean, Weight)]+ -> [(Mean, Weight)]+ go _q0 _qLimit sigma [] = [sigma] -- C'.append(σ)+ go q0 qLimit sigma (x:xs)+ | q <= qLimit = go q0 qLimit (plus sigma x) xs+ | otherwise = sigma : go q0' (qLimit' q0') x xs+-- traceShow ("q", sigma, x, q, qLimit) $+ where+ q = q0 + (snd sigma + snd x) / tw+ q0' = q0 + snd sigma / tw++ plus :: Centroid -> Centroid -> Centroid+ plus (m1,w1) (m2,w2) = ((m1 * w1 + m2 * w2) / w, w) where w = w1 + w2++-------------------------------------------------------------------------------+-- Implementation+-------------------------------------------------------------------------------++emptyTDigest :: TDigest comp+emptyTDigest = TDigest 0 mempty 0 mempty True++combineTDigest :: forall comp. KnownNat comp => TDigest comp -> TDigest comp -> TDigest comp+combineTDigest (TDigest tw d _ b dir) (TDigest tw' d' _ b' dir') = + TDigest (tw + tw') newD 0 [] (dir /= dir')+ where+ newD = VU.fromList+ . merge tw comp+ . sortBy (comparing fst) -- sort+ $ VU.toList d ++ VU.toList d' ++ map (flip (,) 1) (b ++ b')++ comp = fromInteger (natVal (Proxy :: Proxy comp)) * sizeCoefficient++-- | Flush insertion buffer+finalize :: forall comp. KnownNat comp => TDigest comp -> TDigest comp+finalize td+ | null (tdigestBuffer td) = td+ | otherwise = forceCompress td++forceCompress :: forall comp. KnownNat comp => TDigest comp -> TDigest comp+forceCompress (TDigest tw d _bs b dir) = TDigest tw d' 0 [] (not dir)+ where+ d' = VU.fromList+ . rev+ . merge tw comp -- compress+ . rev+ . sortBy (comparing fst) -- sort+ . (++ map (flip (,) 1) b) -- add buffer+ . VU.toList+ $ d+ comp = fromInteger (natVal (Proxy :: Proxy comp)) * sizeCoefficient+ rev | dir = id+ | otherwise = reverse++compress :: forall comp. KnownNat comp => TDigest comp -> TDigest comp+compress t@(TDigest _ _ bs _ _)+ | bs > compInt * 2 = forceCompress t+ | otherwise = t+ where+ compInt = fromInteger (natVal (Proxy :: Proxy comp)) * sizeCoefficient++-------------------------------------------------------------------------------+-- Params+-------------------------------------------------------------------------------++sizeCoefficient :: Num a => a+sizeCoefficient = 32++-------------------------------------------------------------------------------+-- Debug+-------------------------------------------------------------------------------++-- | @'isRight' . 'validate'@+valid :: TDigest comp -> Bool+valid = isRight . validate++-- | Check various invariants in the 'TDigest' structure.+validate :: TDigest comp -> Either String (TDigest comp)+validate td@(TDigest tw d bs b _dir)+ | not (bs == length b) =+ Left $ "Buffer lenght don't match: " ++ show (bs, length b)+ | not (tw == bs + round dw) =+ Left $ "Total weight doesn't match"+ | dl /= sortBy (comparing fst) dl =+ Left $ "Data buffer isn't ordered"+ | otherwise = Right td+ where+ dl :: [Centroid]+ dl = VU.toList d++ -- total weight of @d@+ dw :: Double+ dw = sum (map snd dl)++-------------------------------------------------------------------------------+-- Higher level helpers+-------------------------------------------------------------------------------++-- | Insert single value into 'TDigest'.+insert+ :: KnownNat comp+ => Double -- ^ element+ -> TDigest comp+ -> TDigest comp+insert x = compress . insert' x++-- | Insert single value, don't compress 'TDigest' even if needed.+--+-- This may violate the insertion buffer size invariant.+--+-- For sensibly bounded input, it makes sense to let 'TDigest' grow (it might+-- grow linearly in size), and after that compress it once.+insert'+ :: KnownNat comp+ => Double -- ^ element+ -> TDigest comp+ -> TDigest comp+insert' x (TDigest s d sb b dir) = TDigest (s + 1) d (sb + 1) (x : b) dir++-- | Make a 'TDigest' of a single data point.+singleton :: Double -> TDigest comp+singleton x = TDigest 1 (VU.singleton (x, 1)) 0 [] True++-- | Strict 'foldl'' over 'Foldable' structure.+tdigest :: (Foldable f, KnownNat comp) => f Double -> TDigest comp+tdigest = foldl' (flip insert) mempty . toList++-- $setup+-- >>> :set -XDataKinds
+ src/Data/TDigest/Vector/NonEmpty.hs view
@@ -0,0 +1,1 @@+module Data.TDigest.Vector.NonEmpty where
+ src/Data/TDigest/Vector/Postprocess.hs view
@@ -0,0 +1,77 @@+-- | 'TDigest' postprocessing functions.+--+-- These are re-exported from "Data.TDigest" module.+--+module Data.TDigest.Vector.Postprocess (+ -- * Quantiles+ median,+ quantile,+ -- * Mean & variance+ --+ -- | As we have "full" histogram, we can calculate other statistical+ -- variables.+ mean,+ variance,+ stddev,+ -- * CDF+ cdf,+ icdf,+ ) where++import GHC.TypeLits (KnownNat)+import Prelude ()+import Prelude.Compat++import Data.TDigest.Vector.Internal++import qualified Data.TDigest.Postprocess as PP++-------------------------------------------------------------------------------+-- Quantile+-------------------------------------------------------------------------------++-- | Median, i.e. @'quantile' 0.5@.+median :: KnownNat comp => TDigest comp -> Maybe Double+median = PP.median++-- | Calculate quantile of a specific value.+quantile :: KnownNat comp => Double -> TDigest comp -> Maybe Double+quantile = PP.quantile++-------------------------------------------------------------------------------+-- Mean+-------------------------------------------------------------------------------++-- | Mean.+--+-- >>> mean (tdigest [1..100] :: TDigest 10)+-- Just 50.5+--+-- /Note:/ if you only need the mean, calculate it directly.+--+mean :: KnownNat comp => TDigest comp -> Maybe Double+mean = PP.mean++-- | Variance.+--+variance :: KnownNat comp => TDigest comp -> Maybe Double+variance = PP.variance++-- | Standard deviation, square root of variance.+stddev :: KnownNat comp => TDigest comp -> Maybe Double+stddev = PP.stddev++-------------------------------------------------------------------------------+-- CDF - cumulative distribution function+-------------------------------------------------------------------------------++-- | Cumulative distribution function.+--+-- /Note:/ if this is the only thing you need, it's more efficient to count+-- this directly.+cdf :: KnownNat comp => Double -> TDigest comp -> Double+cdf = PP.cdf++-- | An alias for 'quantile'+icdf :: KnownNat comp => Double -> TDigest comp -> Maybe Double+icdf = quantile
tdigest.cabal view
@@ -1,20 +1,22 @@+cabal-version: >= 1.10 name: tdigest-version: 0.1+version: 0.2+ synopsis: On-line accumulation of rank-based statistics description: A new data structure for accurate on-line accumulation of rank-based statistics such as quantiles and trimmed means. . See original paper: "Computing extremely accurate quantiles using t-digest" by Ted Dunning and Otmar Ertl- for more details <https://github.com/tdunning/t-digest/blob/master/docs/t-digest-paper/histo.pdf>.+ for more details <https://github.com/tdunning/t-digest/blob/07b8f2ca2be8d0a9f04df2feadad5ddc1bb73c88/docs/t-digest-paper/histo.pdf>. category: Numeric+ homepage: https://github.com/futurice/haskell-tdigest#readme bug-reports: https://github.com/futurice/haskell-tdigest/issues author: Oleg Grenrus <oleg.grenrus@iki.fi> maintainer: Oleg Grenrus <oleg.grenrus@iki.fi> license: BSD3 license-file: LICENSE-tested-with: GHC==7.8.4, GHC==7.10.3, GHC==8.0.1, GHC==8.0.2+tested-with: GHC==7.8.4, GHC==7.10.3, GHC==8.0.1, GHC==8.0.2, GHC==8.2.2, GHC==8.4.1 build-type: Custom-cabal-version: >= 1.10 extra-source-files: README.md@@ -26,29 +28,51 @@ custom-setup setup-depends:- base >=4.5 && <5,+ base >=4.7 && <5, Cabal >=1.14,- cabal-doctest >=1 && <1.1+ cabal-doctest >=1.0.6 && <1.1 library hs-source-dirs: src ghc-options: -Wall++ -- GHC boot libraries build-depends:- base >=4.7 && <4.10- , base-compat >=0.9.1 && <0.10+ base >=4.7 && <4.12 , deepseq >=1.3.0.2 && <1.5- , binary >=0.7.1.0 && <0.9- , reducers >=3.12.1 && <3.13- , semigroups >=0.18.2 && <0.19- , semigroupoids >=5.1 && <5.2- , vector >=0.11 && <0.13+ , binary >=0.7.1.0 && <0.10+ , transformers >=0.3 && <0.6++ if !impl(ghc >= 8.0)+ build-depends:+ semigroups >=0.18.4 && <0.19++ -- other dependencies+ build-depends:+ base-compat >=0.10.1 && <0.11+ , reducers >=3.12.2 && <3.13+ , semigroupoids >=5.2.2 && <5.3+ , vector >=0.12.0.1 && <0.13 , vector-algorithms >=0.7.0.1 && <0.8+ exposed-modules: Data.TDigest Data.TDigest.NonEmpty Data.TDigest.Postprocess+ Data.TDigest.Tree+ Data.TDigest.Tree.NonEmpty+ Data.TDigest.Tree.Postprocess+ Data.TDigest.Vector+ Data.TDigest.Vector.NonEmpty+ Data.TDigest.Vector.Postprocess++ -- Internal modules are exposed, but aren't under PVP contract.+ exposed-modules: Data.TDigest.Internal+ Data.TDigest.Postprocess.Internal+ Data.TDigest.Tree.Internal+ Data.TDigest.Vector.Internal default-language: Haskell2010 other-extensions: DataKinds@@ -72,8 +96,8 @@ semigroups, vector, vector-algorithms,- tasty >=0.11.0.4 && <0.12,- tasty-quickcheck >=0.8.4 && <0.9+ tasty >=0.11.0.4 && <1.1,+ tasty-quickcheck >=0.8.4 && <0.11 test-suite doctests default-language: Haskell2010@@ -84,4 +108,4 @@ build-depends: base,- doctest >=0.11.1 && <0.12+ doctest >=0.11.1 && <0.16
tests/Tests.hs view
@@ -2,6 +2,8 @@ module Main (main) where import Data.TDigest+import Data.TDigest.Postprocess+import Data.TDigest.Postprocess.Internal (validateHistogram) import Test.Tasty import Test.Tasty.QuickCheck