packages feed

dataframe-2.3.0.0: tests/Learn/SklearnParity.hs

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

{- | Parity tests against scikit-learn on clean Kaggle-style datasets. The
reference values in @data/ml/golden.json@ are produced by
@scripts/gen_sklearn_golden.py@. Closed-form models (OLS, ridge, PCA) are held to
tight coefficient parity; iterative models to an accuracy/inertia floor.
-}
module Learn.SklearnParity (tests) where

import Data.Aeson (Value (..), decodeFileStrict')
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as VU
import DataFrame.Model (fit, predict)
import Test.HUnit

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

import DataFrame.Boosting (
    GBConfig (..),
    GBLoss (..),
    defaultGBConfig,
    gbProbaExpr,
 )
import DataFrame.KMeans
import DataFrame.LinearModel
import DataFrame.LinearSolver (defaultSolverConfig)
import DataFrame.PCA
import DataFrame.SVM

goldenPath :: FilePath
goldenPath = "data/ml/golden.json"

loadGolden :: IO Value
loadGolden =
    fromMaybe (error "missing data/ml/golden.json") <$> decodeFileStrict' goldenPath

(.!) :: Value -> T.Text -> Value
(.!) (Object o) k = fromMaybe Null (KM.lookup (K.fromText k) o)
(.!) _ _ = Null

asNum :: Value -> Double
asNum (Number s) = realToFrac s
asNum _ = error "asNum: not a number"

asNums :: Value -> [Double]
asNums (Array a) = map asNum (V.toList a)
asNums _ = error "asNums: not an array"

asMatrix :: Value -> [[Double]]
asMatrix (Array a) = map asNums (V.toList a)
asMatrix _ = error "asMatrix: not a matrix"

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)

colDoubles :: D.DataFrame -> T.Text -> [Double]
colDoubles df name = interpD df (F.col @Double name)

closeAll :: Double -> [Double] -> [Double] -> Bool
closeAll tol a b =
    length a == length b && and (zipWith (\x y -> abs (x - y) <= tol) a b)

accuracyOf :: [Double] -> [Double] -> Double
accuracyOf preds truth =
    fromIntegral (length (filter id (zipWith (==) preds truth)))
        / fromIntegral (max 1 (length truth))

testOLSParity :: Test
testOLSParity = TestCase $ do
    g <- loadGolden
    df <- D.readCsv "data/ml/regression.csv"
    let m = fit defaultLinearConfig (F.col @Double "target") df
        goldCoef = asNums (g .! "ols" .! "coef")
        goldB = asNum (g .! "ols" .! "intercept")
    assertBool
        "OLS coefficients match sklearn"
        (closeAll 1e-4 (VU.toList (regCoef m)) goldCoef)
    assertBool "OLS intercept matches sklearn" (abs (regIntercept m - goldB) < 1e-4)

testRidgeParity :: Test
testRidgeParity = TestCase $ do
    g <- loadGolden
    df <- D.readCsv "data/ml/regression.csv"
    let m =
            fit (LinearConfig (Ridge 1.0) defaultSolverConfig) (F.col @Double "target") df
        goldCoef = asNums (g .! "ridge" .! "coef")
    assertBool
        "Ridge coefficients match sklearn"
        (closeAll 1e-2 (VU.toList (regCoef m)) goldCoef)

testPCAParity :: Test
testPCAParity = TestCase $ do
    g <- loadGolden
    df <- D.readCsv "data/ml/iris.csv"
    let feats =
            map
                (F.col @Double)
                ["sepal_length", "sepal_width", "petal_length", "petal_width"]
        m = fit (PCAConfig (NComp 2) False) feats df
        goldEvr = asNums (g .! "pca" .! "evr")
        goldComp = asMatrix (g .! "pca" .! "components_abs")
        ourComp = map (map abs . VU.toList) (V.toList (pcaComponents m))
    assertBool
        "PCA explained variance ratio matches sklearn"
        (closeAll 1e-4 (VU.toList (pcaExplainedVarianceRatio m)) goldEvr)
    assertBool
        "PCA |components| match sklearn"
        (and (zipWith (closeAll 1e-3) ourComp goldComp))

testLogisticIrisParity :: Test
testLogisticIrisParity = TestCase $ do
    g <- loadGolden
    df <- D.readCsv "data/ml/iris.csv"
    let m = fit defaultLogisticConfig (F.col @Double "species") df
        preds = interpD df (predict m)
        truth = colDoubles df "species"
        acc = accuracyOf preds truth
        gold = asNum (g .! "logistic_iris" .! "accuracy")
    assertBool
        ("logistic iris accuracy within 0.06 of sklearn " ++ show (acc, gold))
        (acc >= gold - 0.06)

testSVCParity :: Test
testSVCParity = TestCase $ do
    g <- loadGolden
    df <- D.readCsv "data/ml/iris_binary.csv"
    let m = fit defaultSVCConfig (F.col @Double "label") df
        preds = interpD df (predict m)
        truth = colDoubles df "label"
        acc = accuracyOf preds truth
        gold = asNum (g .! "linear_svc" .! "accuracy")
    assertBool
        ("linear SVC accuracy within 0.06 of sklearn " ++ show (acc, gold))
        (acc >= gold - 0.06)

testGBMParity :: Test
testGBMParity = TestCase $ do
    g <- loadGolden
    df <- D.readCsv "data/ml/iris_binary.csv"
    let m =
            fit
                defaultGBConfig{gbLoss = LogisticDeviance, gbNEstimators = 100}
                (F.col @Double "label")
                df
        probs = interpD df (gbProbaExpr m)
        preds = map (\p -> if p > 0.5 then 1 else 0) probs
        truth = colDoubles df "label"
        acc = accuracyOf preds truth
        gold = asNum (g .! "gbm" .! "accuracy")
    assertBool
        ("GBM accuracy within 0.1 of sklearn " ++ show (acc, gold))
        (acc >= gold - 0.1)

testKMeansParity :: Test
testKMeansParity = TestCase $ do
    g <- loadGolden
    df <- D.readCsv "data/ml/blobs.csv"
    let m =
            fit
                defaultKMeansConfig{kmK = 3, kmNInit = 10, kmSeed = 0}
                [F.col @Double "x", F.col @Double "y"]
                df
        gold = asNum (g .! "kmeans" .! "inertia")
    assertBool
        ("k-means inertia within 10% of sklearn " ++ show (kmInertia m, gold))
        (abs (kmInertia m - gold) / gold < 0.1)

tests :: [Test]
tests =
    [ testOLSParity
    , testRidgeParity
    , testPCAParity
    , testLogisticIrisParity
    , testSVCParity
    , testGBMParity
    , testKMeansParity
    ]