packages feed

dataframe-learn-2.0.0.0: src/DataFrame/SVM/RFF.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

{- | Approximate RBF-kernel SVM via Random Fourier Features (Rahimi & Recht): map
each row through @z(x) = √(2/D)·cos(W x + b)@ with @W ~ N(0, 2γI)@ (seeded), then
fit a linear SVC in the random-feature space. 'predict' compiles to a closed
@Σ_r β_r·cos(…)@ expression of size @O(D·d)@, independent of the row count.
-}
module DataFrame.SVM.RFF (
    module DataFrame.Model,
    RFFConfig (..),
    defaultRFFConfig,
    RFFSVMModel (..),
) where

import Data.List (sort)
import qualified Data.Text as T
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as VU

import DataFrame.Featurize.Internal (featureNames, numericMatrix, targetValues)
import qualified DataFrame.Functions as F
import DataFrame.Internal.Column (Columnable)
import DataFrame.Internal.DataFrame (DataFrame)
import DataFrame.Internal.Expression (Expr (..))
import DataFrame.LinearAlgebra (dot)
import DataFrame.LinearSolver (LinearModel (..), SolverConfig (..), fitProx)
import DataFrame.LinearSolver.Loss (sqHingeLoss)
import DataFrame.Model
import DataFrame.Operators ((.*.), (.+.), (.>.))
import DataFrame.Random (Gen, gaussianVector, mkGen, nextDouble)

data RFFConfig = RFFConfig
    { rffD :: !Int
    , rffGamma :: !Double
    , rffC :: !Double
    , rffMaxIter :: !Int
    , rffTol :: !Double
    , rffSeed :: !Int
    }
    deriving (Eq, Show)

defaultRFFConfig :: RFFConfig
defaultRFFConfig =
    RFFConfig
        { rffD = 100
        , rffGamma = 0.1
        , rffC = 1.0
        , rffMaxIter = 1000
        , rffTol = 1.0e-4
        , rffSeed = 0
        }

{- | A fitted RFF SVM (binary). 'rffW' / 'rffB' are the random projection;
'rffCoef' / 'rffIntercept' the linear SVC in feature space.
-}
data RFFSVMModel a = RFFSVMModel
    { rffW :: !(V.Vector (VU.Vector Double))
    , rffB :: !(VU.Vector Double)
    , rffCoef :: !(VU.Vector Double)
    , rffIntercept :: !Double
    , rffScale :: !Double
    , rffNegClass :: !a
    , rffPosClass :: !a
    , rffFeatureNames :: !(V.Vector T.Text)
    }
    deriving (Show)

instance (Columnable a, Ord a) => Fit RFFConfig (Expr a) where
    type ModelOf RFFConfig (Expr a) = (RFFSVMModel a)
    fit = fitRFFSVM

instance (Columnable a) => Predict (RFFSVMModel a) where
    type Prediction (RFFSVMModel a) = Expr a
    predict m =
        If (margin .>. F.lit 0) (Lit (rffPosClass m)) (Lit (rffNegClass m))
      where
        names = V.toList (rffFeatureNames m)
        margin =
            foldr (.+.) (F.lit (rffIntercept m)) $
                [ F.lit (rffCoef m VU.! r * rffScale m) .*. cosTerm r
                | r <- [0 .. V.length (rffW m) - 1]
                , rffCoef m VU.! r /= 0
                ]
        cosTerm r = cos (linComb (rffW m V.! r) (rffB m VU.! r))
        linComb w b =
            foldr (.+.) (F.lit b) $
                [ F.lit (w VU.! j) .*. (Col n :: Expr Double)
                | (j, n) <- zip [0 ..] names
                ]

-- | Fit a binary RFF SVM. Targets with more than two classes are rejected.
fitRFFSVM ::
    (Columnable a, Ord a) => RFFConfig -> Expr a -> DataFrame -> RFFSVMModel a
fitRFFSVM cfg target df =
    case classes of
        [neg, pos] -> build neg pos
        _ -> error "fitRFFSVM: binary classification only (got /= 2 classes)"
  where
    names = featureNames target df
    (nameVec, mat) = numericMatrix names df
    ys = targetValues target df
    classes = sort (foldr dedup [] (V.toList ys))
    dedup x acc = if x `elem` acc then acc else x : acc
    d = if V.null mat then 0 else VU.length (V.head mat)
    bigD = max 1 (rffD cfg)
    (ws, bs) = sampleRFF bigD d (rffGamma cfg) (mkGen (rffSeed cfg))
    scale = sqrt (2 / fromIntegral bigD)
    z = V.map (featureRow ws bs scale) mat
    featNames = V.fromList ["rff" <> T.pack (show r) | r <- [0 .. bigD - 1]]
    build neg pos =
        let labels = VU.generate (V.length ys) (\i -> if ys V.! i == pos then 1 else -1)
            solverCfg =
                SolverConfig
                    { scL1Lambda = 0
                    , scL2Lambda = 1 / rffC cfg
                    , scMaxIter = rffMaxIter cfg
                    , scTol = rffTol cfg
                    , scSampleWeights = Nothing
                    }
            model = fitProx sqHingeLoss solverCfg z labels featNames
         in RFFSVMModel ws bs (lmWeights model) (lmIntercept model) scale neg pos nameVec

sampleRFF ::
    Int -> Int -> Double -> Gen -> (V.Vector (VU.Vector Double), VU.Vector Double)
sampleRFF bigD d gamma g0 = (V.fromList ws, VU.fromList bs)
  where
    sigma = sqrt (2 * gamma)
    (ws, g1) = goW bigD g0 []
    goW 0 g acc = (reverse acc, g)
    goW k g acc =
        let (vec, g') = gaussianVector d g
         in goW (k - 1) g' (VU.map (* sigma) vec : acc)
    bs = take bigD (goB g1)
    goB g = let (u, g') = nextDouble g in (u * 2 * pi) : goB g'

featureRow ::
    V.Vector (VU.Vector Double) ->
    VU.Vector Double ->
    Double ->
    VU.Vector Double ->
    VU.Vector Double
featureRow ws bs scale x =
    VU.generate (V.length ws) $ \r ->
        scale * cos (dot (ws V.! r) x + bs VU.! r)