dataframe-learn-1.0.2.0: src/DataFrame/DecisionTree/Predict.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
-- | Prediction, care-point identification, node validity, and tree loss. The
-- batched, cache-aware variants resolve each branch condition's truth vector
-- once per call instead of once per row.
module DataFrame.DecisionTree.Predict (
predictWithTree,
predictManyWithTree,
predictManyWithTreeCached,
identifyCarePoints,
identifyCarePointsCached,
countCarePointErrors,
partitionIndices,
partitionIndicesCached,
majorityValueFromIndices,
computeTreeLoss,
computeTreeLossCached,
isValidAtNode,
) where
import DataFrame.DecisionTree.CondVec (CondCache, countErrorsByVec, lookupCondVec)
import DataFrame.DecisionTree.Types (CarePoint (..), Direction (..), Tree (..), TreeConfig (..))
import DataFrame.Internal.Column (Columnable, TypedColumn (..), toVector)
import DataFrame.Internal.DataFrame (DataFrame)
import DataFrame.Internal.Expression (Expr (..))
import DataFrame.Internal.Interpreter (interpret)
import Control.Exception (throw)
import Control.Monad.ST (ST)
import Data.Function (on)
import Data.List (maximumBy)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import qualified Data.Vector as V
import qualified Data.Vector.Mutable as VM
import qualified Data.Vector.Unboxed as VU
-- | A condition's truth vector over the DataFrame, or 'Nothing' on a
-- type/interpret failure (callers default such rows to the left child).
branchBool :: DataFrame -> Expr Bool -> Maybe (VU.Vector Bool)
branchBool df cond = case interpret @Bool df cond of
Right (TColumn column) -> either (const Nothing) Just (toVector @Bool @VU.Vector column)
_ -> Nothing
-- | The target column as a label vector, or 'Nothing' on failure.
interpretLabelCol :: forall a. (Columnable a) => DataFrame -> T.Text -> Maybe (V.Vector a)
interpretLabelCol df target = case interpret @a df (Col target) of
Right (TColumn column) -> either (const Nothing) Just (toVector @a column)
_ -> Nothing
-- | Predict the label for a single row by walking a fixed tree (@True@ → left).
predictWithTree :: forall a. (Columnable a) => T.Text -> DataFrame -> Int -> Tree a -> a
predictWithTree _ _ _ (Leaf v) = v
predictWithTree target df idx (Branch cond left right) =
predictWithTree @a target df idx (childFor cond left right idx df)
childFor :: Expr Bool -> Tree a -> Tree a -> Int -> DataFrame -> Tree a
childFor cond left right idx df = case branchBool df cond of
Nothing -> left
Just boolVals -> if boolVals VU.! idx then left else right
predictManyWithTree :: forall a. (Columnable a) => Tree a -> DataFrame -> V.Vector Int -> V.Vector a
predictManyWithTree = predictManyWithTreeCached @a M.empty
-- | 'predictManyWithTree' resolving each branch condition through a 'CondCache'.
-- Each condition is read at most once per call rather than once per row.
predictManyWithTreeCached :: forall a. (Columnable a) => CondCache -> Tree a -> DataFrame -> V.Vector Int -> V.Vector a
predictManyWithTreeCached cache tree df indices = V.create $ do
mv <- VM.new (V.length indices)
fill mv (V.zip (V.enumFromN 0 (V.length indices)) indices) tree
pure mv
where
fill :: VM.MVector s a -> V.Vector (Int, Int) -> Tree a -> ST s ()
fill mv prs (Leaf v) = V.mapM_ (\(p, _) -> VM.write mv p v) prs
fill mv prs (Branch cond left right) = case lookupCondVec cache df cond of
Nothing -> fill mv prs left
Just boolVals -> fillSplit mv (V.partition (\(_, i) -> boolVals VU.! i) prs) left right
fillSplit :: VM.MVector s a -> (V.Vector (Int, Int), V.Vector (Int, Int)) -> Tree a -> Tree a -> ST s ()
fillSplit mv (leftPrs, rightPrs) left right = fill mv leftPrs left >> fill mv rightPrs right
identifyCarePoints :: forall a. (Columnable a) => T.Text -> DataFrame -> V.Vector Int -> Tree a -> Tree a -> [CarePoint]
identifyCarePoints = identifyCarePointsCached @a M.empty
-- | Rows the parent must route to a specific child for the (fixed) subtrees to
-- classify correctly; a 'CondCache' avoids re-interpreting subtree conditions.
identifyCarePointsCached :: forall a. (Columnable a) => CondCache -> T.Text -> DataFrame -> V.Vector Int -> Tree a -> Tree a -> [CarePoint]
identifyCarePointsCached cache target df indices leftTree rightTree =
maybe [] carePoints (interpretLabelCol @a df target)
where
leftPreds = predictManyWithTreeCached cache leftTree df indices
rightPreds = predictManyWithTreeCached cache rightTree df indices
carePoints targetVals = V.toList (V.imapMaybe (checkPoint targetVals leftPreds rightPreds) indices)
checkPoint :: (Eq a) => V.Vector a -> V.Vector a -> V.Vector a -> Int -> Int -> Maybe CarePoint
checkPoint targetVals leftPreds rightPreds k idx =
case (leftPreds V.! k == trueLabel, rightPreds V.! k == trueLabel) of
(True, False) -> Just (CarePoint idx GoLeft)
(False, True) -> Just (CarePoint idx GoRight)
_ -> Nothing
where
trueLabel = targetVals V.! idx
-- | Care points a free condition misroutes (uncached; for the linear path).
countCarePointErrors :: Expr Bool -> DataFrame -> [CarePoint] -> Int
countCarePointErrors cond df carePoints =
maybe (length carePoints) (`countErrorsByVec` carePoints) (branchBool df cond)
partitionIndices :: Expr Bool -> DataFrame -> V.Vector Int -> (V.Vector Int, V.Vector Int)
partitionIndices = partitionIndicesCached M.empty
-- | 'partitionIndices' resolving the condition through a 'CondCache'; a miss
-- routes every index left (matching the uncached fallback).
partitionIndicesCached :: CondCache -> Expr Bool -> DataFrame -> V.Vector Int -> (V.Vector Int, V.Vector Int)
partitionIndicesCached cache cond df indices = case lookupCondVec cache df cond of
Nothing -> (indices, V.empty)
Just boolVals -> V.partition (boolVals VU.!) indices
-- | A split is valid at a node when both children keep at least 'minLeafSize'.
isValidAtNode :: TreeConfig -> DataFrame -> V.Vector Int -> Expr Bool -> Bool
isValidAtNode cfg df indices c =
V.length t >= minLeafSize cfg && V.length f >= minLeafSize cfg
where
(t, f) = partitionIndices c df indices
majorityValueFromIndices :: forall a. (Columnable a, Ord a) => T.Text -> DataFrame -> V.Vector Int -> a
majorityValueFromIndices target df indices = majorityOf (countLabels (labelColOrThrow @a df target) indices)
labelColOrThrow :: forall a. (Columnable a) => DataFrame -> T.Text -> V.Vector a
labelColOrThrow df target = case interpret @a df (Col target) of
Left e -> throw e
Right (TColumn column) -> either throw id (toVector @a column)
countLabels :: (Ord a) => V.Vector a -> V.Vector Int -> M.Map a Int
countLabels vals = V.foldl' (\acc i -> M.insertWith (+) (vals V.! i) (1 :: Int) acc) M.empty
majorityOf :: M.Map a Int -> a
majorityOf counts
| M.null counts = error "Empty indices in majorityValueFromIndices"
| otherwise = fst (maximumBy (compare `on` snd) (M.toList counts))
computeTreeLoss :: forall a. (Columnable a) => T.Text -> DataFrame -> V.Vector Int -> Tree a -> Double
computeTreeLoss = computeTreeLossCached @a M.empty
-- | 0/1 loss of a tree over @indices@, with a 'CondCache' for the predictions.
computeTreeLossCached :: forall a. (Columnable a) => CondCache -> T.Text -> DataFrame -> V.Vector Int -> Tree a -> Double
computeTreeLossCached cache target df indices tree
| V.null indices = 0
| otherwise = maybe 1.0 (treeLoss cache tree df indices) (interpretLabelCol @a df target)
treeLoss :: (Columnable a) => CondCache -> Tree a -> DataFrame -> V.Vector Int -> V.Vector a -> Double
treeLoss cache tree df indices targetVals =
fromIntegral (countMismatches targetVals indices preds) / fromIntegral (V.length indices)
where
preds = predictManyWithTreeCached cache tree df indices
countMismatches :: (Eq a) => V.Vector a -> V.Vector Int -> V.Vector a -> Int
countMismatches targetVals indices preds =
V.length (V.ifilter (\k _ -> targetVals V.! (indices V.! k) /= preds V.! k) preds)