packages feed

dataframe-learn-2.0.0.0: src-internal/DataFrame/Featurize/Internal.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

{- | Shared internal helpers used across the model fitters: turning a 'DataFrame'
plus a target/feature 'Expr' into the numeric matrices the algorithms consume,
plus common expression builders (affine score, arg-max/arg-min over scores).
-}
module DataFrame.Featurize.Internal (
    -- * Supervised extraction
    featureNames,
    numericMatrix,
    targetDoubles,
    targetValues,

    -- * Unsupervised extraction
    Features (..),
    extractFeatures,
    columnExprName,
    materializeColumn,

    -- * Expression builders
    affineExpr,
    argMaxExpr,
    argMinExpr,
) where

import Control.Exception (throw)
import Data.Maybe (isJust)
import qualified Data.Text as T
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as VU
import Type.Reflection (TypeRep, typeRep)

import Data.Either (fromRight)
import DataFrame.Errors (DataFrameException (..), TypeErrorContext (..))
import qualified DataFrame.Functions as F
import DataFrame.Internal.Column (Columnable, columnBitmap, columnTypeString)
import qualified DataFrame.Internal.Column as D
import DataFrame.Internal.DataFrame (DataFrame, columnNames, getColumn)
import DataFrame.Internal.Expression (Expr (..))
import DataFrame.LinearAlgebra (Matrix, transposeM)
import DataFrame.Operations.Core (
    columnAsDoubleVector,
    columnAsUnboxedVector,
    columnAsVector,
 )
import DataFrame.Operators ((.&&.), (.*.), (.+.), (.<=.), (.>=.))

-- | Every column name except the supervised target's.
featureNames :: Expr a -> DataFrame -> [T.Text]
featureNames (Col target) df = filter (/= target) (columnNames df)
featureNames _ df = columnNames df

{- | The named columns as a row-major @n×d@ matrix, paired with the names. Every
column must already be stored as non-null 'Double'; a nullable ('Maybe Double')
or non-'Double' column is a fit-time error rather than a silent coercion.
-}
numericMatrix :: [T.Text] -> DataFrame -> (V.Vector T.Text, Matrix)
numericMatrix names df = (V.fromList names, transposeM colMajor)
  where
    colMajor = V.fromList (map (`requireDoubleColumn` df) names)

{- | Read a feature\/target column strictly as a non-null 'Double' vector. A
nullable ('Maybe Double') column, or a column of any other type, is a fit-time
error naming the column and pointing at the fix.
-}
requireDoubleColumn :: T.Text -> DataFrame -> VU.Vector Double
requireDoubleColumn name df = case getColumn name df of
    Nothing ->
        throw
            ( TypeMismatchException
                ( MkTypeErrorContext
                    (Right (typeRep @Double))
                    (Left "missing" :: Either String (TypeRep Double))
                    (Just (T.unpack name))
                    Nothing
                )
            )
    Just c ->
        if isJust (columnBitmap c)
            then throw (nullableInputError name (columnTypeString c))
            else
                if D.hasElemType @Double c
                    then fromRight undefined (columnAsUnboxedVector (F.col @Double name) df)
                    else
                        throw
                            ( asDoubleInputError
                                name
                                ( TypeMismatchException
                                    ( MkTypeErrorContext
                                        (Right (typeRep @Double))
                                        (Left (columnTypeString c) :: Either String (TypeRep Double))
                                        (Just (T.unpack name))
                                        Nothing
                                    )
                                )
                            )

-- | Actionable error for a non-'Double' input column (wraps the type mismatch).
asDoubleInputError :: T.Text -> DataFrameException -> DataFrameException
asDoubleInputError name (TypeMismatchException ctx) =
    TypeMismatchException
        ctx
            { errorColumnName = Just (T.unpack name)
            , callingFunctionName =
                Just
                    "model fit (input columns must be Double; convert with toDouble or build the column as Double)"
            }
asDoubleInputError _ e = e

-- | Actionable error for a nullable ('Maybe Double') input column.
nullableInputError :: T.Text -> String -> DataFrameException
nullableInputError name found =
    TypeMismatchException
        ( MkTypeErrorContext
            { userType = Right (typeRep @Double)
            , expectedType = Left found
            , errorColumnName = Just (T.unpack name)
            , callingFunctionName =
                Just
                    "model fit (input columns must be non-null Double; drop or impute missing values before fitting)"
            } ::
            TypeErrorContext Double ()
        )

{- | The target column as a non-null 'Double' vector. A nullable or non-'Double'
target is a fit-time error, not a coercion.
-}
targetDoubles :: Expr Double -> DataFrame -> VU.Vector Double
targetDoubles (Col name) df = requireDoubleColumn name df
targetDoubles expr df = case columnAsUnboxedVector expr df of
    Right v -> v
    Left e -> throw (asDoubleInputError (T.pack "<target>") e)

-- | The target column as a vector of its own type (for classifiers).
targetValues :: (Columnable a) => Expr a -> DataFrame -> V.Vector a
targetValues expr df = case columnAsVector expr df of
    Right v -> v
    Left e -> throw e

{- | The extracted feature columns of an unsupervised fit, in the shapes the
algorithms need: names, column-major vectors, the row-major matrix, and the
@(n, d)@ dimensions.
-}
data Features = Features
    { ftNames :: ![T.Text]
    , ftCols :: ![VU.Vector Double]
    , ftRows :: !Matrix
    , ftN :: !Int
    , ftD :: !Int
    }

-- | Extract the given feature columns once, in every shape the fitters use.
extractFeatures :: [Expr Double] -> DataFrame -> Features
extractFeatures features df = Features names cols rows n d
  where
    names = map columnExprName features
    cols = map (materializeColumn df) features
    n = case cols of
        (x : _) -> VU.length x
        _ -> 0
    d = length cols
    rows = V.generate n (\i -> VU.generate d (\j -> (cols !! j) VU.! i))

-- | The column name behind a @Col@ feature expression.
columnExprName :: Expr Double -> T.Text
columnExprName (Col n) = n
columnExprName e = error ("expected a column expression, got " ++ show e)

-- | Interpret a @Col@ (or numeric) expression to a @Double@ vector.
materializeColumn :: DataFrame -> Expr Double -> VU.Vector Double
materializeColumn df e = case columnAsDoubleVector e df of
    Right v -> v
    Left err -> throw err

{- | An affine score @b + Σ wⱼ·colⱼ@ over named columns, dropping zero weights
(the shared core of linear/logistic/SVM margins).
-}
affineExpr :: Double -> [(Double, T.Text)] -> Expr Double
affineExpr b terms =
    foldr
        (.+.)
        (F.lit b)
        [F.lit w .*. (Col n :: Expr Double) | (w, n) <- terms, w /= 0]

{- | The class whose score is greatest, as a nested-@If@ expression; ties go to
the earlier class.
-}
argMaxExpr :: (Columnable a) => [(a, Expr Double)] -> Expr a
argMaxExpr = argExtreme (.>=.)

-- | The class whose score is smallest (e.g. nearest cluster by distance).
argMinExpr :: (Columnable a) => [(a, Expr Double)] -> Expr a
argMinExpr = argExtreme (.<=.)

argExtreme ::
    (Columnable a) =>
    (Expr Double -> Expr Double -> Expr Bool) -> [(a, Expr Double)] -> Expr a
argExtreme _ [] = error "argExtreme: no classes"
argExtreme _ [(c, _)] = Lit c
argExtreme cmp ((c, sc) : rest) =
    If
        (foldr ((\o acc -> cmp sc o .&&. acc) . snd) (F.lit True) rest)
        (Lit c)
        (argExtreme cmp rest)