packages feed

dataframe-learn-2.0.0.0: src/DataFrame/Model.hs

{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

{- | The two verbs every model speaks. Instead of a per-model @fitX@ / @xExpr@
zoo, every estimator is an instance of these classes:

  * 'fit' trains a model from a hyperparameter config, an @input@ (the supervised
    target @Expr a@ or the unsupervised feature list @[Expr Double]@), and a frame.
  * 'predict' compiles the model's canonical prediction to an @Expr@ over the raw
    columns (regressors give @Expr Double@, classifiers @Expr a@, clusterers
    @Expr Int@). Models with no honest out-of-sample prediction (e.g. DBSCAN) simply
    have no instance — @predict@ on them is a compile error, not a fake.

Every prediction lands in the /same/ expression type, so a fitted model composes
with 'DataFrame.Operations.Transformations.derive', the 'DataFrame.Transform'
monoid, and 'DataFrame.Transform.compileThrough' with no per-model glue.

Auxiliary outputs (class probabilities, per-cluster distances, component
loadings, the @*Transform@ pipeline pieces) keep their own descriptive
functions — they are not the one canonical prediction, so they are not forced
through 'predict'.

'fit' is a single, frame-polymorphic verb: it accepts an untyped 'DataFrame' or a
phantom-typed 'DataFrame.Typed.Types.TypedDataFrame', via 'ToDataFrame'. When a
model declares a schema requirement (via 'FrameReq'), that requirement is checked
at /compile time/ for a typed frame and is a no-op for an untyped one. Linear
regression, for instance, requires an all-'Double' frame ('AllDoubleFrame'), so
@fit@ on a typed frame with a non-'Double' column is a compile error; on an
untyped frame the same mistake surfaces as a fit-time error.
-}
module DataFrame.Model (
    Fit (..),
    ToDataFrame (..),
    Fitted (..),
    FitResult,
    FrameFor,
    FrameKind (..),
    CheckFrame,
    AllDouble,
    Predict (..),
    AsTExpr,
    ToTExpr (..),
) where

import Data.Kind (Constraint, Type)

import DataFrame.Internal.DataFrame (DataFrame)
import DataFrame.Internal.Expression (Expr (..))
import DataFrame.Typed.Freeze (ToDataFrame (..), thaw)
import DataFrame.Typed.Schema (AllDouble)
import DataFrame.Typed.Types (AsTExpr, TExpr (..), ToTExpr (..), TypedDataFrame)

{- | A model trained on a typed frame, carrying the schema @cols@ as a phantom so
its 'predict' yields a typed 'TExpr'. Use 'fittedModel' to recover the bare model
record (coefficients, etc.).
-}
newtype Fitted (cols :: [Type]) model = Fitted {fittedModel :: model}

{- | The type 'fit' returns for a given frame source: the bare @model@ for an
untyped 'DataFrame', or a schema-tagged 'Fitted' for a 'TypedDataFrame'. Training
on an untyped frame is therefore unchanged.
-}
type family FitResult (f :: Type) (model :: Type) :: Type where
    FitResult DataFrame model = model
    FitResult (TypedDataFrame cols) model = Fitted cols model

{- | The frame a given training @input@ is fit against: an untyped target/feature
input pairs with a plain 'DataFrame'; a typed input ('TExpr' / @['TExpr']@) pairs
with a 'TypedDataFrame' over the /same/ schema. So the target expression and the
frame are forced to share their columns at compile time.
-}
type family FrameFor (input :: Type) :: Type where
    FrameFor (Expr a) = DataFrame
    FrameFor (TExpr cols a) = TypedDataFrame cols
    FrameFor [Expr Double] = DataFrame
    FrameFor [TExpr cols Double] = TypedDataFrame cols

{- | The schema requirement a model places on its training frame. 'AnyFrame'
imposes nothing (the default); 'AllDoubleFrame' demands every column be 'Double'.
-}
data FrameKind = AnyFrame | AllDoubleFrame

{- | Turn a model's 'FrameKind' requirement into a constraint on the actual frame
type. Untyped 'DataFrame's are never constrained (they are runtime-checked); a
typed frame must satisfy the requirement at compile time.
-}
type family CheckFrame (req :: FrameKind) (f :: Type) :: Constraint where
    CheckFrame _ DataFrame = ()
    CheckFrame 'AnyFrame _ = ()
    CheckFrame 'AllDoubleFrame (TypedDataFrame cols) = AllDouble cols

{- | Train a model. @cfg@ is the hyperparameter config; @input@ is the supervised
target @Expr a@ or the unsupervised feature list @[Expr Double]@; the frame is
any 'ToDataFrame' source (an untyped 'DataFrame' or a 'TypedDataFrame'). The
config and input together determine the model, so no annotation is needed (a
classifier's label type comes from its @Expr a@ target).

A model overrides 'FrameReq' to demand a schema shape (e.g. 'AllDoubleFrame'),
which 'fit' enforces at compile time on a typed frame; the default is 'AnyFrame'.

The frame is determined by the @input@ (via 'FrameFor'): an untyped target pairs
with a 'DataFrame' and yields the bare model; a typed target ('TExpr' over @cols@)
pairs with a @TypedDataFrame cols@ and yields a @Fitted cols model@, so 'predict'
gives a typed 'TExpr'. A non-'Double' typed column is then a compile error.
-}
class Fit cfg input where
    {- | The model this config + input trains, computed as a type family so it is
    visible in the instance head (and so the typed-lift instances below can
    forward it) without needing a result annotation at the call site.
    -}
    type ModelOf cfg input :: Type

    type FrameReq cfg input :: FrameKind
    type FrameReq cfg input = 'AnyFrame
    fit ::
        (CheckFrame (FrameReq cfg input) (FrameFor input)) =>
        cfg ->
        input ->
        FrameFor input ->
        FitResult (FrameFor input) (ModelOf cfg input)

{- | Lift any model fittable on an untyped target @Expr a@ to a typed target
@TExpr cols a@ over a @TypedDataFrame cols@, returning a schema-tagged 'Fitted'.
-}
instance (Fit cfg (Expr a)) => Fit cfg (TExpr cols a) where
    type ModelOf cfg (TExpr cols a) = ModelOf cfg (Expr a)
    type FrameReq cfg (TExpr cols a) = FrameReq cfg (Expr a)
    fit cfg (TExpr e) tdf = Fitted (fit cfg e (thaw tdf))

-- | The same lift for the unsupervised feature-list inputs.
instance (Fit cfg [Expr Double]) => Fit cfg [TExpr cols Double] where
    type ModelOf cfg [TExpr cols Double] = ModelOf cfg [Expr Double]
    type FrameReq cfg [TExpr cols Double] = FrameReq cfg [Expr Double]
    fit cfg feats tdf = Fitted (fit cfg (map unTExpr feats) (thaw tdf))

{- | Compile a fitted model's canonical prediction to an expression over the raw
columns. The 'Prediction' type tracks the model: a bare model gives @Expr r@; a
'Fitted' model (trained on a typed frame) gives @TExpr cols r@.
-}
class Predict model where
    type Prediction model :: Type
    predict :: model -> Prediction model

instance (Predict model, ToTExpr cols (Prediction model)) => Predict (Fitted cols model) where
    type Prediction (Fitted cols model) = AsTExpr cols (Prediction model)
    predict (Fitted m) = toTExpr @cols (predict m)