|
| 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 |
0 commit comments