dataframe-viz-1.0.2.0: src/DataFrame/Display/Internal/Common.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE ExplicitNamespaces #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{- |
Internal shared helpers used by both the terminal and web plot backends.
Not part of the public API.
-}
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)
import DataFrame.Internal.DataFrame (DataFrame (..), getColumn)
import DataFrame.Internal.Types
-- | 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"
{- | Apply an aggregation across rows grouped by the given category column.
For 'Count', the value column is ignored. For all other aggregations, the
value column is extracted as numeric and folded per group. Group order is
the order in which categories first appear.
-}
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, broken out so the overlapping-Count case in
'aggregateByGroup' can be dispatched at the head pattern without
producing a redundant inner match.
-}
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 (identity for @Text@, @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)
-- | Coerce a numeric column to @[Double]@; errors if the element type is not numeric.
columnToDoubles :: (HasCallStack) => Column -> [Double]
columnToDoubles col = case col of
BoxedColumn _ vec -> vectorToDoubles vec
UnboxedColumn _ vec -> unboxedVectorToDoubles vec
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)
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 by value; fold the rest into a single "Other"
bucket. Used to limit bar/pie counts to a readable number.
-}
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
)
]