dataframe-learn-1.1.0.0: src/DataFrame/Metrics.hs
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{- | Evaluation metrics for fitted models. The everyday entry point is
'evaluate', which applies a model's prediction expression and a truth column to
a frame and folds a metric — no manual @interpret@/extract plumbing. Metrics are
plain functions (@type Metric = Vector -> Vector -> Double@), so you pass @mse@
or @accuracy@ directly. Classification metrics handle multiclass via 'Average';
'classificationReport' / 'regressionReport' bundle the common numbers with a
scikit-learn-style 'Show'.
-}
module DataFrame.Metrics (
-- * Metric type + evaluation
Metric,
evaluate,
predictColumn,
columnOf,
-- * Regression metrics
mse,
rmse,
mae,
r2,
-- * Classification metrics
accuracy,
logLoss,
Average (..),
precision,
recall,
f1,
rocAuc,
-- * Per-class helpers (for reports)
classCounts,
precOf,
recOf,
f1Of,
) where
import Data.Either (fromRight)
import Data.List (nub, sort, sortBy)
import Data.Ord (comparing)
import qualified Data.Text as T
import qualified Data.Vector.Unboxed as VU
import DataFrame.Internal.Column (TypedColumn (..), toVector)
import DataFrame.Internal.DataFrame (DataFrame)
import DataFrame.Internal.Expression (Expr)
import DataFrame.Internal.Interpreter (interpret)
import DataFrame.Operations.Transformations (derive)
-- | A metric maps predictions and ground truth to a scalar score.
type Metric = VU.Vector Double -> VU.Vector Double -> Double
{- | Evaluate a model's prediction expression against a truth column on a frame.
> evaluate rmse (linearExpr model) (F.col @Double "target") df
> evaluate accuracy (logisticDecisionExpr model) (F.col @Double "label") df
-}
evaluate :: Metric -> Expr Double -> Expr Double -> DataFrame -> Double
evaluate metric predExpr truthExpr df =
metric (columnOf df predExpr) (columnOf df truthExpr)
-- | Add a model's prediction expression to a frame as a named column.
predictColumn :: T.Text -> Expr Double -> DataFrame -> DataFrame
predictColumn = derive
-- | Interpret an expression to a @Double@ vector over a frame.
columnOf :: DataFrame -> Expr Double -> VU.Vector Double
columnOf df e = case interpret @Double df e of
Right (TColumn c) -> fromRight VU.empty (toVector @Double @VU.Vector c)
Left err -> error (show err)
n2 :: VU.Vector Double -> Double
n2 = fromIntegral . VU.length
-- | Mean squared error.
mse :: Metric
mse preds truth
| VU.null truth = 0
| otherwise =
VU.sum (VU.zipWith (\p t -> (p - t) ^ (2 :: Int)) preds truth) / n2 truth
-- | Root mean squared error.
rmse :: Metric
rmse preds truth = sqrt (mse preds truth)
-- | Mean absolute error.
mae :: Metric
mae preds truth
| VU.null truth = 0
| otherwise = VU.sum (VU.zipWith (\p t -> abs (p - t)) preds truth) / n2 truth
-- | Coefficient of determination @R²@.
r2 :: Metric
r2 preds truth
| VU.null truth || ssTot == 0 = 0
| otherwise = 1 - ssRes / ssTot
where
mean = VU.sum truth / n2 truth
ssRes = VU.sum (VU.zipWith (\p t -> (t - p) ^ (2 :: Int)) preds truth)
ssTot = VU.sum (VU.map (\t -> (t - mean) ^ (2 :: Int)) truth)
-- | Fraction of exact matches.
accuracy :: Metric
accuracy preds truth
| VU.null truth = 0
| otherwise =
fromIntegral (VU.length (VU.filter id (VU.zipWith (==) preds truth))) / n2 truth
-- | Binary log loss; probabilities clamped away from @0@/@1@.
logLoss :: Metric
logLoss probs truth
| VU.null truth = 0
| otherwise =
negate
( VU.sum
( VU.zipWith
(\p y -> let q = clampP p in y * log q + (1 - y) * log (1 - q))
probs
truth
)
)
/ n2 truth
where
clampP p = max 1e-15 (min (1 - 1e-15) p)
-- | Averaging strategy for multiclass precision/recall/F1.
data Average
= -- | one class is positive; the rest negative
Binary Double
| -- | unweighted mean over classes
Macro
| -- | pool per-class counts (equals accuracy for single-label)
Micro
| -- | support-weighted mean over classes
Weighted
deriving (Eq, Show)
-- | Per-class @(tp, fp, fn, support)@ over the class set of @truth ∪ preds@.
classCounts ::
VU.Vector Double -> VU.Vector Double -> [(Double, (Int, Int, Int, Int))]
classCounts preds truth =
[(c, countsFor c) | c <- classes]
where
classes = sort (nub (VU.toList truth ++ VU.toList preds))
countsFor c =
VU.foldl'
( \(tp, fp, fn, sup) (p, y) ->
( if p == c && y == c then tp + 1 else tp
, if p == c && y /= c then fp + 1 else fp
, if p /= c && y == c then fn + 1 else fn
, if y == c then sup + 1 else sup
)
)
(0, 0, 0, 0)
(VU.zip preds truth)
safeDiv :: Int -> Int -> Double
safeDiv a b = if b == 0 then 0 else fromIntegral a / fromIntegral b
precOf, recOf :: (Int, Int, Int, Int) -> Double
precOf (tp, fp, _, _) = safeDiv tp (tp + fp)
recOf (tp, _, fn, _) = safeDiv tp (tp + fn)
f1Of :: (Int, Int, Int, Int) -> Double
f1Of cs =
let p = precOf cs; r = recOf cs in if p + r == 0 then 0 else 2 * p * r / (p + r)
averaged ::
((Int, Int, Int, Int) -> Double) ->
Average ->
VU.Vector Double ->
VU.Vector Double ->
Double
averaged stat avg preds truth =
case avg of
Binary pos -> maybe 0 stat (lookup pos cc)
Macro -> meanOf [stat c | (_, c) <- cc]
Weighted ->
let total = sum [sup | (_, (_, _, _, sup)) <- cc]
in if total == 0
then 0
else
sum [fromIntegral sup * stat c | (_, c@(_, _, _, sup)) <- cc]
/ fromIntegral total
Micro ->
let tp = sum [t | (_, (t, _, _, _)) <- cc]
fp = sum [x | (_, (_, x, _, _)) <- cc]
fn = sum [x | (_, (_, _, x, _)) <- cc]
in stat (tp, fp, fn, 0)
where
cc = classCounts preds truth
meanOf xs = if null xs then 0 else sum xs / fromIntegral (length xs)
-- | Precision with the given averaging.
precision :: Average -> VU.Vector Double -> VU.Vector Double -> Double
precision = averaged precOf
-- | Recall with the given averaging.
recall :: Average -> VU.Vector Double -> VU.Vector Double -> Double
recall = averaged recOf
-- | F1 with the given averaging.
f1 :: Average -> VU.Vector Double -> VU.Vector Double -> Double
f1 = averaged f1Of
{- | Binary ROC-AUC (Mann–Whitney). @scores@ are predicted probabilities, @truth@
is @0@/@1@.
-}
rocAuc :: VU.Vector Double -> VU.Vector Double -> Double
rocAuc scores truth
| nPos == 0 || nNeg == 0 = 0.5
| otherwise = (rankSum - nPos * (nPos + 1) / 2) / (nPos * nNeg)
where
ranked = rankAverages (VU.toList scores)
pairs = zip (VU.toList truth) ranked
rankSum = sum [r | (y, r) <- pairs, y == 1]
nPos = fromIntegral (length (filter (== 1) (VU.toList truth)))
nNeg = fromIntegral (VU.length truth) - nPos
-- | Average ranks (ties share the mean rank), returned in input order.
rankAverages :: [Double] -> [Double]
rankAverages xs =
let indexed = zip [0 :: Int ..] xs
sorted = sortBy (comparing snd) indexed
ranked = assignRanks 1 sorted
in map snd (sortBy (comparing fst) ranked)
where
assignRanks _ [] = []
assignRanks start grp =
let v = snd (head grp)
(tied, rest) = span ((== v) . snd) grp
k = length tied
avgRank = fromIntegral (sum [start .. start + k - 1]) / fromIntegral k
in [(i, avgRank) | (i, _) <- tied] ++ assignRanks (start + k) rest