packages feed

dataframe-learn-2.5.0.0: src-internal/DataFrame/DecisionTree/Histogram.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE TupleSections #-}

-- | Regression trees grown from per-bin histograms, for gradient boosting.
module DataFrame.DecisionTree.Histogram (
    Binned,
    BinnedFeatures,
    binFeatures,
    TreeLimits (..),
    fitBinnedTree,
    fitBinnedTreeOn,
) where

import Control.Monad.ST (runST)
import Control.Parallel (par, pseq)
import Data.Maybe (fromMaybe, maybeToList)
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as VU
import qualified Data.Vector.Unboxed.Mutable as VUM
import Data.Word (Word16)

import DataFrame.DecisionTree.Cart (
    CartFeature (..),
    NullSide (..),
    sortIndicesByValue,
    splitMidpoint,
 )
import DataFrame.DecisionTree.Types (Tree (..))
import DataFrame.Internal.Expression (Expr)

-- | A feature's bins: rows in bin @b@ satisfy @value <= upper ! b@.
data Binned = Binned
    { bnFeature :: !CartFeature
    , bnBins :: !(VU.Vector Word16)
    -- ^ Bin per row; null rows hold 'bnNBins'.
    , bnNBins :: !Int
    , bnUpper :: !(VU.Vector Double)
    }

-- | Every feature's bins, also stored row by row for cache locality.
data BinnedFeatures = BinnedFeatures
    { bfFeatures :: !(V.Vector Binned)
    , bfRows :: !(VU.Vector Word16)
    , bfOffsets :: !(VU.Vector Int)
    -- ^ Where each feature's histogram starts in a node's histogram buffer.
    , bfSlots :: !Int
    }

-- | Bin every feature into at most @maxBins@ bins (capped at 65535).
binFeatures :: Int -> V.Vector CartFeature -> BinnedFeatures
binFeatures maxBins features = BinnedFeatures binned rows offsets (VU.last sizes)
  where
    binned = V.map (binFeature (max 2 (min 65535 maxBins))) features
    nf = V.length binned
    n = if nf == 0 then 0 else VU.length (bnBins (V.head binned))
    sizes =
        VU.prescanl'
            (+)
            0
            (VU.fromList [4 * (bnNBins b + 1) | b <- V.toList binned] `VU.snoc` 0)
    offsets = VU.take nf sizes
    rows = VU.create $ do
        m <- VUM.unsafeNew (n * nf)
        V.iforM_ binned $ \j b ->
            VU.iforM_ (bnBins b) $ \i bin -> VUM.unsafeWrite m (i * nf + j) bin
        pure m

binFeature :: Int -> CartFeature -> Binned
binFeature maxBins f = Binned f bins nb upper
  where
    vals = cfValues f
    isNull i = maybe False (VU.! i) (cfNulls f)
    sorted =
        VU.map (vals VU.!) (VU.filter (not . isNull) (sortIndicesByValue vals))
    runs = runLengths sorted
    distinct = map fst runs
    edges
        | length runs <= maxBins =
            VU.fromList (zipWith splitMidpoint distinct (drop 1 distinct))
        | otherwise = VU.fromList (cutEdges maxBins (VU.length sorted) runs)
    nb = VU.length edges + 1
    -- Top threshold is the largest value, so null vs non-null stays valid.
    upper = VU.snoc edges (if VU.null sorted then 0 else VU.last sorted)
    bins = VU.generate (VU.length vals) binOf
    binOf i
        | isNull i = fromIntegral nb
        | otherwise = fromIntegral (lowerBound edges (vals VU.! i))

-- | Cut points giving bins of roughly equal row counts, never inside a run.
cutEdges :: Int -> Int -> [(Double, Int)] -> [Double]
cutEdges maxBins n = go 0 0
  where
    perBin = fromIntegral n / fromIntegral maxBins :: Double
    go :: Int -> Int -> [(Double, Int)] -> [Double]
    go !nCuts !acc ((v, c) : rest@((v', _) : _))
        | nCuts >= maxBins - 1 = []
        | fromIntegral (acc + c) >= perBin = splitMidpoint v v' : go (nCuts + 1) 0 rest
        | otherwise = go nCuts (acc + c) rest
    go _ _ _ = []

runLengths :: VU.Vector Double -> [(Double, Int)]
runLengths v = go 0
  where
    n = VU.length v
    go i
        | i >= n = []
        | otherwise =
            let x = v VU.! i
                j = until (\k -> k >= n || v VU.! k /= x) (+ 1) i
             in (x, j - i) : go j

-- | Number of edges below @x@: the bin @x@ falls in.
lowerBound :: VU.Vector Double -> Double -> Int
lowerBound edges x = go 0 (VU.length edges)
  where
    go lo hi
        | lo >= hi = lo
        | VU.unsafeIndex edges m < x = go (m + 1) hi
        | otherwise = go lo m
      where
        m = (lo + hi) `div` 2

-- | The stopping rules of 'DataFrame.DecisionTree.Regression.RegTreeConfig'.
data TreeLimits = TreeLimits
    { tlMaxDepth :: !Int
    , tlMinSamplesSplit :: !Int
    , tlMinLeafSize :: !Int
    , tlMinImpurityDecrease :: !Double
    , tlL2 :: !Double
    -- ^ Added to every node's total weight, shrinking leaf values and gains.
    , tlMinChildWeight :: !Double
    -- ^ Smallest total weight a child may have.
    }

-- | Per-bin weight, weighted-y sums and row count, four slots per bin.
type Hist = VU.Vector Double

-- | Every feature's histogram for a node, built in one pass over its rows.
buildHists ::
    BinnedFeatures ->
    VU.Vector Int ->
    VU.Vector Double ->
    VU.Vector Double ->
    VU.Vector Int ->
    V.Vector Hist
buildHists bf allowed w y idxs = V.imap slice (bfFeatures bf)
  where
    nf = V.length (bfFeatures bf)
    na = VU.length allowed
    slice j b = VU.slice (VU.unsafeIndex (bfOffsets bf) j) (4 * (bnNBins b + 1)) flat
    flat = runST $ do
        m <- VUM.replicate (bfSlots bf) 0
        VU.forM_ idxs $ \i -> do
            let wi = VU.unsafeIndex w i
                yi = VU.unsafeIndex y i
                wy = wi * yi
                wyy = wy * yi
                row = i * nf
                go !k
                    | k >= na = pure ()
                    | otherwise = do
                        let j = VU.unsafeIndex allowed k
                            bin = fromIntegral (VU.unsafeIndex (bfRows bf) (row + j))
                            s = VU.unsafeIndex (bfOffsets bf) j + 4 * bin
                        VUM.unsafeModify m (+ wi) s
                        VUM.unsafeModify m (+ wy) (s + 1)
                        VUM.unsafeModify m (+ wyy) (s + 2)
                        VUM.unsafeModify m (+ 1) (s + 3)
                        go (k + 1)
            go 0
        VU.unsafeFreeze m

data Node
    = NLeaf !Double !(VU.Vector Int)
    | NBranch !(Expr Bool) !Node !Node

-- | Fit a weighted-SSE regression tree on binned features.
fitBinnedTree ::
    TreeLimits ->
    BinnedFeatures ->
    VU.Vector Double ->
    Maybe (VU.Vector Double) ->
    (Tree Double, VU.Vector Double)
fitBinnedTree lim bf = fitBinnedTreeOn (VU.enumFromN 0 (V.length (bfFeatures bf))) lim bf

-- | 'fitBinnedTree' splitting only on the features at the given indices.
fitBinnedTreeOn ::
    VU.Vector Int ->
    TreeLimits ->
    BinnedFeatures ->
    VU.Vector Double ->
    Maybe (VU.Vector Double) ->
    (Tree Double, VU.Vector Double)
fitBinnedTreeOn allowed lim bf y mw = (toTree root, inSample)
  where
    binned = bfFeatures bf
    n = VU.length y
    w = Data.Maybe.fromMaybe (VU.replicate n 1) mw
    allIdx = VU.enumFromN 0 n
    root = node 0 allIdx (hists allIdx)
    inSample = VU.update (VU.replicate n 0) (VU.concat (leafRows root))
    leafRows (NLeaf v idxs) = [VU.map (,v) idxs]
    leafRows (NBranch _ l r) = leafRows l ++ leafRows r

    hists = buildHists bf allowed w y
    -- Every allowed feature's histogram covers all of a node's rows.
    totalsOf hs = histTotals (hs V.! VU.head allowed)

    node depth idxs hs
        | depth >= tlMaxDepth lim || VU.length idxs < tlMinSamplesSplit lim || V.null hs =
            leaf
        | otherwise = maybe leaf split (bestSplit lim binned allowed hs)
      where
        Totals tw tsy _ _ = totalsOf hs
        leaf = NLeaf (if tw + tlL2 lim == 0 then 0 else tsy / (tw + tlL2 lim)) idxs
        split (fj, b, nullLeft) =
            forceNode l `par` (forceNode r `pseq` NBranch cond l r)
          where
            bn = binned V.! fj
            goesLeft i =
                let k = fromIntegral (VU.unsafeIndex (bnBins bn) i)
                 in if k == bnNBins bn then nullLeft else k <= b
            lefts = VU.filter goesLeft idxs
            rights = VU.filter (not . goesLeft) idxs
            -- Histogram the smaller child; the larger is parent minus it.
            smallIsLeft = VU.length lefts <= VU.length rights
            smallH = hists (if smallIsLeft then lefts else rights)
            bigH = V.zipWith (VU.zipWith (-)) hs smallH
            (lh, rh) = if smallIsLeft then (smallH, bigH) else (bigH, smallH)
            l = node (depth + 1) lefts lh
            r = node (depth + 1) rights rh
            side = if nullLeft then NullsLeft else NullsRight
            cond = cfSplit (bnFeature bn) (bnUpper bn VU.! b) side

data Totals = Totals !Double !Double !Double !Double

histTotals :: Hist -> Totals
histTotals h = go 0 0 0 0 0
  where
    slots = VU.length h `div` 4
    go !k !a !b !c !d
        | k >= slots = Totals a b c d
        | otherwise =
            go
                (k + 1)
                (a + h VU.! (4 * k))
                (b + h VU.! (4 * k + 1))
                (c + h VU.! (4 * k + 2))
                (d + h VU.! (4 * k + 3))

-- | Best (feature, bin, nulls left) by SSE reduction.
bestSplit ::
    TreeLimits ->
    V.Vector Binned ->
    VU.Vector Int ->
    V.Vector Hist ->
    Maybe (Int, Int, Bool)
bestSplit lim binned allowed hs
    | null candidates = Nothing
    | red > 0 && red >= tlMinImpurityDecrease lim = Just sp
    | otherwise = Nothing
  where
    Totals totW totSY totSY2 totC = histTotals (hs V.! VU.head allowed)
    nNode = round totC :: Int
    sse = penalisedSse (tlL2 lim)
    nodeSSE = sse totSY totSY2 totW
    candidates = concat [featBest fj (hs V.! fj) | fj <- VU.toList allowed]
    (red, sp) = maximumByFst candidates
    featBest fj h = [(r, (fj, b, nl)) | (b, nl, r) <- maybeToList (scan 0 0 0 0 0 Nothing)]
      where
        nb = bnNBins (binned V.! fj)
        nW = h VU.! (4 * nb)
        nSY = h VU.! (4 * nb + 1)
        nSY2 = h VU.! (4 * nb + 2)
        nC = round (h VU.! (4 * nb + 3)) :: Int
        hasNulls = nC > 0
        lastB = if hasNulls then nb - 1 else nb - 2
        score nl wl syl syl2
            | nl >= tlMinLeafSize lim
            , nNode - nl >= tlMinLeafSize lim
            , wl > 0
            , wr > 0
            , wl >= tlMinChildWeight lim
            , wr >= tlMinChildWeight lim =
                Just (nodeSSE - (sse syl syl2 wl + sse (totSY - syl) (totSY2 - syl2) wr))
            | otherwise = Nothing
          where
            wr = totW - wl
        scan ::
            Int ->
            Double ->
            Double ->
            Double ->
            Int ->
            Maybe (Int, Bool, Double) ->
            Maybe (Int, Bool, Double)
        scan !b !wl !syl !syl2 !cl best
            | b > lastB = best
            | otherwise = scan (b + 1) wl' syl' syl2' cl' best'
          where
            wl' = wl + h VU.! (4 * b)
            syl' = syl + h VU.! (4 * b + 1)
            syl2' = syl2 + h VU.! (4 * b + 2)
            cl' = cl + round (h VU.! (4 * b + 3))
            right = (,) False <$> score cl' wl' syl' syl2'
            left
                | hasNulls =
                    (,) True <$> score (cl' + nC) (wl' + nW) (syl' + nSY) (syl2' + nSY2)
                | otherwise = Nothing
            consider bst (dir, r)
                | maybe True (\(_, _, rb) -> r > rb) bst = Just (b, dir, r)
                | otherwise = bst
            best' = foldl consider best (maybeToList right ++ maybeToList left)

-- | Weighted SSE of a node, with l2 added to the weight.
penalisedSse :: Double -> Double -> Double -> Double -> Double
penalisedSse l2 sumY sumSq wt = sumSq - (if wt + l2 == 0 then 0 else sumY * sumY / (wt + l2))

maximumByFst :: (Ord a) => [(a, b)] -> (a, b)
maximumByFst = foldr1 (\x@(a, _) y@(b, _) -> if a >= b then x else y)

toTree :: Node -> Tree Double
toTree (NLeaf v _) = Leaf v
toTree (NBranch c l r) = Branch c (toTree l) (toTree r)

-- | Force a subtree so the sibling's spark has real work (cf. 'Regression').
forceNode :: Node -> ()
forceNode (NLeaf v _) = v `seq` ()
forceNode (NBranch _ l r) = forceNode l `seq` forceNode r