packages feed

dataframe-viz-1.3.2.1: src/DataFrame/Display/Internal/Common.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE ExplicitNamespaces #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

-- | Internal helpers shared by the terminal and web plot backends.
module DataFrame.Display.Internal.Common (
    -- * Aggregation
    Agg (..),
    aggLabel,
    aggregateByGroup,

    -- * Column extraction
    extractStringColumn,
    extractNumericColumn,
    columnToStrings,
    columnToDoubles,

    -- * Type guards
    isNumericColumn,
    isNumericColumnCheck,

    -- * Categorical helpers
    getCategoricalCounts,

    -- * Top-N rollup
    groupWithOther,
    groupWithOtherForPie,
) where

import qualified Data.Bifunctor
import qualified Data.List as L
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import Data.Type.Equality (TestEquality (testEquality), type (:~:) (Refl))
import qualified Data.Vector as V
import qualified Data.Vector.Generic as VG
import qualified Data.Vector.Unboxed as VU
import Data.Word (Word8)
import GHC.Stack (HasCallStack)
import Type.Reflection (TypeRep, typeRep)

import DataFrame.Internal.Column (
    Column (..),
    Columnable,
    isNumeric,
    materializeMerged,
    materializePacked,
 )
import DataFrame.Internal.Column.Types
import DataFrame.Internal.DataFrame (DataFrame (..), getColumn)

-- | Aggregation strategy for grouped/categorical plots.
data Agg
    = -- | Count rows per group; ignores the value column.
      Count
    | -- | Sum of value column per group.
      Sum
    | -- | Arithmetic mean of value column per group.
      Mean
    | -- | Median of value column per group.
      Median
    | -- | Minimum of value column per group.
      Min
    | -- | Maximum of value column per group.
      Max
    deriving (Eq, Show)

-- | Short label for an aggregation, used in auto-generated chart titles.
aggLabel :: Agg -> T.Text
aggLabel Count = "count"
aggLabel Sum = "sum"
aggLabel Mean = "mean"
aggLabel Median = "median"
aggLabel Min = "min"
aggLabel Max = "max"

-- | Aggregate values across rows grouped by a category column.
aggregateByGroup ::
    (HasCallStack) =>
    Agg ->
    -- | Grouping column (categorical).
    T.Text ->
    -- | Value column; required for everything except 'Count'.
    Maybe T.Text ->
    DataFrame ->
    [(T.Text, Double)]
aggregateByGroup Count groupCol _ df =
    let groups = extractStringColumn groupCol df
        m = L.foldl' (\acc g -> M.insertWith (+) g 1 acc) M.empty groups
        seen = L.nub groups
     in [(g, M.findWithDefault 0 g m) | g <- seen]
aggregateByGroup agg groupCol mValueCol df = case mValueCol of
    Nothing ->
        error $
            "Aggregation "
                ++ show agg
                ++ " requires a value column; only Count works without one."
    Just valueCol ->
        let groups = extractStringColumn groupCol df
            values = extractNumericColumn valueCol df
            seen = L.nub groups
            pairs = zip groups values
            byGroup =
                L.foldl'
                    (\acc (g, v) -> M.insertWith (flip (++)) g [v] acc)
                    M.empty
                    pairs
            reduce = numericReducer agg
         in [(g, reduce (M.findWithDefault [] g byGroup)) | g <- seen]

-- | The numeric reducers, split out from 'aggregateByGroup'.
numericReducer :: Agg -> [Double] -> Double
numericReducer Sum = sum
numericReducer Mean = \vs -> if null vs then 0 else sum vs / fromIntegral (length vs)
numericReducer Median = medianD
numericReducer Min = minimum
numericReducer Max = maximum
numericReducer Count = fromIntegral . length

medianD :: [Double] -> Double
medianD [] = 0
medianD xs =
    let sorted = L.sort xs
        n = length sorted
        mid = n `div` 2
     in if even n
            then (sorted !! (mid - 1) + sorted !! mid) / 2
            else sorted !! mid

isNumericColumn :: DataFrame -> T.Text -> Bool
isNumericColumn df colName = maybe False isNumeric (getColumn colName df)

isNumericColumnCheck :: T.Text -> DataFrame -> Bool
isNumericColumnCheck colName df = isNumericColumn df colName

extractStringColumn :: (HasCallStack) => T.Text -> DataFrame -> [T.Text]
extractStringColumn colName df =
    case M.lookup colName (columnIndices df) of
        Nothing -> error $ "Column " ++ T.unpack colName ++ " not found"
        Just idx -> columnToStrings (columns df V.! idx)

extractNumericColumn :: (HasCallStack) => T.Text -> DataFrame -> [Double]
extractNumericColumn colName df =
    case M.lookup colName (columnIndices df) of
        Nothing -> error $ "Column " ++ T.unpack colName ++ " not found"
        Just idx -> columnToDoubles (columns df V.! idx)

-- | Render a column's values as strings (@Text@ as is, @show@ otherwise).
columnToStrings :: (HasCallStack) => Column -> [T.Text]
columnToStrings col = case col of
    BoxedColumn _ (vec :: V.Vector a) ->
        case testEquality (typeRep @a) (typeRep @T.Text) of
            Just Refl -> V.toList vec
            Nothing -> V.toList $ V.map (T.pack . show) vec
    UnboxedColumn _ vec ->
        V.toList $ VG.map (T.pack . show) (VG.convert vec)
    PackedText _ _ -> columnToStrings (materializePacked col)
    MergedColumn _ _ -> columnToStrings (materializeMerged col)

-- | Coerce a numeric column to @[Double]@; errors on non-numeric elements.
columnToDoubles :: (HasCallStack) => Column -> [Double]
columnToDoubles col = case col of
    BoxedColumn _ vec -> vectorToDoubles vec
    UnboxedColumn _ vec -> unboxedVectorToDoubles vec
    PackedText _ _ -> columnToDoubles (materializePacked col)
    MergedColumn _ _ -> columnToDoubles (materializeMerged col)

vectorToDoubles :: forall a. (Columnable a, Show a) => V.Vector a -> [Double]
vectorToDoubles vec =
    case testEquality (typeRep @a) (typeRep @Double) of
        Just Refl -> V.toList vec
        Nothing -> case sIntegral @a of
            STrue -> V.toList $ V.map fromIntegral vec
            SFalse -> case sFloating @a of
                STrue -> V.toList $ V.map realToFrac vec
                SFalse ->
                    error $ "Column is not numeric (type: " ++ show (typeRep @a) ++ ")"

unboxedVectorToDoubles ::
    forall a. (Columnable a, VU.Unbox a, Show a) => VU.Vector a -> [Double]
unboxedVectorToDoubles vec =
    case testEquality (typeRep @a) (typeRep @Double) of
        Just Refl -> VU.toList vec
        Nothing -> case sIntegral @a of
            STrue -> VU.toList $ VU.map fromIntegral vec
            SFalse -> case sFloating @a of
                STrue -> VU.toList $ VU.map realToFrac vec
                SFalse ->
                    error $ "Column is not numeric (type: " ++ show (typeRep @a) ++ ")"

getCategoricalCounts ::
    (HasCallStack) => T.Text -> DataFrame -> Maybe [(T.Text, Double)]
getCategoricalCounts colName df =
    case M.lookup colName (columnIndices df) of
        Nothing -> error $ "Column " ++ T.unpack colName ++ " not found"
        Just idx ->
            let col = columns df V.! idx
             in case col of
                    BoxedColumn _ (vec :: V.Vector a) ->
                        Just (countBoxed (typeRep @a) vec)
                    UnboxedColumn _ (vec :: VU.Vector a) ->
                        Just (countUnboxed (typeRep @a) vec)
                    PackedText _ _ -> case materializePacked col of
                        BoxedColumn _ (vec :: V.Vector a) -> Just (countBoxed (typeRep @a) vec)
                        _ -> Nothing
                    MergedColumn _ _ -> case materializeMerged col of
                        BoxedColumn _ (vec :: V.Vector a) -> Just (countBoxed (typeRep @a) vec)
                        _ -> Nothing
  where
    countBoxed ::
        forall a. (Show a) => TypeRep a -> V.Vector a -> [(T.Text, Double)]
    countBoxed tr vec
        | Just Refl <- testEquality tr (typeRep @T.Text) = toPairsText $ countValues vec
        | Just Refl <- testEquality tr (typeRep @String) = toPairs $ countValues vec
        | Just Refl <- testEquality tr (typeRep @Integer) = toPairs $ countValues vec
        | Just Refl <- testEquality tr (typeRep @Int) = toPairs $ countValues vec
        | Just Refl <- testEquality tr (typeRep @Double) = toPairs $ countValues vec
        | Just Refl <- testEquality tr (typeRep @Float) = toPairs $ countValues vec
        | Just Refl <- testEquality tr (typeRep @Bool) = toPairs $ countValues vec
        | Just Refl <- testEquality tr (typeRep @Char) = toPairs $ countValues vec
        | otherwise = countByShow $ V.toList vec

    countUnboxed ::
        forall a. (Show a, VU.Unbox a) => TypeRep a -> VU.Vector a -> [(T.Text, Double)]
    countUnboxed tr vec
        | Just Refl <- testEquality tr (typeRep @Int) = toPairs $ countValuesUnboxed vec
        | Just Refl <- testEquality tr (typeRep @Double) =
            toPairs $ countValuesUnboxed vec
        | Just Refl <- testEquality tr (typeRep @Float) =
            toPairs $ countValuesUnboxed vec
        | Just Refl <- testEquality tr (typeRep @Bool) =
            toPairs $ countValuesUnboxed vec
        | Just Refl <- testEquality tr (typeRep @Char) =
            toPairs $ countValuesUnboxed vec
        | Just Refl <- testEquality tr (typeRep @Word8) =
            toPairs $ countValuesUnboxed vec
        | otherwise = countByShow $ VU.toList vec

    toPairs :: (Show a) => [(a, Int)] -> [(T.Text, Double)]
    toPairs = map (\(k, v) -> (T.pack (show k), fromIntegral v))

    toPairsText :: [(T.Text, Int)] -> [(T.Text, Double)]
    toPairsText = map (Data.Bifunctor.second fromIntegral)

    countValues :: (Ord a) => V.Vector a -> [(a, Int)]
    countValues vec = M.toList $ V.foldr' (\x acc -> M.insertWith (+) x 1 acc) M.empty vec

    countValuesUnboxed :: (Ord a, VU.Unbox a) => VU.Vector a -> [(a, Int)]
    countValuesUnboxed vec = M.toList $ VU.foldr' (\x acc -> M.insertWith (+) x 1 acc) M.empty vec

    countByShow :: (Show a) => [a] -> [(T.Text, Double)]
    countByShow xs =
        map (Data.Bifunctor.bimap T.pack fromIntegral) $
            M.toList $
                L.foldl' (\acc x -> M.insertWith (+) (show x) (1 :: Int) acc) M.empty xs

-- | Keep the top-N entries and fold the rest into an "Other" bucket.
groupWithOther :: Int -> [(T.Text, Double)] -> [(T.Text, Double)]
groupWithOther n items =
    let sorted = L.sortOn (negate . snd) items
        (topN, rest) = splitAt n sorted
        otherSum = sum (map snd rest)
     in if null rest || otherSum == 0
            then topN
            else topN ++ [("Other (" <> T.pack (show (length rest)) <> " items)", otherSum)]

-- | Like 'groupWithOther' but annotates the "Other" bucket with its share %.
groupWithOtherForPie :: Int -> [(T.Text, Double)] -> [(T.Text, Double)]
groupWithOtherForPie n items =
    let total = sum (map snd items)
        sorted = L.sortOn (negate . snd) items
        (topN, rest) = splitAt n sorted
        otherSum = sum (map snd rest)
        otherPct = if total == 0 then 0 else round (100 * otherSum / total) :: Int
     in if null rest || otherSum == 0
            then topN
            else
                topN
                    ++ [
                           ( "Other ("
                                <> T.pack (show (length rest))
                                <> " items, "
                                <> T.pack (show otherPct)
                                <> "%)"
                           , otherSum
                           )
                       ]