packages feed

dataframe-learn-2.5.0.0: src/DataFrame/Boosting/GBM.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}

-- | Gradient boosting of regression trees on per-feature histograms.
module DataFrame.Boosting.GBM (
    module DataFrame.Model,
    GBLoss (..),
    GBConfig (..),
    defaultGBConfig,
    GBModel (..),
    gbExprAtStage,
    gbProbaExpr,
    gbDecisionExpr,
) where

import Control.Exception (throw)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as VU
import DataFrame.Errors (DataFrameException (..))

import Data.Maybe (fromMaybe)
import DataFrame.DecisionTree.Cart (cartFeaturesByColumn)
import DataFrame.DecisionTree.Fit (treeToExpr)
import DataFrame.DecisionTree.Histogram (
    TreeLimits (..),
    binFeatures,
    fitBinnedTreeOn,
 )
import DataFrame.DecisionTree.Types (Tree)
import DataFrame.Expression.Operators ((.*.), (.+.), (.>.))
import DataFrame.Featurize.Internal (targetDoubles)
import qualified DataFrame.Functions as F
import DataFrame.Internal.DataFrame (DataFrame)
import DataFrame.Internal.Expression (Expr (..), getColumns)
import DataFrame.Model
import System.Random (StdGen, mkStdGen, randoms)

-- | The boosting loss.
data GBLoss = SquaredError | LogisticDeviance
    deriving (Eq, Show)

data GBConfig = GBConfig
    { gbLoss :: !GBLoss
    , gbNEstimators :: !Int
    , gbLearningRate :: !Double
    , gbMaxDepth :: !Int
    , gbMaxBins :: !Int
    -- ^ Bins per feature for split finding.
    , gbSeed :: !Int
    , gbL2 :: !Double
    -- ^ L2 penalty on leaf values (XGBoost's @lambda@).
    , gbMinChildWeight :: !Double
    -- ^ Smallest Hessian sum a leaf may have (XGBoost's @min_child_weight@).
    , gbFeatureGroups :: ![[T.Text]]
    -- ^ Column groups: tree m splits only on group m mod the group count.
    , gbBaseScore :: !(Maybe (Expr Double))
    -- ^ Per-row scores to boost from instead of a constant.
    , gbSubsample :: !Double
    -- ^ Fraction of rows each tree is fitted on (1 = all).
    , gbColsample :: !Double
    -- ^ Fraction of allowed features each tree may split on (1 = all).
    }
    deriving (Show)

defaultGBConfig :: GBConfig
defaultGBConfig =
    GBConfig
        { gbLoss = SquaredError
        , gbNEstimators = 100
        , gbLearningRate = 0.1
        , gbMaxDepth = 3
        , gbMaxBins = 1024
        , gbSeed = 0
        , gbL2 = 0
        , gbMinChildWeight = 0
        , gbFeatureGroups = []
        , gbBaseScore = Nothing
        , gbSubsample = 1
        , gbColsample = 1
        }

-- | A fitted gradient-boosting model: initial score plus staged trees.
data GBModel = GBModel
    { gbInit :: !Double
    , gbTrees :: !(V.Vector (Tree Double))
    , gbRate :: !Double
    , gbModelLoss :: !GBLoss
    , gbTrainScore :: !(VU.Vector Double)
    , gbFeatureUsage :: !(M.Map T.Text Int)
    , gbBase :: !(Maybe (Expr Double))
    }
    deriving (Show)

instance Fit GBConfig (Expr Double) where
    type ModelOf GBConfig (Expr Double) = GBModel
    fit = fitGBM

instance Predict GBModel where
    type Prediction GBModel = Expr Double
    predict = gbExpr

-- | Fit a gradient-boosting ensemble predicting the target column.
fitGBM :: GBConfig -> Expr Double -> DataFrame -> GBModel
fitGBM cfg target@(Col name) df =
    GBModel
        f0
        (V.fromList (reverse trees))
        lr
        (gbLoss cfg)
        (VU.fromList (reverse scores))
        usage
        (gbBaseScore cfg)
  where
    byColumn = cartFeaturesByColumn name df
    binned = binFeatures (gbMaxBins cfg) (V.fromList (map snd byColumn))
    allFeatures = VU.enumFromN 0 (length byColumn)
    groupFeatures =
        [ VU.fromList [j | (j, (c, _)) <- zip [0 ..] byColumn, c `elem` g]
        | g <- gbFeatureGroups cfg
        ]
    featuresFor m =
        let allowed = case groupFeatures of
                [] -> allFeatures
                gs -> gs !! (m `mod` length gs)
         in if gbColsample cfg >= 1
                then allowed
                else
                    keepFraction
                        (gbColsample cfg)
                        (mkStdGen (7919 * gbSeed cfg + 2 * m + 1))
                        allowed
    rowMask m
        | gbSubsample cfg >= 1 = Nothing
        | otherwise =
            Just
                ( VU.fromList
                    ( take
                        n
                        ( map
                            (\u -> if u < gbSubsample cfg then 1 else 0)
                            (randoms (mkStdGen (7919 * gbSeed cfg + 2 * m)) :: [Double])
                        )
                    )
                )
    y = targetDoubles target df
    n = VU.length y
    lr = gbLearningRate cfg
    limits =
        TreeLimits
            { tlMaxDepth = gbMaxDepth cfg
            , tlMinSamplesSplit = 2
            , tlMinLeafSize = 1
            , tlMinImpurityDecrease = 0.0
            , tlL2 = gbL2 cfg
            , tlMinChildWeight = gbMinChildWeight cfg
            }
    baseScores = fmap (`targetDoubles` df) (gbBaseScore cfg)
    f0 = case (baseScores, gbLoss cfg) of
        (Just _, _) -> 0
        (Nothing, SquaredError) -> VU.sum y / fromIntegral (max 1 n)
        (Nothing, LogisticDeviance) ->
            let p = clamp01 (VU.sum y / fromIntegral (max 1 n))
             in log (p / (1 - p))
    (trees, scores, usage) = boost 0 (fromMaybe (VU.replicate n f0) baseScores) [] [] M.empty
    boost !m fScores ts ss usageAcc
        | m >= gbNEstimators cfg = (ts, ss, usageAcc)
        | otherwise =
            let (target', weights0) = newtonStep (gbLoss cfg) y fScores
                weights = case rowMask m of
                    Nothing -> weights0
                    Just mask -> Just (VU.zipWith (*) mask (fromMaybe (VU.replicate n 1) weights0))
                (tree, pred) = fitBinnedTreeOn (featuresFor m) limits binned target' weights
                fScores' = VU.zipWith (\f p -> f + lr * p) fScores pred
                !score = lossValue (gbLoss cfg) y fScores'
                !usage' = foldr (\c -> M.insertWith (+) c 1) usageAcc (treeColumns tree)
             in boost (m + 1) fScores' (tree : ts) (score : ss) usage'
fitGBM _ expr _ =
    throw (NonColumnReferenceException ("fitGBM: " <> T.pack (show expr)))

newtonStep ::
    GBLoss ->
    VU.Vector Double ->
    VU.Vector Double ->
    (VU.Vector Double, Maybe (VU.Vector Double))
newtonStep SquaredError y f = (negGradient SquaredError y f, Nothing)
newtonStep LogisticDeviance y f = (z, Just h)
  where
    p = VU.map sigmoid f
    h = VU.map (\pi' -> max hFloor (pi' * (1 - pi'))) p
    z = VU.zipWith3 (\yi pi' hi -> (yi - pi') / hi) y p h

-- | Floor on the Hessian, so a saturated row cannot produce an unbounded step.
hFloor :: Double
hFloor = 1e-6

negGradient ::
    GBLoss -> VU.Vector Double -> VU.Vector Double -> VU.Vector Double
negGradient SquaredError y f = VU.zipWith (-) y f
negGradient LogisticDeviance y f =
    VU.zipWith (\yi fi -> yi - sigmoid fi) y f

lossValue :: GBLoss -> VU.Vector Double -> VU.Vector Double -> Double
lossValue SquaredError y f =
    VU.sum (VU.zipWith (\yi fi -> (yi - fi) ^ (2 :: Int)) y f)
        / fromIntegral (max 1 (VU.length y))
lossValue LogisticDeviance y f =
    VU.sum
        ( VU.zipWith
            ( \yi fi -> let p = clamp01 (sigmoid fi) in negate (yi * log p + (1 - yi) * log (1 - p))
            )
            y
            f
        )
        / fromIntegral (max 1 (VU.length y))

sigmoid :: Double -> Double
sigmoid z
    | z >= 0 = 1 / (1 + exp (-z))
    | otherwise = let e = exp z in e / (1 + e)

clamp01 :: Double -> Double
clamp01 p = max 1e-12 (min (1 - 1e-12) p)

treeColumns :: Tree Double -> [T.Text]
treeColumns = getColumns . treeToExpr

-- | The full additive prediction expression: @f0 + lr · Σ treeᵢ@.
gbExpr :: GBModel -> Expr Double
gbExpr m = stageExpr (V.length (gbTrees m)) m

-- | The prediction expression using only the first @k@ trees (staged predict).
gbExprAtStage :: Int -> GBModel -> Maybe (Expr Double)
gbExprAtStage k m
    | k < 0 || k > V.length (gbTrees m) = Nothing
    | otherwise = Just (stageExpr k m)

stageExpr :: Int -> GBModel -> Expr Double
stageExpr k m =
    foldr ((.+.) . scaled) start (take k (V.toList (gbTrees m)))
  where
    start = maybe (F.lit (gbInit m)) (\b -> b .+. F.lit (gbInit m)) (gbBase m)
    scaled t = F.lit (gbRate m) .*. treeToExpr t

-- | Probability expression for classification: @sigmoid(score)@.
gbProbaExpr :: GBModel -> Expr Double
gbProbaExpr m = F.lit 1 / (F.lit 1 + exp (negate (gbExpr m)))

-- | Decision expression for classification: positive class when score > 0.
gbDecisionExpr :: GBModel -> Expr Bool
gbDecisionExpr m = gbExpr m .>. F.lit 0

-- | A uniformly chosen subset of the given size fraction, in order.
keepFraction :: Double -> StdGen -> VU.Vector Int -> VU.Vector Int
keepFraction frac g xs =
    let k = max 1 (round (frac * fromIntegral (VU.length xs)))
        keyed = zip (take (VU.length xs) (randoms g :: [Double])) (VU.toList xs)
        chosen = map snd (take k (M.toAscList (M.fromList keyed)))
     in VU.fromList (M.keys (M.fromList [(x, ()) | x <- chosen]))