Skip to content

Commit bbc048c

Browse files
committed
feat: Add histogram trees to dataframe-learn
1 parent 88ecb2a commit bbc048c

5 files changed

Lines changed: 373 additions & 10 deletions

File tree

‎dataframe-learn/dataframe-learn.cabal‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ library internal
4545
DataFrame.DecisionTree.Pool
4646
DataFrame.DecisionTree.Linear
4747
DataFrame.DecisionTree.Tao
48+
DataFrame.DecisionTree.Histogram
4849
DataFrame.DecisionTree.Fit
4950
DataFrame.LinearSolver
5051
DataFrame.LinearSolver.Loss
@@ -126,6 +127,7 @@ test-suite learn-internal
126127
other-modules: DataFrameApi
127128
Cart
128129
NullSplits
130+
HistogramTrees
129131
DecisionTree
130132
TreePruning
131133
Worklist
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
{-# LANGUAGE BangPatterns #-}
2+
3+
{- | Regression trees grown from per-bin histograms, for gradient boosting.
4+
Each feature is bucketed once per fit; a node's split search then costs
5+
O(bins) per feature instead of a sweep over its sorted rows. Splits, null
6+
handling and leaf values follow 'DataFrame.DecisionTree.Regression': with no
7+
more distinct values than bins, the trees partition the rows identically.
8+
-}
9+
module DataFrame.DecisionTree.Histogram (
10+
Binned,
11+
binFeatures,
12+
TreeLimits (..),
13+
fitBinnedTree,
14+
) where
15+
16+
import Control.Monad.ST (runST)
17+
import Control.Parallel (par, pseq)
18+
import Control.Parallel.Strategies (evalList, parList, rseq, using)
19+
import Data.Maybe (maybeToList)
20+
import qualified Data.Vector as V
21+
import qualified Data.Vector.Unboxed as VU
22+
import qualified Data.Vector.Unboxed.Mutable as VUM
23+
import Data.Word (Word16)
24+
25+
import DataFrame.DecisionTree.Cart (
26+
CartFeature (..),
27+
NullSide (..),
28+
sortIndicesByValue,
29+
splitMidpoint,
30+
)
31+
import DataFrame.DecisionTree.Types (Tree (..))
32+
import DataFrame.Internal.Expression (Expr)
33+
34+
-- | A feature's bins: rows in bin @b@ satisfy @value <= upper ! b@.
35+
data Binned = Binned
36+
{ bnFeature :: !CartFeature
37+
, bnBins :: !(VU.Vector Word16)
38+
-- ^ Bin per row; null rows hold 'bnNBins'.
39+
, bnNBins :: !Int
40+
, bnUpper :: !(VU.Vector Double)
41+
}
42+
43+
-- | Bin every feature into at most @maxBins@ bins (capped at 65535).
44+
binFeatures :: Int -> V.Vector CartFeature -> V.Vector Binned
45+
binFeatures maxBins = V.map (binFeature (max 2 (min 65535 maxBins)))
46+
47+
binFeature :: Int -> CartFeature -> Binned
48+
binFeature maxBins f = Binned f bins nb upper
49+
where
50+
vals = cfValues f
51+
isNull i = maybe False (VU.! i) (cfNulls f)
52+
sorted =
53+
VU.map (vals VU.!) (VU.filter (not . isNull) (sortIndicesByValue vals))
54+
runs = runLengths sorted
55+
distinct = map fst runs
56+
edges
57+
| length runs <= maxBins = VU.fromList (zipWith splitMidpoint distinct (drop 1 distinct))
58+
| otherwise = VU.fromList (cutEdges maxBins (VU.length sorted) runs)
59+
nb = VU.length edges + 1
60+
-- The last bin's threshold is the largest value, so "every non-null row
61+
-- left" stays a valid null-vs-non-null split, as in the exact tree.
62+
upper = VU.snoc edges (if VU.null sorted then 0 else VU.last sorted)
63+
bins = VU.generate (VU.length vals) binOf
64+
binOf i
65+
| isNull i = fromIntegral nb
66+
| otherwise = fromIntegral (lowerBound edges (vals VU.! i))
67+
68+
-- | Cut points giving bins of roughly equal row counts, never inside a run.
69+
cutEdges :: Int -> Int -> [(Double, Int)] -> [Double]
70+
cutEdges maxBins n = go 0 0
71+
where
72+
perBin = fromIntegral n / fromIntegral maxBins :: Double
73+
go :: Int -> Int -> [(Double, Int)] -> [Double]
74+
go !nCuts !acc ((v, c) : rest@((v', _) : _))
75+
| nCuts >= maxBins - 1 = []
76+
| fromIntegral (acc + c) >= perBin = splitMidpoint v v' : go (nCuts + 1) 0 rest
77+
| otherwise = go nCuts (acc + c) rest
78+
go _ _ _ = []
79+
80+
runLengths :: VU.Vector Double -> [(Double, Int)]
81+
runLengths v = go 0
82+
where
83+
n = VU.length v
84+
go i
85+
| i >= n = []
86+
| otherwise =
87+
let x = v VU.! i
88+
j = until (\k -> k >= n || v VU.! k /= x) (+ 1) i
89+
in (x, j - i) : go j
90+
91+
-- | Number of edges below @x@: the bin @x@ falls in.
92+
lowerBound :: VU.Vector Double -> Double -> Int
93+
lowerBound edges x = go 0 (VU.length edges)
94+
where
95+
go lo hi
96+
| lo >= hi = lo
97+
| VU.unsafeIndex edges m < x = go (m + 1) hi
98+
| otherwise = go lo m
99+
where
100+
m = (lo + hi) `div` 2
101+
102+
-- | The stopping rules of 'DataFrame.DecisionTree.Regression.RegTreeConfig'.
103+
data TreeLimits = TreeLimits
104+
{ tlMaxDepth :: !Int
105+
, tlMinSamplesSplit :: !Int
106+
, tlMinLeafSize :: !Int
107+
, tlMinImpurityDecrease :: !Double
108+
}
109+
110+
{- | Per-bin Σw, Σwy, Σwy² and row count, four slots per bin, null bin last.
111+
Built strictly: left lazy, the histograms of a deep tree pile up as thunks.
112+
-}
113+
type Hist = VU.Vector Double
114+
115+
buildHist :: VU.Vector Double -> VU.Vector Double -> VU.Vector Int -> Binned -> Hist
116+
buildHist w y idxs bn = runST $ do
117+
m <- VUM.replicate (4 * (bnNBins bn + 1)) 0
118+
VU.forM_ idxs $ \i -> do
119+
let s = 4 * fromIntegral (VU.unsafeIndex (bnBins bn) i)
120+
wi = VU.unsafeIndex w i
121+
yi = VU.unsafeIndex y i
122+
VUM.unsafeModify m (+ wi) s
123+
VUM.unsafeModify m (+ wi * yi) (s + 1)
124+
VUM.unsafeModify m (+ wi * yi * yi) (s + 2)
125+
VUM.unsafeModify m (+ 1) (s + 3)
126+
VU.unsafeFreeze m
127+
128+
data Node
129+
= NLeaf !Double !(VU.Vector Int)
130+
| NBranch !(Expr Bool) !Node !Node
131+
132+
{- | Fit a weighted-SSE regression tree on binned features; returns the tree
133+
and its in-sample predictions.
134+
-}
135+
fitBinnedTree ::
136+
TreeLimits ->
137+
V.Vector Binned ->
138+
VU.Vector Double ->
139+
Maybe (VU.Vector Double) ->
140+
(Tree Double, VU.Vector Double)
141+
fitBinnedTree lim binned y mw = (toTree root, inSample)
142+
where
143+
n = VU.length y
144+
w = maybe (VU.replicate n 1) id mw
145+
allIdx = VU.enumFromN 0 n
146+
root = node 0 allIdx (hists allIdx)
147+
inSample = VU.update (VU.replicate n 0) (VU.concat (leafRows root))
148+
leafRows (NLeaf v idxs) = [VU.map (\i -> (i, v)) idxs]
149+
leafRows (NBranch _ l r) = leafRows l ++ leafRows r
150+
151+
-- Feature-parallel only where a node is big enough to pay for the sparks.
152+
hists idxs =
153+
V.fromList (map (buildHist w y idxs) (V.toList binned) `using` strategy)
154+
where
155+
strategy = if VU.length idxs >= 20000 then parList rseq else evalList rseq
156+
157+
node depth idxs hs
158+
| depth >= tlMaxDepth lim || VU.length idxs < tlMinSamplesSplit lim || V.null hs = leaf
159+
| otherwise = maybe leaf split (bestSplit lim binned hs)
160+
where
161+
Totals tw tsy _ _ = histTotals (V.head hs)
162+
leaf = NLeaf (if tw == 0 then 0 else tsy / tw) idxs
163+
split (fj, b, nullLeft) =
164+
forceNode l `par` (forceNode r `pseq` NBranch cond l r)
165+
where
166+
bn = binned V.! fj
167+
goesLeft i =
168+
let k = fromIntegral (VU.unsafeIndex (bnBins bn) i)
169+
in if k == bnNBins bn then nullLeft else k <= b
170+
lefts = VU.filter goesLeft idxs
171+
rights = VU.filter (not . goesLeft) idxs
172+
-- Histogram the smaller child; the larger is parent minus it.
173+
smallIsLeft = VU.length lefts <= VU.length rights
174+
smallH = hists (if smallIsLeft then lefts else rights)
175+
bigH = V.zipWith (VU.zipWith (-)) hs smallH
176+
(lh, rh) = if smallIsLeft then (smallH, bigH) else (bigH, smallH)
177+
l = node (depth + 1) lefts lh
178+
r = node (depth + 1) rights rh
179+
side = if nullLeft then NullsLeft else NullsRight
180+
cond = cfSplit (bnFeature bn) (bnUpper bn VU.! b) side
181+
182+
data Totals = Totals !Double !Double !Double !Double
183+
184+
histTotals :: Hist -> Totals
185+
histTotals h = go 0 0 0 0 0
186+
where
187+
slots = VU.length h `div` 4
188+
go !k !a !b !c !d
189+
| k >= slots = Totals a b c d
190+
| otherwise =
191+
go
192+
(k + 1)
193+
(a + h VU.! (4 * k))
194+
(b + h VU.! (4 * k + 1))
195+
(c + h VU.! (4 * k + 2))
196+
(d + h VU.! (4 * k + 3))
197+
198+
{- | Best @(feature, bin, nulls left)@ by SSE reduction. Each bin boundary is
199+
scored with the node's null rows on the right and then on the left; ties keep
200+
the earliest candidate, as in the exact sweep.
201+
-}
202+
bestSplit :: TreeLimits -> V.Vector Binned -> V.Vector Hist -> Maybe (Int, Int, Bool)
203+
bestSplit lim binned hs
204+
| null candidates = Nothing
205+
| red > 0 && red >= tlMinImpurityDecrease lim = Just sp
206+
| otherwise = Nothing
207+
where
208+
Totals totW totSY totSY2 totC = histTotals (V.head hs)
209+
nNode = round totC :: Int
210+
nodeSSE = sse totSY totSY2 totW
211+
candidates = concat (zipWith featBest [0 ..] (V.toList hs))
212+
(red, sp) = maximumByFst candidates
213+
featBest fj h = [(r, (fj, b, nl)) | (b, nl, r) <- maybeToList (scan 0 0 0 0 0 Nothing)]
214+
where
215+
nb = bnNBins (binned V.! fj)
216+
nW = h VU.! (4 * nb)
217+
nSY = h VU.! (4 * nb + 1)
218+
nSY2 = h VU.! (4 * nb + 2)
219+
nC = round (h VU.! (4 * nb + 3)) :: Int
220+
hasNulls = nC > 0
221+
lastB = if hasNulls then nb - 1 else nb - 2
222+
score nl wl syl syl2
223+
| nl >= tlMinLeafSize lim && nNode - nl >= tlMinLeafSize lim && wl > 0 && wr > 0 =
224+
Just (nodeSSE - (sse syl syl2 wl + sse (totSY - syl) (totSY2 - syl2) wr))
225+
| otherwise = Nothing
226+
where
227+
wr = totW - wl
228+
scan :: Int -> Double -> Double -> Double -> Int -> Maybe (Int, Bool, Double) -> Maybe (Int, Bool, Double)
229+
scan !b !wl !syl !syl2 !cl best
230+
| b > lastB = best
231+
| otherwise = scan (b + 1) wl' syl' syl2' cl' best'
232+
where
233+
wl' = wl + h VU.! (4 * b)
234+
syl' = syl + h VU.! (4 * b + 1)
235+
syl2' = syl2 + h VU.! (4 * b + 2)
236+
cl' = cl + round (h VU.! (4 * b + 3))
237+
right = (,) False <$> score cl' wl' syl' syl2'
238+
left
239+
| hasNulls = (,) True <$> score (cl' + nC) (wl' + nW) (syl' + nSY) (syl2' + nSY2)
240+
| otherwise = Nothing
241+
consider bst (dir, r)
242+
| maybe True (\(_, _, rb) -> r > rb) bst = Just (b, dir, r)
243+
| otherwise = bst
244+
best' = foldl consider best (maybeToList right ++ maybeToList left)
245+
246+
-- | Weighted SSE of a node from its Σy, Σy², and total weight.
247+
sse :: Double -> Double -> Double -> Double
248+
sse sumY sumSq wt = sumSq - (if wt == 0 then 0 else sumY * sumY / wt)
249+
250+
maximumByFst :: (Ord a) => [(a, b)] -> (a, b)
251+
maximumByFst = foldr1 (\x@(a, _) y@(b, _) -> if a >= b then x else y)
252+
253+
toTree :: Node -> Tree Double
254+
toTree (NLeaf v _) = Leaf v
255+
toTree (NBranch c l r) = Branch c (toTree l) (toTree r)
256+
257+
-- | Force a subtree so the sibling's spark has real work (cf. 'Regression').
258+
forceNode :: Node -> ()
259+
forceNode (NLeaf v _) = v `seq` ()
260+
forceNode (NBranch _ l r) = forceNode l `seq` forceNode r

‎dataframe-learn/src/DataFrame/Boosting/GBM.hs‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
{- | Gradient boosting of regression trees (Friedman). Trees are fitted to the
1010
negative gradient of the loss each round and accumulated with a shrinkage
1111
factor; squared error gives regression, logistic deviance gives binary
12-
classification. 'predict' is the additive score; 'gbProbaExpr' /
12+
classification. Split search runs on per-feature histograms (see
13+
'gbMaxBins'), so a tree costs O(rows) rather than a sweep per node.
14+
'predict' is the additive score; 'gbProbaExpr' /
1315
'gbDecisionExpr' give the classification probability / decision.
1416
-}
1517
module DataFrame.Boosting.GBM (
@@ -32,7 +34,7 @@ import DataFrame.Errors (DataFrameException (..))
3234

3335
import DataFrame.DecisionTree.Cart (cartFeatures)
3436
import DataFrame.DecisionTree.Fit (treeToExpr)
35-
import DataFrame.DecisionTree.Regression (RegFit (..), RegTreeConfig (..), fitRegTree)
37+
import DataFrame.DecisionTree.Histogram (TreeLimits (..), binFeatures, fitBinnedTree)
3638
import DataFrame.DecisionTree.Types (Tree)
3739
import DataFrame.Expression.Operators ((.*.), (.+.), (.>.))
3840
import DataFrame.Featurize.Internal (targetDoubles)
@@ -50,6 +52,9 @@ data GBConfig = GBConfig
5052
, gbNEstimators :: !Int
5153
, gbLearningRate :: !Double
5254
, gbMaxDepth :: !Int
55+
, gbMaxBins :: !Int
56+
-- ^ Bins per feature for split finding. Features with at most this many
57+
-- distinct values split exactly as on the raw values.
5358
, gbSeed :: !Int
5459
}
5560
deriving (Eq, Show)
@@ -61,6 +66,7 @@ defaultGBConfig =
6166
, gbNEstimators = 100
6267
, gbLearningRate = 0.1
6368
, gbMaxDepth = 3
69+
, gbMaxBins = 1024
6470
, gbSeed = 0
6571
}
6672

@@ -97,16 +103,16 @@ fitGBM cfg target@(Col name) df =
97103
(VU.fromList (reverse scores))
98104
usage
99105
where
100-
feats = V.fromList (cartFeatures name df)
106+
binned = binFeatures (gbMaxBins cfg) (V.fromList (cartFeatures name df))
101107
y = targetDoubles target df
102108
n = VU.length y
103109
lr = gbLearningRate cfg
104-
rtCfg =
105-
RegTreeConfig
106-
{ rtMaxDepth = gbMaxDepth cfg
107-
, rtMinSamplesSplit = 2
108-
, rtMinLeafSize = 1
109-
, rtMinImpurityDecrease = 0.0
110+
limits =
111+
TreeLimits
112+
{ tlMaxDepth = gbMaxDepth cfg
113+
, tlMinSamplesSplit = 2
114+
, tlMinLeafSize = 1
115+
, tlMinImpurityDecrease = 0.0
110116
}
111117
f0 = case gbLoss cfg of
112118
SquaredError -> VU.sum y / fromIntegral (max 1 n)
@@ -118,7 +124,7 @@ fitGBM cfg target@(Col name) df =
118124
| m >= gbNEstimators cfg = (ts, ss, usageAcc)
119125
| otherwise =
120126
let (target', weights) = newtonStep (gbLoss cfg) y fScores
121-
RegFit tree pred = fitRegTree rtCfg feats target' weights
127+
(tree, pred) = fitBinnedTree limits binned target' weights
122128
fScores' = VU.zipWith (\f p -> f + lr * p) fScores pred
123129
score = lossValue (gbLoss cfg) y fScores'
124130
usage' = foldr (\c -> M.insertWith (+) c 1) usageAcc (treeColumns tree)

0 commit comments

Comments
 (0)