packages feed

dataframe-learn-2.0.0.0: src/DataFrame/Boosting/AdaBoost.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

{- | AdaBoost (SAMME) over short, sample-weighted classification trees. The
weighted-Gini stump fitter here is self-contained (it reuses the CART feature
encoding but not the unweighted CART recursion), so the existing decision-tree
path is untouched. 'predict' is the arg-max of weighted votes.
-}
module DataFrame.Boosting.AdaBoost (
    module DataFrame.Model,
    AdaBoostConfig (..),
    defaultAdaBoostConfig,
    AdaBoostModel (..),
) where

import Data.List (sort)
import Data.Maybe (fromMaybe, maybeToList)
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as VU

import DataFrame.DecisionTree.Cart (
    CartFeature (..),
    cartFeatures,
    sortIndicesByValue,
 )
import DataFrame.DecisionTree.Fit (treeToExpr)
import DataFrame.DecisionTree.Types (Tree (..))
import DataFrame.Featurize.Internal (argMaxExpr, targetValues)
import qualified DataFrame.Functions as F
import DataFrame.Internal.Column (Columnable, TypedColumn (..), toVector)
import DataFrame.Internal.DataFrame (DataFrame)
import DataFrame.Internal.Expression (Expr (..))
import DataFrame.Internal.Interpreter (interpret)
import DataFrame.Model
import DataFrame.Operators ((.*.), (.+.), (.==.))

data AdaBoostConfig = AdaBoostConfig
    { abNEstimators :: !Int
    , abMaxDepth :: !Int
    }
    deriving (Eq, Show)

defaultAdaBoostConfig :: AdaBoostConfig
defaultAdaBoostConfig = AdaBoostConfig{abNEstimators = 50, abMaxDepth = 1}

-- | A fitted SAMME model: per-stage weights and stumps over the class set.
data AdaBoostModel a = AdaBoostModel
    { abAlphas :: !(VU.Vector Double)
    , abStumps :: !(V.Vector (Tree a))
    , abClasses :: !(V.Vector a)
    }
    deriving (Show)

instance (Columnable a, Ord a) => Fit AdaBoostConfig (Expr a) where
    type ModelOf AdaBoostConfig (Expr a) = (AdaBoostModel a)
    fit = fitAdaBoost

instance (Columnable a, Ord a) => Predict (AdaBoostModel a) where
    type Prediction (AdaBoostModel a) = Expr a
    predict = adaBoostExpr

-- | Fit an AdaBoost-SAMME classifier.
fitAdaBoost ::
    (Columnable a, Ord a) =>
    AdaBoostConfig -> Expr a -> DataFrame -> AdaBoostModel a
fitAdaBoost cfg target@(Col name) df =
    AdaBoostModel
        (VU.fromList (reverse alphas))
        (V.fromList (reverse stumps))
        classesV
  where
    feats = V.fromList (cartFeatures name df)
    ys = targetValues target df
    n = V.length ys
    classes = sort (foldr dedup [] (V.toList ys))
    dedup x acc = if x `elem` acc then acc else x : acc
    classesV = V.fromList classes
    kClasses = length classes
    codes = VU.generate n (\i -> classIndex (ys V.! i))
    classIndex v = length (takeWhile (< v) classes)
    (alphas, stumps) = boost 0 (VU.replicate n (1 / fromIntegral (max 1 n))) [] []
    boost !m w as ts
        | m >= abNEstimators cfg = (as, ts)
        | otherwise =
            let stump = fitWeightedTree (abMaxDepth cfg) feats classesV codes kClasses w
                pred = predictCodes df classesV stump
                wrong :: VU.Vector Int
                wrong = VU.generate n (\i -> if pred VU.! i /= codes VU.! i then 1 else 0)
                err = clamp (VU.sum (VU.zipWith (*) w (VU.map fromIntegral wrong)) / VU.sum w)
                alpha = log ((1 - err) / err) + log (fromIntegral (max 1 (kClasses - 1)))
                w' = normalize (VU.zipWith (\wi e -> wi * exp (alpha * fromIntegral e)) w wrong)
             in if err <= 0 || err >= 1 - 1 / fromIntegral kClasses
                    then (alpha : as, stump : ts)
                    else boost (m + 1) w' (alpha : as) (stump : ts)
    clamp e = max 1e-10 (min (1 - 1e-10) e)
    normalize v = let s = VU.sum v in if s == 0 then v else VU.map (/ s) v
fitAdaBoost _ expr _ =
    error ("fitAdaBoost: target must be a column, got " ++ show expr)

predictCodes ::
    forall a.
    (Columnable a, Ord a) =>
    DataFrame -> V.Vector a -> Tree a -> VU.Vector Int
predictCodes df classesV stump =
    VU.fromList (map toCode preds)
  where
    preds :: [a]
    preds = case interpret df (treeToExpr stump) of
        Right (TColumn c) -> either (const []) V.toList (toVector @a @V.Vector c)
        Left e -> error (show e)
    toCode v = fromMaybe 0 (V.findIndex (== v) classesV)

-- | A depth-bounded weighted classification tree (weighted Gini splits).
fitWeightedTree ::
    (Columnable a) =>
    Int ->
    V.Vector CartFeature ->
    V.Vector a ->
    VU.Vector Int ->
    Int ->
    VU.Vector Double ->
    Tree a
fitWeightedTree maxDepth feats classesV codes kClasses weights =
    go 0 (VU.enumFromN 0 (VU.length codes))
  where
    go depth idxs
        | depth >= maxDepth || VU.length idxs < 2 || isPure idxs =
            Leaf (classesV V.! majority idxs)
        | otherwise = case bestSplit idxs of
            Nothing -> Leaf (classesV V.! majority idxs)
            Just (fj, thr) ->
                let vals = cfValues (feats V.! fj)
                    (l, r) = VU.partition (\i -> vals VU.! i <= thr) idxs
                 in if VU.null l || VU.null r
                        then Leaf (classesV V.! majority idxs)
                        else
                            Branch
                                (cfPred (feats V.! fj) thr)
                                (go (depth + 1) l)
                                (go (depth + 1) r)
    classWeights idxs =
        VU.accumulate
            (+)
            (VU.replicate kClasses 0)
            (VU.map (\i -> (codes VU.! i, weights VU.! i)) idxs)
    majority idxs = VU.maxIndex (classWeights idxs)
    isPure idxs = VU.length (VU.filter (> 0) (classWeights idxs)) <= 1
    bestSplit idxs =
        let cands =
                [ (score, fj, thr)
                | fj <- [0 .. V.length feats - 1]
                , (thr, score) <- featureSplits idxs fj
                ]
         in case cands of
                [] -> Nothing
                _ -> let (_, fj, thr) = minimum3 cands in Just (fj, thr)
    featureSplits idxs fj =
        let vals = cfValues (feats V.! fj)
            member =
                VU.replicate (VU.length codes) False
                    VU.// [(i, True) | i <- VU.toList idxs]
            sorted = VU.filter (member VU.!) (sortIndicesByValue vals)
         in sweep vals sorted (classWeights idxs)
    sweep vals sorted totW = go0 0 (VU.replicate kClasses 0) Nothing
      where
        m = VU.length sorted
        totWsum = VU.sum totW
        go0 !k leftW best
            | k >= m - 1 = maybeToList best
            | otherwise =
                let i = sorted VU.! k
                    leftW' = leftW VU.// [(codes VU.! i, leftW VU.! (codes VU.! i) + weights VU.! i)]
                    vCur = vals VU.! i
                    vNext = vals VU.! (sorted VU.! (k + 1))
                    wl = VU.sum leftW'
                    wr = totWsum - wl
                    score = wl * gini leftW' + wr * gini (VU.zipWith (-) totW leftW')
                    valid = vCur /= vNext && wl > 0 && wr > 0
                    best' =
                        if valid && maybe True (\(_, s) -> score < s) best
                            then Just ((vCur + vNext) / 2, score)
                            else best
                 in go0 (k + 1) leftW' best'

gini :: VU.Vector Double -> Double
gini cw =
    let total = VU.sum cw
     in if total == 0
            then 0
            else 1 - VU.sum (VU.map (\c -> (c / total) ^ (2 :: Int)) cw)

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

{- | Compile to an arg-max-of-weighted-votes expression over the class set:
@argmax_c Σ_m αₘ·[stumpₘ = c]@.
-}
adaBoostExpr :: (Columnable a, Ord a) => AdaBoostModel a -> Expr a
adaBoostExpr m = argMaxExpr (zip classes scores)
  where
    classes = V.toList (abClasses m)
    stumpExprs = map treeToExpr (V.toList (abStumps m))
    alphas = VU.toList (abAlphas m)
    scores =
        [ foldr (.+.) (F.lit 0) (zipWith (vote c) alphas stumpExprs)
        | c <- classes
        ]
    vote c a se = F.lit a .*. indicator (se .==. F.lit c)
    indicator cond = F.ifThenElse cond (F.lit 1.0) (F.lit 0.0)