packages feed

dataframe-3.7.0.0: tests/Learn/Ensembles.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

module Learn.Ensembles (tests) where

import qualified DataFrame as D
import qualified DataFrame.Functions as F
import DataFrame.Internal.Column (TypedColumn (..), toVector)
import qualified DataFrame.Internal.Column as DI
import DataFrame.Internal.Expression (Expr)
import DataFrame.Internal.Interpreter (interpret)

import DataFrame.Boosting
import DataFrame.DBSCAN
import DataFrame.GMM
import DataFrame.LinearModel
import DataFrame.Metrics (r2)
import DataFrame.ModelSelection

import Data.Maybe (isJust, isNothing, listToMaybe)
import qualified Data.Vector.Unboxed as VU
import Test.HUnit

interpD :: D.DataFrame -> Expr Double -> [Double]
interpD df e = case interpret @Double df e of
    Right (TColumn c) -> either (const []) VU.toList (toVector @Double @VU.Vector c)
    Left err -> error (show err)

interpI :: D.DataFrame -> Expr Int -> [Int]
interpI df e = case interpret @Int df e of
    Right (TColumn c) -> either (const []) VU.toList (toVector @Int @VU.Vector c)
    Left err -> error (show err)

clsDF :: D.DataFrame
clsDF =
    D.fromColumns
        [ ("x", DI.fromList ([-3, -2, -1, -0.5, 0.5, 1, 2, 3] :: [Double]))
        , ("label", DI.fromList ([0, 0, 0, 0, 1, 1, 1, 1] :: [Int]))
        ]

blobs :: D.DataFrame
blobs =
    D.fromColumns
        [ ("a", DI.fromList ([0, 0.2, -0.1, 0.1, 8, 8.1, 7.9, 8.2] :: [Double]))
        , ("b", DI.fromList ([0, -0.1, 0.2, 0.0, 5, 5.2, 4.9, 5.1] :: [Double]))
        ]

testGBMRegression :: Test
testGBMRegression = TestCase $ do
    let df =
            D.fromColumns
                [ ("x", DI.fromList ([1 .. 12] :: [Double]))
                ,
                    ( "y"
                    , DI.fromList
                        ( [sin (fromIntegral i) + fromIntegral i * 0.3 | i <- [1 .. 12 :: Int]] ::
                            [Double]
                        )
                    )
                ]
        gb =
            fit
                defaultGBConfig{gbNEstimators = 60, gbMaxDepth = 2}
                (F.col @Double "y")
                df
        preds = interpD df (predict gb)
        truth = interpD df (F.col @Double "y")
        err = sum (zipWith (\p t -> (p - t) ^ (2 :: Int)) preds truth) / 12
    assertBool "GBM fits training data well" (err < 0.05)
    assertBool "trainScore recorded" (VU.length (gbTrainScore gb) == 60)

testGBMStaged :: Test
testGBMStaged = TestCase $ do
    let df =
            D.fromColumns
                [ ("x", DI.fromList ([1 .. 8] :: [Double]))
                , ("y", DI.fromList ([1 .. 8] :: [Double]))
                ]
        gb = fit defaultGBConfig{gbNEstimators = 10} (F.col @Double "y") df
    assertBool "stage 0 valid" (isJust (gbExprAtStage 0 gb))
    assertBool "out of range stage rejected" (isNothing (gbExprAtStage 11 gb))

testAdaBoost :: Test
testAdaBoost = TestCase $ do
    let m = fit defaultAdaBoostConfig (F.col @Int "label") clsDF
        preds = interpI clsDF (predict m)
    assertEqual "AdaBoost separates" [0, 0, 0, 0, 1, 1, 1, 1] preds

testGMM :: Test
testGMM = TestCase $ do
    let m =
            fit
                defaultGMMConfig{gmmK = 2, gmmSeed = 1}
                [F.col @Double "a", F.col @Double "b"]
                blobs
        assigns = interpI blobs (predict m)
    assertBool "GMM converged" (gmmConverged m)
    assertBool
        "GMM splits the blobs"
        (listToMaybe assigns /= listToMaybe (reverse assigns))
    let m2 =
            fit
                defaultGMMConfig{gmmK = 2, gmmSeed = 1}
                [F.col @Double "a", F.col @Double "b"]
                blobs
    assertEqual "GMM deterministic" (gmmMeans m) (gmmMeans m2)
    assertBool "BIC finite" (not (isNaN (gmmBIC m)) && not (isInfinite (gmmBIC m)))

testDBSCAN :: Test
testDBSCAN = TestCase $ do
    let df =
            D.fromColumns
                [ ("a", DI.fromList ([0, 0.1, 0.2, 5, 5.1, 5.2, 50] :: [Double]))
                , ("b", DI.fromList ([0, 0.1, 0.0, 5, 5.0, 5.1, 50] :: [Double]))
                ]
        m = fit (DBSCANConfig 1.0 2) [F.col @Double "a", F.col @Double "b"] df
    assertEqual "two clusters" 2 (dbNClusters m)
    assertEqual "last point is noise" (-1) (VU.last (dbLabels m))
    let surrogate =
            dbscanSurrogateExpr
                D.defaultTreeConfig
                [F.col @Double "a", F.col @Double "b"]
                m
                df
        preds = interpI df surrogate
    assertEqual
        "surrogate matches non-noise labels on cores"
        (take 6 (VU.toList (dbLabels m)))
        (take 6 preds)

testGridSearch :: Test
testGridSearch = TestCase $ do
    let df =
            D.fromColumns
                [ ("x", DI.fromList ([1 .. 40] :: [Double]))
                ,
                    ( "y"
                    , DI.fromList ([2 * fromIntegral i + 5 | i <- [1 .. 40 :: Int]] :: [Double])
                    )
                ]
        score alpha train test' =
            let mdl =
                    fit
                        (LinearConfig (Ridge alpha) defaultSolverConfig)
                        (F.col @Double "y")
                        (train :: D.DataFrame)
             in r2
                    (VU.fromList (interpD test' (predict mdl)))
                    (VU.fromList (interpD test' (F.col @Double "y")))
        res = gridSearch 4 7 [0.0, 1.0, 100.0] score df
    assertBool "best score high" (gsBestScore res > 0.99)
    assertEqual "all configs scored" 3 (length (gsAll res))

-- | Logistic boosting must recover a known conditional probability.
sigmoidCurveDF :: D.DataFrame
sigmoidCurveDF =
    D.fromColumns
        [ ("x", DI.fromList (concatMap (replicate group . fst) cells))
        , ("label", DI.fromList (concatMap snd cells))
        ]
  where
    group = 20 :: Int
    xs = [fromIntegral i / 10 - 1 | i <- [0 .. 20 :: Int]] :: [Double]
    cells = [(x, labelsAt x) | x <- xs]
    labelsAt x =
        let k = round (fromIntegral group * trueP x) :: Int
         in replicate k 1 ++ replicate (group - k) (0 :: Double)

trueP :: Double -> Double
trueP x = 1 / (1 + exp (negate (4 * x)))

testGBMCalibration :: Test
testGBMCalibration = TestCase $ do
    let m =
            fit
                defaultGBConfig
                    { gbLoss = LogisticDeviance
                    , gbNEstimators = 100
                    , gbLearningRate = 0.1
                    , gbMaxDepth = 3
                    }
                (F.col @Double "label")
                sigmoidCurveDF
        probs = interpD sigmoidCurveDF (gbProbaExpr m)
        truth = map trueP (interpD sigmoidCurveDF (F.col @Double "x"))
        err =
            sum (zipWith (\p t -> abs (p - t)) probs truth)
                / fromIntegral (length probs)
    assertBool
        ("logistic boosting recovers the conditional probability " ++ show err)
        (err < 0.03)

gbmData :: D.DataFrame
gbmData =
    D.fromColumns
        [ ("x", DI.fromList ([fromIntegral i / 4 | i <- [1 .. 40 :: Int]] :: [Double]))
        ,
            ( "z"
            , DI.fromList
                ([fromIntegral ((i * 7) `mod` 11) | i <- [1 .. 40 :: Int]] :: [Double])
            )
        ,
            ( "y"
            , DI.fromList
                ( [ sin (fromIntegral i / 4) + fromIntegral ((i * 7) `mod` 11) * 0.1
                  | i <- [1 .. 40 :: Int]
                  ] ::
                    [Double]
                )
            )
        ]

-- A constant base equal to the default initial score boosts the same trees.
testGBMBaseScore :: Test
testGBMBaseScore = TestCase $ do
    let cfg = defaultGBConfig{gbNEstimators = 20, gbMaxDepth = 2}
        ys = interpD gbmData (F.col @Double "y")
        mean = sum ys / fromIntegral (length ys)
        plain = fit cfg (F.col @Double "y") gbmData
        based = fit cfg{gbBaseScore = Just (F.lit mean)} (F.col @Double "y") gbmData
        gap =
            maximum
                ( zipWith
                    (\a b -> abs (a - b))
                    (interpD gbmData (predict plain))
                    (interpD gbmData (predict based))
                )
    assertBool
        ("base-score model matches the default, gap " ++ show gap)
        (gap < 1e-9)
    -- A per-row base is part of the prediction: shifting it shifts every score.
    let shifted = fit cfg{gbBaseScore = Just (F.col @Double "z")} (F.col @Double "y") gbmData
        noTrees =
            fit
                cfg{gbNEstimators = 0, gbBaseScore = Just (F.col @Double "z")}
                (F.col @Double "y")
                gbmData
    assertEqual
        "zero trees predict the base"
        (interpD gbmData (F.col @Double "z"))
        (interpD gbmData (predict noTrees))
    assertBool
        "base model still fits"
        (length (interpD gbmData (predict shifted)) == 40)

-- Sampling at 1 is inert; below 1 it changes the fit, fixed by the seed.
testGBMSubsample :: Test
testGBMSubsample = TestCase $ do
    let cfg = defaultGBConfig{gbNEstimators = 20, gbMaxDepth = 2}
        scores :: GBConfig -> [Double]
        scores c = interpD gbmData (predict (fit c (F.col @Double "y") gbmData))
    assertEqual
        "subsample 1 / colsample 1 reproduce the default"
        (scores cfg)
        (scores cfg{gbSubsample = 1, gbColsample = 1})
    let half = cfg{gbSubsample = 0.5, gbColsample = 0.5, gbSeed = 3}
    assertEqual "deterministic in the seed" (scores half) (scores half)
    assertBool "sampling changes the fit" (scores half /= scores cfg)
    assertBool
        "a different seed changes the fit"
        (scores half /= scores half{gbSeed = 4})

tests :: [Test]
tests =
    [ testGBMRegression
    , testGBMBaseScore
    , testGBMSubsample
    , testGBMStaged
    , testGBMCalibration
    , testAdaBoost
    , testGMM
    , testDBSCAN
    , testGridSearch
    ]