on master help sign in
Add a module for sliding window folds

Moved some basic incremental folds from 'streamly-statistics" package.

commitf95fcbd82fe4fb31bd3a169b31193a4a0244d708 authorRanjeet Kumar Ranjan <ranjeetr@composewell.com> dateTue 15 Mar 2022, 15:03 (5 years ago) parentee85b7a
9 files changed+462
benchmark/Streamly/Benchmark/Data/Fold/Window.hs+115 benchmark/streamly-benchmarks.cabal+6 core/src/Streamly/Internal/Data/Fold/Window.hs+263 core/streamly-core.cabal+3 default.nix+1 hie.yaml+4 streamly.cabal+3 test/Streamly/Test/Data/Fold/Window.hs+61 test/streamly-tests.cabal+6
benchmark/Streamly/Benchmark/Data/Fold/Window.hsnew file+115
@@ -0,0 +1,115 @@
1+module Main (main) where
2+
3+import Control.DeepSeq (NFData)
4+import Streamly.Data.Fold (Fold)
5+import Streamly.Prelude (SerialT)
6+import System.Random (randomRIO)
7+
8+import qualified Streamly.Data.Fold as Fold
9+import qualified Streamly.Internal.Data.Ring.Foreign as Ring
10+import qualified Streamly.Prelude as Stream
11+import qualified Streamly.Internal.Data.Fold.Window as Window
12+
13+import Gauge
14+
15+{-# INLINE source #-}
16+source :: (Monad m, Stream.IsStream t, Num a, Stream.Enumerable a) =>
17+ Int -> a -> t m a
18+source len from =
19+ Stream.enumerateFromThenTo from (from + 1) (from + fromIntegral len)
20+
21+{-# INLINE sourceDescending #-}
22+sourceDescending :: (Monad m, Stream.IsStream t, Num a, Stream.Enumerable a) =>
23+ Int -> a -> t m a
24+sourceDescending len from =
25+ Stream.enumerateFromThenTo
26+ (from + fromIntegral len)
27+ (from + fromIntegral (len - 1))
28+ from
29+
30+{-# INLINE sourceDescendingInt #-}
31+sourceDescendingInt :: (Monad m, Stream.IsStream t) => Int -> Int -> t m Int
32+sourceDescendingInt = sourceDescending
33+
34+{-# INLINE benchWith #-}
35+benchWith :: (Num a, NFData a) =>
36+ (Int -> a -> SerialT IO a) -> Int -> String -> Fold IO a a -> Benchmark
37+benchWith src len name f =
38+ bench name
39+ $ nfIO
40+ $ randomRIO (1, 1 :: Int) >>= Stream.fold f . src len . fromIntegral
41+
42+{-# INLINE benchWithFold #-}
43+benchWithFold :: Int -> String -> Fold IO Double Double -> Benchmark
44+benchWithFold = benchWith source
45+
46+{-# INLINE benchWithFoldInt #-}
47+benchWithFoldInt :: Int -> String -> Fold IO Int Int -> Benchmark
48+benchWithFoldInt = benchWith source
49+
50+{-# INLINE benchWithPostscan #-}
51+benchWithPostscan :: Int -> String -> Fold IO Double Double -> Benchmark
52+benchWithPostscan len name f =
53+ bench name $ nfIO $ randomRIO (1, 1) >>=
54+ Stream.drain . Stream.postscan f . source len
55+
56+{-# INLINE numElements #-}
57+numElements :: Int
58+numElements = 100000
59+
60+main :: IO ()
61+main =
62+ defaultMain
63+ [ bgroup
64+ "fold"
65+ [ benchWithFold numElements "minimum (window size 100)"
66+ (Ring.slidingWindow 100 Window.minimum)
67+ , benchWithFold numElements "minimum (window size 1000)"
68+ (Ring.slidingWindow 1000 Window.minimum)
69+ , benchWith sourceDescendingInt numElements
70+ "minimum descending (window size 1000)"
71+ (Ring.slidingWindow 1000 Window.minimum)
72+ , benchWithFold numElements "maximum (window size 100)"
73+ (Ring.slidingWindow 100 Window.maximum)
74+ , benchWithFold numElements "maximum (window size 1000)"
75+ (Ring.slidingWindow 1000 Window.maximum)
76+ , benchWith sourceDescendingInt numElements
77+ "maximum descending (window size 1000)"
78+ (Ring.slidingWindow 1000 Window.maximum)
79+ , benchWithFold numElements "range (window size 100)"
80+ (Ring.slidingWindow 100 Window.range)
81+ , benchWithFold numElements "range (window size 1000)"
82+ (Ring.slidingWindow 1000 Window.range)
83+ , benchWithFoldInt numElements "sumInt (window size 100)"
84+ (Ring.slidingWindow 100 Window.sumInt)
85+ , benchWithFoldInt numElements "sum for Int (window size 100)"
86+ (Ring.slidingWindow 100 Window.sum)
87+ , benchWithFold numElements "sum (window size 100)"
88+ (Ring.slidingWindow 100 Window.sum)
89+ , benchWithFold numElements "sum (window size 1000)"
90+ (Ring.slidingWindow 1000 Window.sum)
91+ , benchWithFold numElements "sum (entire stream)"
92+ (Window.whole Window.sum)
93+ , benchWithFold numElements "sum (Data.Fold)"
94+ Fold.sum
95+ ]
96+ , bgroup
97+ "scan"
98+ [ benchWithPostscan numElements "minimum (window size 100)"
99+ (Ring.slidingWindow 100 Window.minimum)
100+ , benchWithPostscan numElements "minimum (window size 1000)"
101+ (Ring.slidingWindow 1000 Window.minimum)
102+ , benchWithPostscan numElements "maximum (window size 100)"
103+ (Ring.slidingWindow 100 Window.maximum)
104+ , benchWithPostscan numElements "maximum (window size 1000)"
105+ (Ring.slidingWindow 1000 Window.maximum)
106+ , benchWithPostscan numElements "range (window size 100)"
107+ (Ring.slidingWindow 100 Window.range)
108+ , benchWithPostscan numElements "range (window size 1000)"
109+ (Ring.slidingWindow 1000 Window.range)
110+ , benchWithPostscan numElements "sum (window size 100)"
111+ (Ring.slidingWindow 100 Window.sum)
112+ , benchWithPostscan numElements "sum (window size 1000)"
113+ (Ring.slidingWindow 1000 Window.sum)
114+ ]
115+ ]
benchmark/streamly-benchmarks.cabal+6
@@ -322,6 +322,12 @@ benchmark Data.Fold
322322 else
323323 buildable: True
324324
325+benchmark Data.Fold.Window
326+ import: bench-options
327+ type: exitcode-stdio-1.0
328+ hs-source-dirs: Streamly/Benchmark/Data/Fold
329+ main-is: Window.hs
330+
325331benchmark Data.Parser.ParserD
326332 import: bench-options
327333 type: exitcode-stdio-1.0
core/src/Streamly/Internal/Data/Fold/Window.hsnew file+263
@@ -0,0 +1,263 @@
1+module Streamly.Internal.Data.Fold.Window
2+( lmap
3+, length
4+, whole
5+, sum
6+, sumInt
7+, minimum
8+, maximum
9+, range
10+)
11+where
12+
13+import Data.Bifunctor(bimap)
14+import Data.Function ((&))
15+import Data.Maybe (fromMaybe)
16+
17+import Streamly.Internal.Data.Fold.Type (Fold(..), Step(..))
18+import Streamly.Internal.Data.Tuple.Strict (Tuple'(..), Tuple3'(..))
19+
20+import Prelude hiding (length, sum, minimum, maximum)
21+
22+import qualified Deque.Strict as DQ
23+import qualified Streamly.Data.Fold as Fold
24+
25+-- $setup
26+-- >>> import Data.Bifunctor(bimap)
27+-- >>> import qualified Streamly.Data.Fold as Fold
28+-- >>> import Prelude hiding (length, sum, minimum, maximum)
29+
30+-------------------------------------------------------------------------------
31+-- Utilities
32+-------------------------------------------------------------------------------
33+
34+-- | Map a function on the incoming as well as outgoing element of a rolling
35+-- window fold.
36+--
37+-- >>> lmap f = Fold.lmap (bimap f (f <$>))
38+--
39+{-# INLINE lmap #-}
40+lmap :: (c -> a) -> Fold m (a, Maybe a) b -> Fold m (c, Maybe c) b
41+lmap f = Fold.lmap (bimap f (f <$>))
42+
43+-- | Convert a rolling fold to a normal fold using the entire input stream as a
44+-- single window.
45+--
46+-- >>> whole f = Fold.lmap (\x -> (x, Nothing)) f
47+--
48+{-# INLINE whole #-}
49+whole :: Fold m (a, Maybe a) b -> Fold m a b
50+whole = Fold.lmap (, Nothing)
51+
52+-------------------------------------------------------------------------------
53+-- Sum
54+-------------------------------------------------------------------------------
55+
56+-- XXX Overflow.
57+--
58+-- | The sum of all the elements in a rolling window. The input elements are
59+-- required to be intergal numbers.
60+--
61+-- This was written in the hope that it would be a tiny bit faster than 'sum'
62+-- for 'Integral' values. But turns out that 'sum' is 2% faster than this even
63+-- for intergal values!
64+--
65+-- /Internal/
66+--
67+{-# INLINE sumInt #-}
68+sumInt :: forall m a. (Monad m, Integral a) => Fold m (a, Maybe a) a
69+sumInt = Fold step initial extract
70+
71+ where
72+
73+ initial = return $ Partial (0 :: a)
74+
75+ step s (a, ma) =
76+ return
77+ $ Partial
78+ $ case ma of
79+ Nothing -> s + a
80+ Just old -> s + a - old
81+
82+ extract = return
83+
84+-- XXX Overflow.
85+--
86+-- | Sum of all the elements in a rolling window:
87+--
88+-- Uses Kahan-Babuska-Neumaier style summation for numerical stability of
89+-- floating precision arithmetic.
90+--
91+-- /Space/: \(\mathcal{O}(1)\)
92+--
93+-- /Time/: \(\mathcal{O}(n)\)
94+--
95+{-# INLINE sum #-}
96+sum :: forall m a. (Monad m, Num a) => Fold m (a, Maybe a) a
97+sum = Fold step initial extract
98+
99+ where
100+
101+ initial =
102+ return
103+ $ Partial
104+ $ Tuple'
105+ (0 :: a) -- running sum
106+ (0 :: a) -- accumulated rounding error
107+
108+ step (Tuple' total err) (new, mOld) =
109+ let incr =
110+ case mOld of
111+ -- XXX new may be large and err may be small we may lose it
112+ Nothing -> new - err
113+ -- XXX if (new - old) is large we may lose err
114+ Just old -> (new - old) - err
115+ -- total is large and incr may be small, we may round incr here but
116+ -- we will accumulate the rounding error in err1 in the next step.
117+ total1 = total + incr
118+ -- Accumulate any rounding error in err1
119+ -- XXX In the Nothing case above we may lose err, therefore we
120+ -- should use ((total1 - total) - new) + err here.
121+ -- Or even in the just case if (new - old) is large we may lose
122+ -- err, so we should use ((total1 - total) + (old - new)) + err.
123+ err1 = (total1 - total) - incr
124+ in return $ Partial $ Tuple' total1 err1
125+
126+ extract (Tuple' total _) = return total
127+
128+-- | The number of elements in the rolling window.
129+--
130+{-# INLINE length #-}
131+length :: (Monad m, Num b) => Fold m (a, Maybe a) b
132+length = Fold.foldl' step 0
133+
134+ where
135+
136+ step w (_, Nothing) = w + 1
137+ step w _ = w
138+
139+-------------------------------------------------------------------------------
140+-- Location
141+-------------------------------------------------------------------------------
142+
143+-- Theoretically, we can approximate minimum in a rolling window by using a
144+-- 'powerMean' with sufficiently large negative power.
145+--
146+-- XXX If we need to know the minimum in the window only once in a while then
147+-- we can use linear search when it is extracted and not pay the cost all the
148+-- time.
149+--
150+-- | The minimum element in a rolling window.
151+--
152+-- If you want to compute the minimum of the entire stream Fold.minimum from streamly
153+-- package would be much faster.
154+--
155+-- /Time/: \(\mathcal{O}(n*w)\) where \(w\) is the window size.
156+--
157+{-# INLINE minimum #-}
158+minimum :: (Monad m, Ord a) => Fold m (a, Maybe a) a
159+minimum = Fold step initial extract
160+
161+ where
162+
163+ initial = return $ Partial $ Tuple3' (0 :: Int) (0 :: Int)
164+ (mempty :: DQ.Deque (Int, a))
165+
166+ step (Tuple3' i w q) (a, ma) =
167+ case ma of
168+ Nothing ->
169+ return $ Partial $ Tuple3' (i + 1) (w + 1)
170+ (headCheck i q (w + 1) & dqloop (i, a))
171+ Just _ ->
172+ return $ Partial $ Tuple3' (i + 1) w
173+ (headCheck i q w & dqloop (i,a))
174+
175+ {-# INLINE headCheck #-}
176+ headCheck i q w =
177+ case DQ.uncons q of
178+ Nothing -> q
179+ Just (ia', q') ->
180+ if fst ia' <= i - w
181+ then q'
182+ else q
183+
184+ dqloop ia q =
185+ case DQ.unsnoc q of
186+ Nothing -> DQ.snoc ia q
187+ -- XXX This can be improved for the case of `=`
188+ Just (ia', q') ->
189+ if snd ia <= snd ia'
190+ then dqloop ia q'
191+ else DQ.snoc ia q
192+
193+ extract (Tuple3' _ _ q) = return $ snd
194+ $ fromMaybe (0, error "min: Empty stream")
195+ $ DQ.head q
196+
197+-- Theoretically, we can approximate maximum in a rolling window by using a
198+-- 'powerMean' with sufficiently large positive power.
199+--
200+-- | The maximum element in a rolling window.
201+--
202+-- If you want to compute the maximum of the entire stream Fold.maximum from streamly
203+-- package would be much faster.
204+--
205+-- /Time/: \(\mathcal{O}(n*w)\) where \(w\) is the window size.
206+--
207+{-# INLINE maximum #-}
208+maximum :: (Monad m, Ord a) => Fold m (a, Maybe a) a
209+maximum = Fold step initial extract
210+
211+ where
212+
213+ initial = return $ Partial $ Tuple3' (0 :: Int) (0 :: Int)
214+ (mempty :: DQ.Deque (Int, a))
215+
216+ step (Tuple3' i w q) (a, ma) =
217+ case ma of
218+ Nothing ->
219+ return $ Partial $ Tuple3' (i + 1) (w + 1)
220+ (headCheck i q (w + 1) & dqloop (i, a))
221+ Just _ ->
222+ return $ Partial $ Tuple3' (i + 1) w
223+ (headCheck i q w & dqloop (i,a))
224+
225+ {-# INLINE headCheck #-}
226+ headCheck i q w =
227+ case DQ.uncons q of
228+ Nothing -> q
229+ Just (ia', q') ->
230+ if fst ia' <= i - w
231+ then q'
232+ else q
233+
234+ dqloop ia q =
235+ case DQ.unsnoc q of
236+ Nothing -> DQ.snoc ia q
237+ -- XXX This can be improved for the case of `=`
238+ Just (ia', q') ->
239+ if snd ia >= snd ia'
240+ then dqloop ia q'
241+ else DQ.snoc ia q
242+
243+ extract (Tuple3' _ _ q) =
244+ return
245+ $ snd
246+ $ fromMaybe (0, error "max: Empty stream")
247+ $ DQ.head q
248+
249+
250+-- | The difference between the maximum and minimum elements of a rolling window.
251+--
252+-- >>> range = Fold.teeWith (-) maximum minimum
253+--
254+-- If you want to compute the range of the entire stream @Fold.teeWith (-)
255+-- Fold.maximum Fold.min@ from the streamly package would be much faster.
256+--
257+-- /Space/: \(\mathcal{O}(n)\) where @n@ is the window size.
258+--
259+-- /Time/: \(\mathcal{O}(n*w)\) where \(w\) is the window size.
260+--
261+{-# INLINE range #-}
262+range :: (Monad m, Num a, Ord a) => Fold m (a, Maybe a) a
263+range = Fold.teeWith (-) maximum minimum
core/streamly-core.cabal+3
@@ -244,6 +244,7 @@ library
244244 , Streamly.Internal.Data.Fold.Step
245245 , Streamly.Internal.Data.Refold.Type
246246 , Streamly.Internal.Data.Fold.Type
247+ , Streamly.Internal.Data.Fold.Window
247248 , Streamly.Internal.Data.Stream.StreamD.Step
248249 , Streamly.Internal.Data.Stream.StreamD.Type
249250 , Streamly.Internal.Data.Stream.StreamDK.Type
@@ -335,6 +336,8 @@ library
335336 , transformers-base >= 0.4 && < 0.5
336337 , primitive >= 0.5.4 && < 0.8
337338 , heaps >= 0.3 && < 0.5
339+ , deque >= 0.4 && < 0.5
340+
338341 if flag(use-unliftio)
339342 build-depends: unliftio-core >= 0.2 && < 0.3
340343 else
default.nix+1
@@ -129,6 +129,7 @@ let haskellPackages =
129129 # Use a better prompt
130130 shellHook = ''
131131 export CABAL_DIR="$(pwd)/.cabal.nix"
132+ cabal user-config update -a "jobs: 1"
132133 if test -n "$PS_SHELL"
133134 then
134135 export PS1="$PS_SHELL\[$bldred\](nix:streamly)\[$txtrst\] "
hie.yaml+4
@@ -68,6 +68,8 @@ cradle:
6868 component: "bench:Unicode.Stream"
6969 - path: "./benchmark/lib/"
7070 component: "lib:streamly-benchmarks"
71+ - path: "./benchmark/Streamly/Benchmark/Data/Fold/Window.hs"
72+ component: "bench:Data.Fold.Window"
7173 - path: "./test"
7274 config:
7375 cradle:
@@ -134,6 +136,8 @@ cradle:
134136 component: "lib:streamly-tests"
135137 - path: "./test/version-bounds.hs"
136138 component: "test:version-bounds"
139+ - path: "./test/Streamly/Test/Data/Fold/Window.hs"
140+ component: "test:Data.Fold.Window"
137141
138142dependencies:
139143 - streamly.cabal
streamly.cabal+3
@@ -63,6 +63,7 @@ extra-source-files:
6363 benchmark/Streamly/Benchmark/Data/Array/Prim.hs
6464 benchmark/Streamly/Benchmark/Data/Array/Prim/Pinned.hs
6565 benchmark/Streamly/Benchmark/Data/Array/Stream/Foreign.hs
66+ benchmark/Streamly/Benchmark/Data/Fold/Window.hs
6667 benchmark/Streamly/Benchmark/Data/Parser/*.hs
6768 benchmark/Streamly/Benchmark/Data/Stream/*.hs
6869 benchmark/Streamly/Benchmark/FileSystem/*.hs
@@ -113,6 +114,7 @@ extra-source-files:
113114 test/Streamly/Test/Prelude.hs
114115 test/Streamly/Test/Prelude/*.hs
115116 test/Streamly/Test/Unicode/*.hs
117+ test/Streamly/Test/Data/Fold/*.hs
116118 test/lib/Streamly/Test/Common.hs
117119 test/lib/Streamly/Test/Prelude/Common.hs
118120 test/streamly-tests.cabal
@@ -204,6 +206,7 @@ extra-source-files:
204206 core/src/Streamly/Internal/Data/Stream/StreamK.hs
205207 core/src/Streamly/Data/Fold.hs
206208 core/src/Streamly/Data/Fold/Tee.hs
209+ core/src/Streamly/Internal/Data/Fold/Window.hs
207210 core/src/Streamly/Data/Array/Foreign.hs
208211 core/src/Streamly/Internal/Data/Time/Clock/Darwin.c
209212 core/src/Streamly/Internal/Data/Time/Clock/Windows.c
test/Streamly/Test/Data/Fold/Window.hsnew file+61
@@ -0,0 +1,61 @@
1+module Streamly.Test.Data.Fold.Window (main) where
2+
3+import Streamly.Internal.Data.Fold.Window
4+import Test.Hspec (hspec, describe, it, runIO)
5+import qualified Streamly.Internal.Data.Ring.Foreign as Ring
6+import qualified Streamly.Prelude as S
7+
8+import Prelude hiding (sum, maximum, minimum)
9+
10+moduleName :: String
11+moduleName = "Window"
12+
13+main :: IO ()
14+main = hspec $ do
15+ describe moduleName $ do
16+ let numElem = 80000
17+ winSize = 800
18+ testCaseChunk = [9007199254740992, 1, 1.0 :: Double,
19+ 9007199254740992, 1, 1, 1, 9007199254740992]
20+ testCase = take numElem $ cycle testCaseChunk
21+ deviationLimit = 1
22+ testFunc f = do
23+ let c = S.fromList testCase
24+ a <- runIO $ S.fold (Ring.slidingWindow winSize f) c
25+ b <- runIO $ S.fold f $ S.drop (numElem - winSize)
26+ $ S.map (, Nothing) c
27+ let c1 = a - b
28+ it ("should not deviate more than " ++ show deviationLimit)
29+ $ c1 >= -1 * deviationLimit && c1 <= deviationLimit
30+
31+ describe "Sum" $ testFunc sum
32+
33+ describe "Correctness" $ do
34+ let winSize = 3
35+ testCase1 = [31, 41, 59, 26, 53, 58, 97] :: [Double]
36+ testCase2 = replicate 5 1.0 ++ [7.0]
37+
38+ testFunc tc f sI sW = do
39+ let c = S.fromList tc
40+ a <- runIO $ S.toList $ S.postscan f $ S.map (, Nothing) c
41+ b <- runIO $ S.toList $ S.postscan
42+ (Ring.slidingWindow winSize f) c
43+ it "Infinite" $ a == sI
44+ it ("Finite " ++ show winSize) $ b == sW
45+
46+ describe "minimum" $ do
47+ let scanInf = [31, 31, 31, 26, 26, 26, 26] :: [Double]
48+ scanWin = [31, 31, 31, 26, 26, 26, 53] :: [Double]
49+ testFunc testCase1 minimum scanInf scanWin
50+ describe "maximum" $ do
51+ let scanInf = [31, 41, 59, 59, 59, 59, 97] :: [Double]
52+ scanWin = [31, 41, 59, 59, 59, 58, 97] :: [Double]
53+ testFunc testCase1 maximum scanInf scanWin
54+ describe "range" $ do
55+ let scanInf = [0, 10, 28, 33, 33, 33, 71] :: [Double]
56+ scanWin = [0, 10, 28, 33, 33, 32, 44] :: [Double]
57+ testFunc testCase1 range scanInf scanWin
58+ describe "sum" $ do
59+ let scanInf = [1, 2, 3, 4, 5, 12] :: [Double]
60+ scanWin = [1, 2, 3, 3, 3, 9] :: [Double]
61+ testFunc testCase2 sum scanInf scanWin
test/streamly-tests.cabal+6
@@ -251,6 +251,12 @@ test-suite Data.Fold
251251 type: exitcode-stdio-1.0
252252 main-is: Streamly/Test/Data/Fold.hs
253253
254+test-suite Data.Fold.Window
255+ import: test-options
256+ type: exitcode-stdio-1.0
257+ main-is: Streamly/Test/Data/Fold/Window.hs
258+ ghc-options: -main-is Streamly.Test.Data.Fold.Window.main
259+
254260test-suite Data.Parser
255261 import: test-options
256262 type: exitcode-stdio-1.0