packages feed

dataframe-3.7.0.0: tests/Learn/Metamorphic.hs

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

-- | Metamorphic and invariance tests: models must obey transformation laws.
module Learn.Metamorphic (tests) where

import qualified Data.Text as T
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as VU

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, getColumns)
import DataFrame.Internal.Interpreter (interpret)

import DataFrame.DecisionTree.Regression (defaultRegTreeConfig)
import DataFrame.LinearModel
import DataFrame.Metrics
import DataFrame.Operations.Merge ()

-- Semigroup DataFrame (row concatenation)
import DataFrame.Transform

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)

close :: Double -> Double -> Double -> Bool
close tol a b = abs (a - b) <= tol

closeList :: Double -> [Double] -> [Double] -> Bool
closeList tol as bs = length as == length bs && and (zipWith (close tol) as bs)

-- Build a regression frame from explicit feature/target lists.
mkRegDF :: [(T.Text, [Double])] -> [Double] -> D.DataFrame
mkRegDF feats ys =
    D.fromColumns
        ([(n, DI.fromList vs) | (n, vs) <- feats] ++ [("y", DI.fromList ys)])

-- Base design: a noiseless linear target so OLS recovers a unique solution.
baseX1, baseX2, baseY :: [Double]
baseX1 = [1, 2, 3, 4, 5, 6, 7, 8]
baseX2 = [2, 1, 4, 3, 6, 5, 8, 7]
baseY = [3 + 2 * a - 0.5 * b | (a, b) <- zip baseX1 baseX2]

baseDF :: D.DataFrame
baseDF = mkRegDF [("x1", baseX1), ("x2", baseX2)] baseY

fitBase :: D.DataFrame -> LinearRegressor
fitBase = fit defaultLinearConfig (F.col @Double "y")

-- | Duplicating every row leaves the OLS coefficients unchanged.
testDuplicateRows :: Test
testDuplicateRows = TestCase $ do
    let dupDF =
            mkRegDF
                [("x1", baseX1 ++ baseX1), ("x2", baseX2 ++ baseX2)]
                (baseY ++ baseY)
        m0 = fitBase baseDF
        m1 = fitBase dupDF
    assertBool
        "duplicate rows: coefficients unchanged"
        (closeList 1e-9 (VU.toList (regCoef m0)) (VU.toList (regCoef m1)))
    assertBool
        "duplicate rows: intercept unchanged"
        (close 1e-9 (regIntercept m0) (regIntercept m1))

-- | OLS is invariant to row order.
testPermuteRows :: Test
testPermuteRows = TestCase $ do
    let permDF =
            mkRegDF
                [("x1", reverse baseX1), ("x2", reverse baseX2)]
                (reverse baseY)
        m0 = fitBase baseDF
        m1 = fitBase permDF
    assertBool
        "permute rows: coefficients unchanged"
        (closeList 1e-9 (VU.toList (regCoef m0)) (VU.toList (regCoef m1)))
    assertBool
        "permute rows: intercept unchanged"
        (close 1e-9 (regIntercept m0) (regIntercept m1))

-- | Scaling a feature by k scales its coefficient by 1/k and keeps predictions.
testScaleFeature :: Test
testScaleFeature = TestCase $ do
    let k = 10.0
        scaledDF = mkRegDF [("x1", map (* k) baseX1), ("x2", baseX2)] baseY
        m0 = fitBase baseDF
        m1 = fitBase scaledDF
        coef0 = VU.toList (regCoef m0)
        coef1 = VU.toList (regCoef m1)
    assertBool
        "scale feature: x1 coefficient scaled by 1/k"
        (close 1e-7 (VU.head (regCoef m0) / k) (VU.head (regCoef m1)))
    assertBool
        "scale feature: x2 coefficient unchanged"
        (close 1e-7 (coef0 !! 1) (coef1 !! 1))
    assertBool
        "scale feature: intercept unchanged"
        (close 1e-7 (regIntercept m0) (regIntercept m1))
    -- Predictions on each model's own (consistently transformed) frame agree.
    let p0 = interpD baseDF (predict m0)
        p1 = interpD scaledDF (predict m1)
    assertBool "scale feature: predictions invariant" (closeList 1e-6 p0 p1)

-- | Renaming feature columns only relabels coefficients and feature names.
testRenameColumns :: Test
testRenameColumns = TestCase $ do
    let renamedDF = mkRegDF [("feat_a", baseX1), ("feat_b", baseX2)] baseY
        m0 = fitBase baseDF
        m1 = fitBase renamedDF
    assertBool
        "rename: coefficients unchanged"
        (closeList 1e-9 (VU.toList (regCoef m0)) (VU.toList (regCoef m1)))
    assertBool
        "rename: intercept unchanged"
        (close 1e-9 (regIntercept m0) (regIntercept m1))
    assertEqual
        "rename: fitted feature names follow the rename"
        ["feat_a", "feat_b"]
        (V.toList (regFeatureNames m1))
    -- predict on the renamed frame yields the same numbers as on the original.
    let p0 = interpD baseDF (predict m0)
        p1 = interpD renamedDF (predict m1)
    assertBool "rename: predictions unchanged" (closeList 1e-9 p0 p1)

-- | A constant feature must not change predictions for the real features.
testConstantFeatureIgnored :: Test
testConstantFeatureIgnored = TestCase $ do
    let withConst =
            mkRegDF
                [ ("x1", baseX1)
                , ("x2", baseX2)
                , ("c", replicate (length baseX1) 7.0)
                ]
                baseY
        m0 = fitBase baseDF
        m1 = fitBase withConst
        p0 = interpD baseDF (predict m0)
        p1 = interpD withConst (predict m1)
    assertBool
        "constant feature: predictions match the no-constant model"
        (closeList 1e-6 p0 p1)
    -- And the real-feature coefficients are unchanged.
    let coef1 = VU.toList (regCoef m1)
    assertBool
        "constant feature: x1 coefficient unchanged"
        (close 1e-6 (VU.head (regCoef m0)) (VU.head (regCoef m1)))
    assertBool
        "constant feature: x2 coefficient unchanged"
        (close 1e-6 (VU.toList (regCoef m0) !! 1) (coef1 !! 1))

-- | Splitting a frame and rejoining it with <> must give an identical fit.
testSplitConcat :: Test
testSplitConcat = TestCase $ do
    let n = length baseX1
        h = n `div` 2
        half lo hi =
            mkRegDF
                [ ("x1", slice lo hi baseX1)
                , ("x2", slice lo hi baseX2)
                ]
                (slice lo hi baseY)
        slice lo hi = take (hi - lo) . drop lo
        rebuiltDF = half 0 h <> half h n
        m0 = fitBase baseDF
        m1 = fitBase rebuiltDF
    -- the concatenated frame must have exactly the original rows back
    assertEqual "split/concat: row count preserved" n (fst (D.dimensions rebuiltDF))
    assertBool
        "split/concat: coefficients unchanged"
        (closeList 1e-9 (VU.toList (regCoef m0)) (VU.toList (regCoef m1)))
    assertBool
        "split/concat: intercept unchanged"
        (close 1e-9 (regIntercept m0) (regIntercept m1))

-- | The order of feature columns must not change predictions.
testColumnOrderParity :: Test
testColumnOrderParity = TestCase $ do
    let swappedDF = mkRegDF [("x2", baseX2), ("x1", baseX1)] baseY
        m0 = fitBase baseDF
        m1 = fitBase swappedDF
        -- evaluate both fitted models on the SAME original frame.
        p0 = interpD baseDF (predict m0)
        p1 = interpD baseDF (predict m1)
    assertBool
        "column order: predictions on the same frame agree"
        (closeList 1e-7 p0 p1)

testStandardScalerLaw :: Test
testStandardScalerLaw = TestCase $ do
    let cols = ["x1", "x2"]
        scaler = standardScaler cols baseDF
        scaledDF = applyTransform (scalerTransform scaler) baseDF
        moments xs =
            let n = fromIntegral (length xs)
                mu = sum xs / n
                var = sum [(x - mu) ^ (2 :: Int) | x <- xs] / n
             in (mu, sqrt var)
    mapM_
        ( \c -> do
            let (mu, sd) = moments (interpD scaledDF (F.col @Double c))
            assertBool ("scaler: " ++ T.unpack c ++ " mean ~ 0") (close 1e-9 mu 0)
            assertBool ("scaler: " ++ T.unpack c ++ " std ~ 1") (close 1e-9 sd 1)
        )
        cols

testScalerStatsMatchData :: Test
testScalerStatsMatchData = TestCase $ do
    let scaler = standardScaler ["x1", "x2"] baseDF
        moments xs =
            let n = fromIntegral (length xs)
                mu = sum xs / n
             in (mu, sqrt (sum [(x - mu) ^ (2 :: Int) | x <- xs] / n))
        (mu1, sd1) = moments baseX1
        (mu2, sd2) = moments baseX2
    assertBool
        "scaler means match data"
        (closeList 1e-9 (VU.toList (smMeans scaler)) [mu1, mu2])
    assertBool
        "scaler stds match data"
        (closeList 1e-9 (VU.toList (smStds scaler)) [sd1, sd2])

testAccuracyLaw :: Test
testAccuracyLaw = TestCase $ do
    let truth = VU.fromList [0, 1, 2, 1, 0, 2]
        perfect = truth
        oneWrong = VU.fromList [0, 1, 2, 1, 0, 0]
    assertBool "accuracy: perfect = 1" (close 1e-12 (accuracy perfect truth) 1.0)
    assertBool
        "accuracy: one miss out of 6 = 5/6"
        (close 1e-12 (accuracy oneWrong truth) (5 / 6))
    assertBool
        "accuracy in [0,1]"
        (let a = accuracy oneWrong truth in a >= 0 && a <= 1)

testAccuracyPermInvariant :: Test
testAccuracyPermInvariant = TestCase $ do
    let preds = VU.fromList [0, 0, 1, 1, 2, 2, 1, 0]
        truth = VU.fromList [0, 0, 1, 2, 2, 2, 1, 0]
        a0 = accuracy preds truth
        a1 = accuracy (VU.reverse preds) (VU.reverse truth)
    assertBool "accuracy: permutation invariant" (close 1e-12 a0 a1)
    -- Sanity: the metric is non-trivial here, so invariance is meaningful.
    assertBool "accuracy: non-degenerate baseline" (a0 > 0 && a0 < 1)

testR2Anchors :: Test
testR2Anchors = TestCase $ do
    let truth = VU.fromList [1, 3, 2, 8, 5, 4]
        meanT = VU.sum truth / fromIntegral (VU.length truth)
        meanPred = VU.replicate (VU.length truth) meanT
    assertBool "r2: perfect fit = 1" (close 1e-12 (r2 truth truth) 1.0)
    assertBool "r2: predicting the mean = 0" (close 1e-12 (r2 meanPred truth) 0.0)
    -- A strictly-worse-than-mean prediction gives r2 < 0 (not clamped).
    let worse = VU.map (+ 100) truth
    assertBool "r2: far-off prediction is negative" (r2 worse truth < 0)

-- | A noiseless linear fit has r2 equal to 1 and rmse equal to 0.
testR2OfFittedModel :: Test
testR2OfFittedModel = TestCase $ do
    let m = fitBase baseDF
        score = evaluate r2 (predict m) (F.col @Double "y") baseDF
        err = evaluate rmse (predict m) (F.col @Double "y") baseDF
    assertBool "r2 of exact linear fit ~ 1" (close 1e-9 score 1.0)
    assertBool "rmse of exact linear fit ~ 0" (err < 1e-6)

testTargetNeverAFeature :: Test
testTargetNeverAFeature = TestCase $ do
    let n = 40 :: Int
        leakDF =
            D.fromColumns
                [ ("y", DI.fromList [if even i then 1.0 else 0.0 :: Double | i <- [0 .. n - 1]])
                , ("a", DI.fromList [fromIntegral (i `div` 4) :: Double | i <- [0 .. n - 1]])
                , ("b", DI.fromList [fromIntegral (i `mod` 3) :: Double | i <- [0 .. n - 1]])
                ]
        assertNoTarget name expr =
            assertBool
                ( name
                    ++ ": target 'y' must not appear in the fitted Expr (got "
                    ++ show expr
                    ++ ")"
                )
                ("y" `notElem` getColumns expr)
    assertNoTarget
        "linear"
        (predict (fit defaultLinearConfig (F.col @Double "y") leakDF))
    assertNoTarget
        "regression tree"
        (predict (fit defaultRegTreeConfig (F.col @Double "y") leakDF))
    assertNoTarget
        "classification tree"
        (predict (fit D.defaultTreeConfig (F.col @Double "y") leakDF))

tests :: [Test]
tests =
    [ testTargetNeverAFeature
    , testDuplicateRows
    , testPermuteRows
    , testScaleFeature
    , testRenameColumns
    , testConstantFeatureIgnored
    , testSplitConcat
    , testColumnOrderParity
    , testStandardScalerLaw
    , testScalerStatsMatchData
    , testAccuracyLaw
    , testAccuracyPermInvariant
    , testR2Anchors
    , testR2OfFittedModel
    ]