dataframe-core-2.5.0.0: src-internal/DataFrame/Internal/Column/Encode.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE ExplicitNamespaces #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{- | Dictionary-encode a text (or factor) group key to dense @Int@ codes: each row
gets a first-appearance code @0..card-1@ (NULL reserved) plus the cardinality.
TODO: mchavinda - revise if this module is still necessary.
-}
module DataFrame.Internal.Column.Encode (
dictEncodeColumn,
dictEncodeColumnUpTo,
dictCompactColumn,
dictMaxCardinality,
) where
import Control.Monad (when)
import Control.Monad.ST (runST)
import qualified Data.Text as T
import qualified Data.Text.Array as A
import Data.Type.Equality (TestEquality (..), type (:~:) (Refl))
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as VU
import qualified Data.Vector.Unboxed.Mutable as VUM
import Type.Reflection (typeRep)
import DataFrame.Internal.Algorithms.Hash (
fnvOffset,
mixBytes,
mixText,
nullSalt,
)
import DataFrame.Internal.Column (Column (..))
import DataFrame.Internal.Column.Bitmap (Bitmap, bitmapTestBit)
import DataFrame.Internal.Data.HashTable (htInsert, newHashTable)
import DataFrame.Internal.Data.PackedText (
PackedTextData (..),
mkOffsets,
mkSel,
packedLength,
packedSlice,
sliceEqBytes,
)
{- | Largest distinct-value count we will dictionary-encode. Above this the codes
no longer index a reasonable direct accumulator and the encode pass is pure
overhead, so the caller keeps the plain hash group-by.
-}
dictMaxCardinality :: Int
dictMaxCardinality = 1048576
{- | Dictionary-encode a text-like column to dense first-appearance @Int@ codes,
returning @Just (codes, cardinality)@ (a NULL row gets its own reserved code).
'Nothing' for non-text columns or cardinality above 'dictMaxCardinality'.
-}
dictEncodeColumn :: Column -> Maybe (VU.Vector Int, Int)
dictEncodeColumn = dictEncodeColumnUpTo dictMaxCardinality
{- | Dictionary-encode like 'dictEncodeColumn' but bail to 'Nothing' as soon as
the distinct count would exceed @maxCard@, letting a low-cardinality probe avoid
a full high-cardinality pass.
-}
dictEncodeColumnUpTo :: Int -> Column -> Maybe (VU.Vector Int, Int)
dictEncodeColumnUpTo maxCard (PackedText bm p) = encodePacked maxCard bm p
dictEncodeColumnUpTo maxCard (BoxedColumn bm (v :: V.Vector a)) =
case testEquality (typeRep @a) (typeRep @T.Text) of
Just Refl -> encodeBoxedText maxCard bm v
Nothing -> Nothing
dictEncodeColumnUpTo _ _ = Nothing
{- | Encode a packed-text column: hash each row's raw UTF-8 bytes (the grouping
'mixBytes'), re-verify byte equality on collisions, assign dense codes in
first-appearance order. A null row hashes 'nullSalt'.
-}
encodePacked ::
Int -> Maybe Bitmap -> PackedTextData -> Maybe (VU.Vector Int, Int)
encodePacked maxCard bm p =
let !n = packedLength p
valid i = case bm of
Just b -> bitmapTestBit b i
Nothing -> True
hashAt i =
if valid i
then let (arr, o, l) = packedSlice p i in mixBytes fnvOffset arr o l
else nullSalt
eqAt a b =
case (valid a, valid b) of
(True, True) ->
let (arrA, oA, lA) = packedSlice p a
(arrB, oB, lB) = packedSlice p b
in sliceEqBytes arrA oA lA arrB oB lB
(False, False) -> True
_ -> False
in buildCodes maxCard n hashAt eqAt
{- | Encode a boxed 'Data.Text.Text' column, mirroring 'encodePacked' but over
boxed values (used when a user-built Text column is grouped).
-}
encodeBoxedText ::
Int -> Maybe Bitmap -> V.Vector T.Text -> Maybe (VU.Vector Int, Int)
encodeBoxedText maxCard bm v =
let !n = V.length v
valid i = case bm of
Just b -> bitmapTestBit b i
Nothing -> True
hashAt i =
if valid i then mixText fnvOffset (V.unsafeIndex v i) else nullSalt
eqAt a b =
case (valid a, valid b) of
(True, True) -> V.unsafeIndex v a == V.unsafeIndex v b
(False, False) -> True
_ -> False
in buildCodes maxCard n hashAt eqAt
{- | The shared code-assignment loop: bucket every row through an open-addressing
table on its precomputed hash, re-verify with @eqAt@ on a hit, assign dense
first-appearance codes. Bails to 'Nothing' once the distinct count exceeds @maxCard@.
-}
buildCodes ::
Int -> Int -> (Int -> Int) -> (Int -> Int -> Bool) -> Maybe (VU.Vector Int, Int)
buildCodes maxCard n hashAt eqAt
| n == 0 = Just (VU.empty, 0)
| otherwise = runST $ do
ht <- newHashTable (min n (maxCard + 1))
codes <- VUM.new n
let go !i !next
| i >= n = pure (Just next)
| next > maxCard = pure Nothing
| otherwise = do
let !h = hashAt i
(code, isNew) <- htInsert ht eqAt next i h
VUM.unsafeWrite codes i code
go (i + 1) (if isNew then next + 1 else next)
mres <- go 0 0
case mres of
Nothing -> pure Nothing
Just card -> do
frozen <- VU.unsafeFreeze codes
pure (Just (frozen, card))
dictCompactColumn :: Column -> Column
dictCompactColumn col@(PackedText bm p) =
case encodePacked dictMaxCardinality bm p of
Just (codes, card)
| 2 * card <= packedLength p ->
PackedText bm (dictPacked p codes card)
_ -> col
dictCompactColumn col = col
dictPacked :: PackedTextData -> VU.Vector Int -> Int -> PackedTextData
dictPacked p codes card = runST $ do
let n = VU.length codes
reps <- VUM.replicate card (-1)
let findReps !i !remaining
| remaining <= 0 || i >= n = pure ()
| otherwise = do
let c = VU.unsafeIndex codes i
cur <- VUM.unsafeRead reps c
if cur < 0
then VUM.unsafeWrite reps c i >> findReps (i + 1) (remaining - 1)
else findReps (i + 1) remaining
findReps 0 card
repsV <- VU.unsafeFreeze reps
let lens = VU.map (\r -> let (_, _, l) = packedSlice p r in l) repsV
offs = VU.scanl' (+) 0 lens
total = VU.last offs
marr <- A.new (max 1 total)
let copyRep !c =
when (c < card) $ do
let r = VU.unsafeIndex repsV c
(arr, o, l) = packedSlice p r
A.copyI l marr (VU.unsafeIndex offs c) arr o
copyRep (c + 1)
copyRep 0
arr <- A.unsafeFreeze marr
pure
( PackedTextData
{ ptBytes = arr
, ptOffsets = mkOffsets offs
, ptSel = Just (mkSel card codes)
, ptCanonicalSel = True
}
)