dataframe-learn-2.0.0.0: src/DataFrame/Boosting/GBM.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{- | Gradient boosting of regression trees (Friedman). Trees are fitted to the
negative gradient of the loss each round and accumulated with a shrinkage
factor; squared error gives regression, logistic deviance gives binary
classification. 'predict' is the additive score; 'gbProbaExpr' /
'gbDecisionExpr' give the classification probability / decision.
-}
module DataFrame.Boosting.GBM (
module DataFrame.Model,
GBLoss (..),
GBConfig (..),
defaultGBConfig,
GBModel (..),
gbExprAtStage,
gbProbaExpr,
gbDecisionExpr,
) where
import Data.Either (fromRight)
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.DecisionTree.Cart (cartFeatures)
import DataFrame.DecisionTree.Fit (treeToExpr)
import DataFrame.DecisionTree.Regression (RegTreeConfig (..), fitRegTreeOn)
import DataFrame.DecisionTree.Types (Tree)
import DataFrame.Featurize.Internal (targetDoubles)
import qualified DataFrame.Functions as F
import DataFrame.Internal.Column (TypedColumn (..), toVector)
import DataFrame.Internal.DataFrame (DataFrame)
import DataFrame.Internal.Expression (Expr (..), getColumns)
import DataFrame.Internal.Interpreter (interpret)
import DataFrame.Model
import DataFrame.Operators ((.*.), (.+.), (.>.))
-- | The boosting loss.
data GBLoss = SquaredError | LogisticDeviance
deriving (Eq, Show)
data GBConfig = GBConfig
{ gbLoss :: !GBLoss
, gbNEstimators :: !Int
, gbLearningRate :: !Double
, gbMaxDepth :: !Int
, gbSeed :: !Int
}
deriving (Eq, Show)
defaultGBConfig :: GBConfig
defaultGBConfig =
GBConfig
{ gbLoss = SquaredError
, gbNEstimators = 100
, gbLearningRate = 0.1
, gbMaxDepth = 3
, gbSeed = 0
}
{- | A fitted gradient-boosting model. 'gbInit' is the constant initial score
(mean, or log-odds for classification); 'gbTrees' are the staged regression
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)
}
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 @target@ from the other columns.
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
where
feats = V.fromList (cartFeatures name df)
y = targetDoubles target df
n = VU.length y
lr = gbLearningRate cfg
rtCfg =
RegTreeConfig
{ rtMaxDepth = gbMaxDepth cfg
, rtMinSamplesSplit = 2
, rtMinLeafSize = 1
, rtMinImpurityDecrease = 0.0
}
f0 = case gbLoss cfg of
SquaredError -> VU.sum y / fromIntegral (max 1 n)
LogisticDeviance ->
let p = clamp01 (VU.sum y / fromIntegral (max 1 n))
in log (p / (1 - p))
(trees, scores, usage) = boost 0 (VU.replicate n f0) [] [] M.empty
boost !m fScores ts ss usageAcc
| m >= gbNEstimators cfg = (ts, ss, usageAcc)
| otherwise =
let grad = negGradient (gbLoss cfg) y fScores
tree = fitRegTreeOn rtCfg feats grad Nothing
pred = predictTree df tree
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 _ =
error ("fitGBM: target must be a column, got " ++ show expr)
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)
predictTree :: DataFrame -> Tree Double -> VU.Vector Double
predictTree df t = case interpret @Double df (treeToExpr t) of
Right (TColumn c) -> fromRight VU.empty (toVector @Double @VU.Vector c)
Left e -> error (show e)
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) (F.lit (gbInit m)) (take k (V.toList (gbTrees m)))
where
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