packages feed

dataframe-2.3.0.0: tests/Operations/Window.hs

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

module Operations.Window where

import qualified Data.Text as T
import qualified DataFrame as D
import qualified DataFrame.Functions as F
import qualified DataFrame.Internal.Column as DI
import qualified DataFrame.Internal.DataFrame as DI

import Data.Function ((&))
import DataFrame.Operators
import Test.HUnit

-- Similar to example discussed in https://www.sumsar.net/blog/pandas-feels-clunky-when-coming-from-r/
purchases :: D.DataFrame
purchases =
    D.fromNamedColumns
        [
            ( "country"
            , DI.fromList
                (["US", "US", "US", "UK", "UK", "UK", "France", "France", "France"] :: [T.Text])
            )
        , ("amount", DI.fromList ([100, 200, 5000, 50, 60, 800, 30, 40, 35] :: [Double]))
        , ("discount", DI.fromList ([5, 10, 0, 2, 3, 0, 1, 2, 1] :: [Double]))
        ]

globalFilterTest :: Test
globalFilterTest =
    TestCase
        ( assertEqual
            "Global median filter removes global outliers"
            7
            ( purchases
                & D.filterWhere
                    (F.col @Double "amount" .<=. F.median (F.col @Double "amount") * 10)
                & D.nRows
            )
        )

overMedianFilterTest :: Test
overMedianFilterTest =
    TestCase
        ( assertEqual
            "Per-group median filter via over removes per-country outliers"
            expected
            actual
        )
  where
    actual =
        purchases
            & D.filterWhere
                ( F.col @Double "amount"
                    .<=. F.over ["country"] (F.median (F.col @Double "amount"))
                    * 10
                )
            & D.sortBy [D.Asc (F.col @T.Text "country"), D.Asc (F.col @Double "amount")]
    expected =
        D.fromNamedColumns
            [
                ( "country"
                , DI.fromList (["France", "France", "France", "UK", "UK", "US", "US"] :: [T.Text])
                )
            , ("amount", DI.fromList ([30, 35, 40, 50, 60, 100, 200] :: [Double]))
            , ("discount", DI.fromList ([1, 1, 2, 2, 3, 5, 10] :: [Double]))
            ]

globalVsOverDifferentResults :: Test
globalVsOverDifferentResults =
    TestCase
        ( assertBool
            "Global filter and per-group over filter should produce different DataFrames"
            (D.nRows globalResult /= D.nRows overResult)
        )
  where
    globalResult =
        purchases
            & D.filterWhere
                (F.col @Double "amount" .<=. F.median (F.col @Double "amount") * 3)
            & D.sortBy [D.Asc (F.col @T.Text "country"), D.Asc (F.col @Double "amount")]

    overResult =
        purchases
            & D.filterWhere
                ( F.col @Double "amount"
                    .<=. F.over ["country"] (F.median (F.col @Double "amount"))
                    * 3
                )
            & D.sortBy [D.Asc (F.col @T.Text "country"), D.Asc (F.col @Double "amount")]

overMeanDeriveTest :: Test
overMeanDeriveTest =
    TestCase
        ( assertEqual
            "over with mean broadcasts per-group averages to all rows"
            expectedMeans
            actualMeans
        )
  where
    simpleData =
        D.fromNamedColumns
            [ ("group", DI.fromList (["A", "A", "B", "B", "B"] :: [T.Text]))
            , ("value", DI.fromList ([10, 20, 30, 60, 90] :: [Double]))
            ]
    result =
        simpleData
            & D.derive "group_mean" (F.over ["group"] (F.mean (F.col @Double "value")))
            & D.sortBy [D.Asc (F.col @T.Text "group"), D.Asc (F.col @Double "value")]
    -- A mean = 15.0, B mean = 60.0
    expectedMeans = [15.0, 15.0, 60.0, 60.0, 60.0] :: [Double]
    actualMeans = case DI.getColumn "group_mean" result of
        Nothing -> error "group_mean column not found"
        Just c -> DI.toList @Double c

overSumTest :: Test
overSumTest =
    TestCase
        ( assertEqual
            "over with sum broadcasts per-group sums"
            expectedSums
            actualSums
        )
  where
    simpleData =
        D.fromNamedColumns
            [ ("group", DI.fromList (["X", "X", "Y", "Y"] :: [T.Text]))
            , ("value", DI.fromList ([10, 20, 100, 200] :: [Int]))
            ]
    result =
        simpleData
            & D.derive "group_sum" (F.over ["group"] (F.sum (F.col @Int "value")))
            & D.sortBy [D.Asc (F.col @T.Text "group"), D.Asc (F.col @Int "value")]
    expectedSums = [30, 30, 300, 300] :: [Int]
    actualSums = case DI.getColumn "group_sum" result of
        Nothing -> error "group_sum column not found"
        Just c -> DI.toList @Int c

overCountTest :: Test
overCountTest =
    TestCase
        ( assertEqual
            "over with count broadcasts per-group counts"
            expectedCounts
            actualCounts
        )
  where
    simpleData =
        D.fromNamedColumns
            [ ("group", DI.fromList (["A", "A", "A", "B", "B"] :: [T.Text]))
            , ("value", DI.fromList ([1, 2, 3, 4, 5] :: [Int]))
            ]
    result =
        simpleData
            & D.derive "group_count" (F.over ["group"] (F.count (F.col @Int "value")))
            & D.sortBy [D.Asc (F.col @T.Text "group"), D.Asc (F.col @Int "value")]
    expectedCounts = [3, 3, 3, 2, 2] :: [Int]
    actualCounts = case DI.getColumn "group_count" result of
        Nothing -> error "group_count column not found"
        Just c -> DI.toList @Int c

mixedGlobalAndOverTest :: Test
mixedGlobalAndOverTest =
    TestCase
        ( assertEqual
            "Can mix over (per-group) and global expressions"
            expectedDeviations
            actualDeviations
        )
  where
    simpleData =
        D.fromNamedColumns
            [ ("group", DI.fromList (["A", "A", "B", "B"] :: [T.Text]))
            , ("value", DI.fromList ([10.0, 20.0, 100.0, 200.0] :: [Double]))
            ]
    result =
        simpleData
            & D.derive
                "deviation"
                (F.col @Double "value" - F.over ["group"] (F.mean (F.col @Double "value")))
            & D.sortBy [D.Asc (F.col @T.Text "group"), D.Asc (F.col @Double "value")]
    expectedDeviations = [-5.0, 5.0, -50.0, 50.0] :: [Double]
    actualDeviations = case DI.getColumn "deviation" result of
        Nothing -> error "deviation column not found"
        Just c -> DI.toList @Double c

-- | Example from https://www.sumsar.net/blog/pandas-feels-clunky-when-coming-from-r/
blogPostExampleGlobal :: Test
blogPostExampleGlobal =
    TestCase
        ( assertEqual
            "Blog post example: global filter then group+aggregate"
            expected
            actual
        )
  where
    amount = F.col @Double "amount"
    discount = F.col @Double "discount"
    actual =
        purchases
            & D.filterWhere (amount .<=. F.median amount * 10)
            & D.groupBy ["country"]
            & D.aggregate [F.sum (amount - discount) `as` "total"]
            & D.sortBy [D.Asc (F.col @T.Text "country")]
    -- Global median = 60, threshold = 600 → removes 5000 and 800
    -- France: (30-1)+(40-2)+(35-1) = 101, UK: (50-2)+(60-3) = 105, US: (100-5)+(200-10) = 285
    expected =
        D.fromNamedColumns
            [ ("country", DI.fromList (["France", "UK", "US"] :: [T.Text]))
            , ("total", DI.fromList ([101, 105, 285] :: [Double]))
            ]

blogPostExampleOver :: Test
blogPostExampleOver =
    TestCase
        ( assertEqual
            "Blog post example: per-group filter (via over) then group+aggregate"
            expected
            actual
        )
  where
    amount = F.col @Double "amount"
    discount = F.col @Double "discount"
    actual =
        purchases
            & D.filterWhere (amount .<=. F.over ["country"] (F.median amount) * 10)
            & D.groupBy ["country"]
            & D.aggregate [F.sum (amount - discount) `as` "total"]
            & D.sortBy [D.Asc (F.col @T.Text "country")]
    expected =
        D.fromNamedColumns
            [ ("country", DI.fromList (["France", "UK", "US"] :: [T.Text]))
            , ("total", DI.fromList ([101, 105, 285] :: [Double]))
            ]

tests :: [Test]
tests =
    [ TestLabel "globalFilterTest" globalFilterTest
    , TestLabel "overMedianFilterTest" overMedianFilterTest
    , TestLabel "globalVsOverDifferentResults" globalVsOverDifferentResults
    , TestLabel "overMeanDeriveTest" overMeanDeriveTest
    , TestLabel "overSumTest" overSumTest
    , TestLabel "overCountTest" overCountTest
    , TestLabel "mixedGlobalAndOverTest" mixedGlobalAndOverTest
    , TestLabel "blogPostExampleGlobal" blogPostExampleGlobal
    , TestLabel "blogPostExampleOver" blogPostExampleOver
    ]