packages feed

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

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}

{- | Feature synthesis by bottom-up enumerative search with observational
equivalence — the canonical enumerative method from Solar-Lezama's
/Introduction to Program Synthesis/, hardened for a numeric, examples-only
setting.

Given a frame and a numeric target column, it searches for a small, interpretable
arithmetic expression over the other columns whose values track the target. The
specification is purely the example rows; there is no SMT solver and no logical
spec. Deterministic and pure.

The engine:

  * enumerates programs by increasing AST size (so the first representative of any
    behaviour is the smallest — interpretability for free);
  * evaluates each candidate /incrementally/ by combining the cached result
    vectors of its subprograms (one vector op), never re-interpreting the whole
    tree;
  * keeps exactly one program per /observational-equivalence/ class — candidates
    producing the same column (up to a float tolerance) are interchangeable, so
    duplicates are dropped rather than re-explored;
  * breaks commutative symmetry (never both @a+b@ and @b+a@) and uses protected
    operators (@sqrt|x|@, @log(|x|+1)@) plus a denominator guard so domain errors
    never arise;
  * caps each size layer by fit score when it grows large (a cost-guided
    tractability bound over /distinct/ behaviours, not a lossy beam over raw
    syntax).

'fit' returns the best 'SynthesizedFeature'; 'predict' is its expression.
'synthesizeFeatures' returns the whole ranked, deduplicated feature bank — useful
as automated feature engineering feeding a downstream model.

Deferred (documented next steps, not yet implemented): skeleton enumeration with
closed-form least-squares coefficient fitting, hard-row counterexample sampling
for very large frames, and piecewise (condition-abduction) features.
-}
module DataFrame.Synthesis (
    module DataFrame.Model,
    LossFunction (..),
    SynthesisConfig (..),
    defaultSynthesisConfig,
    SynthesizedFeature (..),
    synthesizeFeatures,
) where

import Data.Bits (xor)
import Data.Either (fromRight)
import Data.List (sortBy)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe)
import Data.Ord (Down (..), comparing)
import qualified Data.Text as T
import qualified Data.Vector.Unboxed as VU
import Data.Word (Word64)
import GHC.Float (castDoubleToWord64)

import DataFrame.Featurize.Internal (featureNames)
import qualified DataFrame.Functions as F
import DataFrame.Internal.DataFrame (DataFrame)
import DataFrame.Internal.Expression (Expr (..))
import DataFrame.Internal.Statistics (
    meanSquaredError,
    mutualInformationBinned,
    percentile',
    variance',
 )
import DataFrame.Model
import DataFrame.Operations.Core (columnAsDoubleVector)

-- | How a candidate's output column is scored against the target (higher is better).
data LossFunction
    = -- | Pearson @r²@: scale-invariant, the default for derived features.
      PearsonCorrelation
    | -- | Binned mutual information: captures nonlinear association.
      MutualInformation
    | -- | Negative mean squared error: for reproducing a target exactly.
      MeanSquaredError
    deriving (Eq, Show)

-- | Search hyperparameters.
data SynthesisConfig = SynthesisConfig
    { synMaxSize :: !Int
    -- ^ Largest AST (node count) to enumerate.
    , synBankCap :: !Int
    -- ^ Max observationally-distinct programs kept per size layer.
    , synLoss :: !LossFunction
    , synTopK :: !Int
    -- ^ How many ranked features to return in the bank.
    }
    deriving (Eq, Show)

defaultSynthesisConfig :: SynthesisConfig
defaultSynthesisConfig =
    SynthesisConfig
        { synMaxSize = 6
        , synBankCap = 500
        , synLoss = PearsonCorrelation
        , synTopK = 16
        }

{- | A synthesized feature. 'sfExpr' is the best-scoring expression and 'sfFeatures'
is the ranked, observationally-distinct bank (expression and its score).
-}
data SynthesizedFeature = SynthesizedFeature
    { sfExpr :: !(Expr Double)
    , sfScore :: !Double
    , sfFeatures :: ![(Expr Double, Double)]
    }

instance Fit SynthesisConfig (Expr Double) where
    type ModelOf SynthesisConfig (Expr Double) = SynthesizedFeature
    fit = synthesizeFeatures

instance Predict SynthesizedFeature where
    type Prediction SynthesizedFeature = Expr Double
    predict = sfExpr

-- | A candidate's evaluated column over the example rows.
type Output = VU.Vector Double

data Prog = Prog
    { progExpr :: !(Expr Double)
    , progSize :: !Int
    , progOut :: !Output
    }

-- | Search for expressions over the non-target columns that track @target@.
synthesizeFeatures ::
    SynthesisConfig -> Expr Double -> DataFrame -> SynthesizedFeature
synthesizeFeatures cfg target df
    | null leaves || VU.null tgt = SynthesizedFeature (Lit 0) (negate (1 / 0)) []
    | otherwise = SynthesizedFeature best bestScore ranked
  where
    feats = featureNames target df
    tgt = fromRight VU.empty (columnAsDoubleVector target df)
    n = VU.length tgt
    leaves = mkLeaves df feats n
    bank = grow cfg tgt leaves
    scored =
        [ (progExpr p, progSize p, s)
        | p <- bank
        , Just s <- [scoreOf (synLoss cfg) tgt (progOut p)]
        ]
    sorted = sortBy (comparing (\(_, sz, s) -> (Down s, sz))) scored
    ranked = [(e, s) | (e, _, s) <- take (synTopK cfg) sorted]
    (best, bestScore) = case ranked of
        ((e, s) : _) -> (e, s)
        [] -> (Lit 0, negate (1 / 0))

-- | Size-1 programs: numeric feature columns and a pool of constants, OE-deduped.
mkLeaves :: DataFrame -> [T.Text] -> Int -> [Prog]
mkLeaves df feats n = fst (dedupProgs M.empty candidates)
  where
    candidates =
        [ Prog (Col name) 1 o
        | name <- feats
        , Right o <- [columnAsDoubleVector (Col name :: Expr Double) df]
        ]
            ++ [Prog (Lit v) 1 (VU.replicate n v) | v <- constantPool df feats]

{- | Domain-informed constants: per-column quartiles, variance, and std, plus a few
small integers. (Duplicates collapse under observational equivalence.)
-}
constantPool :: DataFrame -> [T.Text] -> [Double]
constantPool df feats =
    [0, 1, 2, -1]
        ++ [ roundSig 2 v
           | name <- feats
           , Right c <- [columnAsDoubleVector (Col name :: Expr Double) df]
           , v <-
                [percentile' p c | p <- [1, 25, 75, 99]] ++ [variance' c, sqrt (variance' c)]
           ]

-- | Grow the bank one size layer at a time, keeping one program per OE class.
grow :: SynthesisConfig -> Output -> [Prog] -> [Prog]
grow cfg tgt leaves = go 2 leaves (foldr (seenInsert . progOut) M.empty leaves)
  where
    go size bank seen
        | size > synMaxSize cfg = bank
        | otherwise =
            let (kept, seen') = absorb cfg tgt seen (layer size bank)
             in go (size + 1) (bank ++ kept) seen'

-- | All candidate programs of exactly @size@ nodes, built from smaller ones.
layer :: Int -> [Prog] -> [Prog]
layer size bank = unaries ++ pows ++ comms ++ subs ++ divs
  where
    atSize s = filter ((== s) . progSize) bank
    args1 = atSize (size - 1)
    unaries =
        [ Prog (mk e) size (VU.map f o)
        | (mk, f) <- unaryProds
        , Prog e _ o <- args1
        ]
    pows =
        [ Prog (F.pow e k) size (VU.map (^ k) o)
        | Prog e _ o <- args1
        , k <- [2 .. 6 :: Int]
        ]
    comms =
        [ Prog (mk ea eb) size (VU.zipWith f oa ob)
        | (mk, f) <- commutativeProds
        , (Prog ea _ oa, Prog eb _ ob) <- unorderedPairs size bank
        ]
    subs =
        [ Prog (ea - eb) size (VU.zipWith (-) oa ob)
        | (Prog ea _ oa, Prog eb _ ob) <- orderedPairs size bank
        ]
    divs =
        [ Prog (ea / eb) size (VU.zipWith (/) oa ob)
        | (Prog ea _ oa, Prog eb _ ob) <- orderedPairs size bank
        , VU.all ((> 1e-9) . abs) ob
        ]

-- | Protected unary operators: total on all reals (no NaN/domain errors).
unaryProds :: [(Expr Double -> Expr Double, Double -> Double)]
unaryProds =
    [ (sqrt . abs, sqrt . abs)
    , (abs, abs)
    , (\e -> log (abs e + 1), \x -> log (abs x + 1))
    , (exp, exp)
    , (sin, sin)
    , (cos, cos)
    , (F.relu, max 0)
    , (signum, signum)
    ]

-- | Commutative binary operators (enumerated over unordered operand pairs).
commutativeProds ::
    [(Expr Double -> Expr Double -> Expr Double, Double -> Double -> Double)]
commutativeProds =
    [ ((+), (+))
    , ((*), (*))
    , (F.min, min)
    , (F.max, max)
    ]

-- | Ordered operand pairs whose sizes sum to @size-1@ (for non-commutative ops).
orderedPairs :: Int -> [Prog] -> [(Prog, Prog)]
orderedPairs size bank =
    [ (a, b)
    | sa <- [1 .. size - 2]
    , let sb = size - 1 - sa
    , sb >= 1
    , a <- atSize sa
    , b <- atSize sb
    ]
  where
    atSize s = filter ((== s) . progSize) bank

-- | Unordered operand pairs (for commutative ops): each pair once.
unorderedPairs :: Int -> [Prog] -> [(Prog, Prog)]
unorderedPairs size bank =
    [ (a, b)
    | sa <- [1 .. size - 2]
    , let sb = size - 1 - sa
    , sb >= 1
    , sa <= sb
    , (i, a) <- zip [0 :: Int ..] (atSize sa)
    , (j, b) <- zip [0 :: Int ..] (atSize sb)
    , sa < sb || i <= j
    ]
  where
    atSize s = filter ((== s) . progSize) bank

{- | Keep the valid, observationally-novel candidates of a layer, then cap by fit
score (cost-guided). Returns the kept programs and the updated OE-class set.
-}
absorb ::
    SynthesisConfig -> Output -> Seen -> [Prog] -> ([Prog], Seen)
absorb cfg tgt seen0 cands = (capLayer cfg tgt fresh, seen')
  where
    (fresh, seen') = dedupProgs seen0 cands

-- | When a layer has more distinct programs than the cap, keep the best-scoring.
capLayer :: SynthesisConfig -> Output -> [Prog] -> [Prog]
capLayer cfg tgt progs
    | length progs <= synBankCap cfg = progs
    | otherwise = take (synBankCap cfg) (sortBy (comparing (Down . rank)) progs)
  where
    rank p = fromMaybe (negate (1 / 0)) (scoreOf (synLoss cfg) tgt (progOut p))

-- | Fit score of an output against the target (higher is better), or @Nothing@.
scoreOf :: LossFunction -> Output -> Output -> Maybe Double
scoreOf lf tgt out
    | VU.length out /= VU.length tgt = Nothing
    | otherwise = finite $ case lf of
        PearsonCorrelation -> pearsonR2 tgt out
        MutualInformation -> mutualInformationBinned bins tgt out
        MeanSquaredError -> negate <$> meanSquaredError tgt out
  where
    bins = max 10 (ceiling (sqrt (fromIntegral (VU.length tgt) :: Double)))
    -- Belt-and-suspenders: drop any non-finite score so it cannot win the ranking.
    finite (Just s) | isNaN s || isInfinite s = Nothing
    finite ms = ms

{- | Pearson @r²@ via the numerically stable centered two-pass formula. Returns
'Nothing' when the feature (or target) is constant, and is bounded by
Cauchy–Schwarz to @[0,1]@ — unlike the one-pass @n·Σxy − Σx·Σy@ form, which
cancels catastrophically for low-variance features and can report @r² > 1@.
-}
pearsonR2 :: Output -> Output -> Maybe Double
pearsonR2 ys xs
    | n < 2 = Nothing
    | sxx <= 0 || syy <= 0 = Nothing
    | otherwise = Just (min 1 (sxy * sxy / (sxx * syy)))
  where
    n = VU.length xs
    nf = fromIntegral n
    mx = VU.sum xs / nf
    my = VU.sum ys / nf
    sxy = VU.sum (VU.zipWith (\x y -> (x - mx) * (y - my)) xs ys)
    sxx = VU.sum (VU.map (\x -> (x - mx) * (x - mx)) xs)
    syy = VU.sum (VU.map (\y -> (y - my) * (y - my)) ys)

-- | An output is usable iff it is non-empty and free of NaN/±Inf.
valid :: Output -> Bool
valid o = not (VU.null o) && VU.all (\x -> not (isNaN x || isInfinite x)) o

{- | Quantize an output to nine significant digits, so float noise (@x*2@ vs
@x+x@) collapses while genuinely distinct features stay apart.
-}
quantize :: Output -> Output
quantize = VU.map (\x -> if x == 0 then 0 else signum x * roundSig 9 (abs x))

{- | The observational-equivalence class set: a map from a 64-bit FNV-1a
fingerprint of the quantized output to the (usually one) quantized outputs with
that fingerprint. Bucketing on the fingerprint keeps membership cheap, and
verifying exact equality within the bucket makes a hash collision harmless — two
genuinely different columns that happen to collide are kept apart, not merged.
-}
type Seen = M.Map Int [Output]

-- | FNV-1a fingerprint of an already-quantized output's bit patterns.
fpOf :: Output -> Int
fpOf = fromIntegral . VU.foldl' step (1469598103934665603 :: Word64)
  where
    step !h x = (h `xor` castDoubleToWord64 x) * 1099511628211

-- | Record an output's observational-equivalence class.
seenInsert :: Output -> Seen -> Seen
seenInsert o = M.insertWith (++) (fpOf q) [q]
  where
    q = quantize o

-- | Round a positive double to @n@ significant digits.
roundSig :: Int -> Double -> Double
roundSig n x
    | x == 0 = 0
    | otherwise =
        let magnitude = floor (logBase 10 (abs x)) :: Int
            scale = 10 ** fromIntegral (n - 1 - magnitude)
         in fromIntegral (round (x * scale) :: Integer) / scale

{- | Keep the first valid program of each observational-equivalence class,
preserving order; returns the kept programs and the grown class set.
-}
dedupProgs :: Seen -> [Prog] -> ([Prog], Seen)
dedupProgs = go []
  where
    go acc s [] = (reverse acc, s)
    go acc s (p : ps)
        | not (valid o) = go acc s ps
        | member = go acc s ps
        | otherwise = go (p : acc) (M.insertWith (++) fp [q] s) ps
      where
        o = progOut p
        q = quantize o
        fp = fpOf q
        member = maybe False (q `elem`) (M.lookup fp s)