dataframe-learn-2.5.0.0: src/DataFrame/Segmented.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
-- | Fit a separate base model per categorical value-combination.
module DataFrame.Segmented (
module DataFrame.Model,
Segmented (..),
segmented,
segmentOn,
pooled,
Segment (..),
SegmentedModel (..),
SegmentFit (..),
) where
import Control.Exception (throw)
import Data.List ((\\))
import qualified Data.List as L
import qualified Data.Map.Strict as M
import Data.Maybe (isJust)
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as VU
import DataFrame.Errors (DataFrameException (..))
import DataFrame.Expression.Operators ((.&&.), (.==.))
import DataFrame.Featurize.Internal (featureNames, numericMatrix, targetDoubles)
import DataFrame.Internal.Column (
Column (..),
Columnable,
columnBitmap,
columnToTextVec,
hasElemType,
)
import DataFrame.Internal.Column.Bitmap (bitmapTestBit)
import DataFrame.Internal.Column.Types (SBool (..), sIntegral)
import DataFrame.Internal.DataFrame (DataFrame, unsafeGetColumn)
import DataFrame.Internal.Expression (Expr (..))
import DataFrame.LinearAlgebra (Matrix, gram, matVec, tMatVec)
import DataFrame.LinearAlgebra.Solve (choleskySolve, qrLeastSquares)
import DataFrame.LinearModel.Logistic (LogisticConfig)
import DataFrame.LinearModel.Regression (
LinearConfig (..),
LinearRegressor (..),
)
import DataFrame.Model
import DataFrame.Operations.Core (nRows)
import DataFrame.Operations.Subset (exclude, rowsAtIndices)
import DataFrame.SymbolicRegression (SRConfig)
-- | A base estimator wrapped to fit one model per categorical combination.
data Segmented cfg = Segmented
{ segBase :: !cfg
, segOn :: !(Maybe [T.Text])
, segMaxCard :: !Int
, segMinRows :: !Int
, segPool :: !Double
}
deriving (Eq, Show)
-- | Wrap a base config with default segmentation settings.
segmented :: cfg -> Segmented cfg
segmented base = Segmented base Nothing 32 30 0
-- | Segment only on the named Text columns, overriding auto-detect.
segmentOn :: Segmented cfg -> [T.Text] -> Segmented cfg
segmentOn s cols = s{segOn = Just cols}
-- | Set the pooling strength @λ@ (shrink segments toward the reference).
pooled :: Segmented cfg -> Double -> Segmented cfg
pooled s lam = s{segPool = lam}
-- | One fitted segment: its categorical key, row count, and base model.
data Segment model = Segment
{ segKey :: ![T.Text]
, segN :: !Int
, segModel :: !model
}
deriving (Show)
-- | A fitted segmented model with per-segment, fallback and routing parts.
data SegmentedModel a model = SegmentedModel
{ smCatCols :: ![T.Text]
, smSegments :: ![Segment model]
, smFellBack :: ![([T.Text], Int)]
, smFallback :: !model
, smExpr :: !(Expr a)
}
deriving (Show)
-- | How a base estimator fits per-segment models under pooling strength.
class (Fit cfg (Expr a)) => SegmentFit cfg a where
-- | Fit the qualifying segments (each a numeric-only frame, in order).
fitSegments :: cfg -> Double -> Expr a -> [DataFrame] -> [ModelOf cfg (Expr a)]
fitSegments cfg lam target dfs
| lam == 0 = map (fit cfg target) dfs
| otherwise =
error
"Segmented: pooling (lambda > 0) is not supported for this base model; use lambda = 0 or a linear base."
-- | Logistic segments support independent fitting (lambda = 0) only, for now.
instance (Columnable a, Ord a) => SegmentFit LogisticConfig a
-- | Symbolic-regression segments support independent fitting (lambda = 0) only.
instance SegmentFit SRConfig Double
instance
( Fit cfg (Expr a)
, SegmentFit cfg a
, Predict (ModelOf cfg (Expr a))
, Prediction (ModelOf cfg (Expr a)) ~ Expr a
, Columnable a
) =>
Fit (Segmented cfg) (Expr a)
where
type ModelOf (Segmented cfg) (Expr a) = SegmentedModel a (ModelOf cfg (Expr a))
type FrameReq (Segmented cfg) (Expr a) = 'AnyFrame
fit = fitSegmented
instance Predict (SegmentedModel a model) where
type Prediction (SegmentedModel a model) = Expr a
predict = smExpr
fitSegmented ::
forall cfg a.
( Fit cfg (Expr a)
, SegmentFit cfg a
, Predict (ModelOf cfg (Expr a))
, Prediction (ModelOf cfg (Expr a)) ~ Expr a
, Columnable a
) =>
Segmented cfg ->
Expr a ->
DataFrame ->
SegmentedModel a (ModelOf cfg (Expr a))
fitSegmented (Segmented base mcols maxCard minRows lam) target df =
seq (guardNumeric df textCols target) result
where
mTarget = case target of
Col n -> Just n
_ -> Nothing
feats = featureNames target df
textCols = [c | c <- feats, isTextCol df c]
catCols = resolveCatCols df mTarget textCols mcols maxCard
numericFrame = exclude textCols
globalM = fit base target (numericFrame df)
result
| null catCols =
SegmentedModel [] [] [] globalM (predict globalM)
| otherwise =
let d = length (feats \\ textCols)
floor' = max minRows (d + 1)
grouped = groupByKey df catCols
(qualifying, undersized) =
span' (\(_, ixs) -> VU.length ixs >= floor') grouped
qualFrames =
[numericFrame (rowsAtIndices ixs df) | (_, ixs) <- qualifying]
segModels = fitSegments base lam target qualFrames
segments =
zipWith
(\(k, ixs) m -> Segment k (VU.length ixs) m)
qualifying
segModels
fellBack = [(k, VU.length ixs) | (k, ixs) <- undersized]
expr = buildExpr catCols segments globalM
in SegmentedModel catCols segments fellBack globalM expr
-- | 'span' over a predicate that need not hold contiguously.
span' :: (b -> Bool) -> [b] -> ([b], [b])
span' p xs = (filter p xs, filter (not . p) xs)
-- | Compile the routing: a right-folded If ladder ending in the fallback.
buildExpr ::
(Columnable a, Predict model, Prediction model ~ Expr a) =>
[T.Text] ->
[Segment model] ->
model ->
Expr a
buildExpr catCols segs fallback =
foldr
(\(Segment key _ m) acc -> If (keyCond catCols key) (predict m) acc)
(predict fallback)
segs
-- | @col1 == v1 && col2 == v2 && ...@ for a segment's key.
keyCond :: [T.Text] -> [T.Text] -> Expr Bool
keyCond catCols vals =
foldr1 (.&&.) [(Col c :: Expr T.Text) .==. Lit v | (c, v) <- zip catCols vals]
-- | The Text feature columns to segment on, explicit or auto-detected.
resolveCatCols ::
DataFrame -> Maybe T.Text -> [T.Text] -> Maybe [T.Text] -> Int -> [T.Text]
resolveCatCols df mTarget textCols mcols maxCard = case mcols of
Just cols ->
let bad = filter (\c -> not (isTextCol df c) || Just c == mTarget) cols
in if null bad
then cols
else
error
( "Segmented: segmentOn columns must be Text features (not the target); invalid: "
++ show bad
)
Nothing -> [c | c <- textCols, distinctCount df c <= maxCard]
isTextCol :: DataFrame -> T.Text -> Bool
isTextCol df c = hasElemType @T.Text (unsafeGetColumn c df)
distinctCount :: DataFrame -> T.Text -> Int
distinctCount df c =
Set.size (Set.fromList (V.toList (columnToTextVec (unsafeGetColumn c df))))
-- | Reject feature columns that are neither Text nor non-null Double.
guardNumeric :: DataFrame -> [T.Text] -> Expr a -> ()
guardNumeric df textCols target =
case problems of
[] -> ()
ps ->
error
( "Segmented: unusable feature column(s):\n"
++ unlines (map fmt ps)
)
where
problems =
[ (c, r)
| c <- featureNames target df \\ textCols
, Just r <- [reason (unsafeGetColumn c df)]
]
fmt (c, r) = " " ++ T.unpack c ++ ": " ++ r
reason col
| isJust (columnBitmap col) =
Just
"has missing values — drop them (filterJust / filterAllJust) or model missingness explicitly; imputing risks train/inference skew"
| isIntegralCol col =
Just "is an integer column — cast to Double with F.toDouble"
| not (hasElemType @Double col) =
Just
"is not Double — convert to Double (numeric) or segment on it (categorical)"
| otherwise = Nothing
isIntegralCol col = case col of
UnboxedColumn _ (_ :: VU.Vector b) -> case sIntegral @b of
STrue -> True
_ -> False
_ -> False
-- | Group row indices by composite categorical key, dropping null keys.
groupByKey :: DataFrame -> [T.Text] -> [([T.Text], VU.Vector Int)]
groupByKey df catCols =
map (\(k, is) -> (k, VU.fromList (reverse is))) (M.toAscList grouped)
where
n = nRows df
cols = map (`unsafeGetColumn` df) catCols
textVecs = map columnToTextVec cols
bitmaps = map columnBitmap cols
validRow i = all (maybe True (`bitmapTestBit` i)) bitmaps
keyOf i = [tv V.! i | tv <- textVecs]
grouped =
L.foldl'
(\m i -> if validRow i then M.insertWith (++) (keyOf i) [i] m else m)
M.empty
[0 .. n - 1]
-- | Linear segments with exact closed-form pooling.
instance SegmentFit LinearConfig Double where
fitSegments cfg lam target dfs
| lam == 0 = map (fit cfg target) dfs
| null dfs = []
| otherwise = shrinkLinear cfg lam target dfs
shrinkLinear ::
LinearConfig -> Double -> Expr Double -> [DataFrame] -> [LinearRegressor]
shrinkLinear cfg lam target dfs = map toReg dsegs
where
names = case dfs of
(d0 : _) -> featureNames target d0
[] -> throw (EmptyDataSetException "shrinkLinear")
d = length names
mats = [snd (numericMatrix names dframe) | dframe <- dfs]
ys = [targetDoubles target dframe | dframe <- dfs]
ns = map V.length mats
pooledRows = V.concat mats
nP = V.length pooledRows
means =
VU.generate d $ \j ->
sum [(pooledRows V.! i) VU.! j | i <- [0 .. nP - 1]] / fromIntegral nP
sds =
VU.generate d $ \j ->
let m = means VU.! j
var =
sum [sq ((pooledRows V.! i) VU.! j - m) | i <- [0 .. nP - 1]]
/ fromIntegral nP
in sqrt var
stdValue j x = let s = sds VU.! j in if s == 0 then 0 else (x - means VU.! j) / s
augStd row = VU.cons 1 (VU.imap stdValue row)
zMats = [V.map augStd m | m <- mats]
olsAll = zipWith fitOLS zMats ys
fitOLS z y = either (const Nothing) Just (qrLeastSquares z y)
good = [(n, sol) | (n, Just sol) <- zip ns olsAll]
totW = fromIntegral (sum (map fst good)) :: Double
dref
| null good = VU.replicate (d + 1) 0
| otherwise =
VU.generate (d + 1) $ \k ->
sum [fromIntegral n * (sol VU.! k) | (n, sol) <- good] / totW
dsegs = zipWith solveSeg zMats ys
solveSeg z y =
let r = VU.zipWith (-) y (matVec z dref)
a = addDiagonal lam (gram z)
rhs = tMatVec z r
in case choleskySolve a rhs of
Just eta -> VU.zipWith (+) dref eta
Nothing -> dref
toReg dseg =
let bStd = dseg VU.! 0
wStd = VU.drop 1 dseg
rawCoef = VU.imap (\j w -> let s = sds VU.! j in if s == 0 then 0 else w / s) wStd
adj =
sum
[ let s = sds VU.! j
in if s == 0 then 0 else (wStd VU.! j) * (means VU.! j) / s
| j <- [0 .. d - 1]
]
in LinearRegressor rawCoef (bStd - adj) (V.fromList names) (lcPenalty cfg)
sq :: Double -> Double
sq x = x * x
-- | Add @lam@ to the diagonal of a square matrix.
addDiagonal :: Double -> Matrix -> Matrix
addDiagonal lam = V.imap (\i row -> row VU.// [(i, (row VU.! i) + lam)])