dataframe-learn 2.4.2.0 → 2.5.0.0
raw patch · 65 files changed
+1603/−1366 lines, 65 filesdep ~dataframe-coredep ~dataframe-expr-serializerdep ~dataframe-operations
Dependency ranges changed: dataframe-core, dataframe-expr-serializer, dataframe-operations
Files
- README.md +142/−39
- dataframe-learn.cabal +17/−13
- src-internal/DataFrame/DecisionTree/Cart.hs +109/−41
- src-internal/DataFrame/DecisionTree/Categorical.hs +11/−36
- src-internal/DataFrame/DecisionTree/CondVec.hs +12/−36
- src-internal/DataFrame/DecisionTree/Fit.hs +5/−9
- src-internal/DataFrame/DecisionTree/Histogram.hs +329/−0
- src-internal/DataFrame/DecisionTree/Linear.hs +40/−33
- src-internal/DataFrame/DecisionTree/Numeric.hs +4/−17
- src-internal/DataFrame/DecisionTree/Pool.hs +10/−31
- src-internal/DataFrame/DecisionTree/Predict.hs +6/−17
- src-internal/DataFrame/DecisionTree/Prune.hs +3/−11
- src-internal/DataFrame/DecisionTree/Tao.hs +12/−30
- src-internal/DataFrame/DecisionTree/Types.hs +5/−15
- src-internal/DataFrame/Featurize/Internal.hs +7/−25
- src-internal/DataFrame/LinearAlgebra.hs +5/−12
- src-internal/DataFrame/LinearAlgebra/Eigen.hs +3/−11
- src-internal/DataFrame/LinearAlgebra/Solve.hs +4/−14
- src-internal/DataFrame/LinearSolver.hs +24/−86
- src-internal/DataFrame/LinearSolver/Loss.hs +2/−7
- src-internal/DataFrame/Random.hs +5/−16
- src-internal/DataFrame/SymbolicRegression/Expr.hs +2/−7
- src-internal/DataFrame/SymbolicRegression/GP.hs +5/−7
- src-internal/DataFrame/SymbolicRegression/Optimize.hs +1/−4
- src-internal/DataFrame/SymbolicRegression/Simplify.hs +1/−4
- src/DataFrame/Boosting.hs +1/−3
- src/DataFrame/Boosting/AdaBoost.hs +5/−9
- src/DataFrame/Boosting/GBM.hs +97/−40
- src/DataFrame/DBSCAN.hs +7/−16
- src/DataFrame/DecisionTree.hs +2/−7
- src/DataFrame/DecisionTree/Model.hs +13/−13
- src/DataFrame/DecisionTree/Regression.hs +93/−44
- src/DataFrame/GMM.hs +2/−7
- src/DataFrame/KMeans.hs +4/−10
- src/DataFrame/Learn.hs +1/−10
- src/DataFrame/LinearModel.hs +2/−6
- src/DataFrame/LinearModel/Logistic.hs +6/−17
- src/DataFrame/LinearModel/Regression.hs +5/−12
- src/DataFrame/Metrics.hs +46/−52
- src/DataFrame/Metrics/Report.hs +3/−9
- src/DataFrame/Model.hs +10/−72
- src/DataFrame/ModelSelection.hs +40/−24
- src/DataFrame/PCA.hs +4/−11
- src/DataFrame/PCA/Kernel.hs +4/−10
- src/DataFrame/SVM.hs +2/−5
- src/DataFrame/SVM/RFF.hs +2/−8
- src/DataFrame/Segmented.hs +15/−40
- src/DataFrame/SymbolicRegression.hs +2/−8
- src/DataFrame/Synthesis.hs +14/−65
- src/DataFrame/Transform.hs +6/−16
- src/DataFrame/Transform/Serialize.hs +1/−10
- tests-internal/Cart.hs +3/−9
- tests-internal/DataFrameApi.hs +3/−6
- tests-internal/DecisionTree.hs +68/−81
- tests-internal/HistogramTrees.hs +124/−0
- tests-internal/Learn/EdgeCases.hs +56/−93
- tests-internal/Learn/NumericalRigor.hs +17/−77
- tests-internal/Learn/Numerics.hs +0/−1
- tests-internal/Learn/Symbolic.hs +4/−4
- tests-internal/LinearSolver.hs +4/−6
- tests-internal/Main.hs +4/−0
- tests-internal/NullSplits.hs +155/−0
- tests-internal/Properties/Simplify.hs +2/−5
- tests-internal/TreePruning.hs +1/−3
- tests-internal/Worklist.hs +11/−36
README.md view
@@ -1,4 +1,4 @@-<!-- scripths: 0.5.3.0 -->+<!-- scripths: 0.5.5.0 --> <!-- This README is a runnable scripths (https://github.com/DataHaskell/scripths)@@ -33,11 +33,12 @@ -- cabal: default-extensions: OverloadedStrings, TypeApplications, DataKinds, TypeOperators, FlexibleContexts -- cabal: ghc-options: -w import qualified DataFrame as D+import DataFrame ((=:)) import DataFrame.Learn -sales = D.fromNamedColumns- [ ("x", D.fromList ([1, 2, 3, 4, 5, 6] :: [Double]))- , ("y", D.fromList ([2 * x + 1 | x <- [1, 2, 3, 4, 5, 6]] :: [Double]))+sales = D.fromColumns+ [ "x" =: [1, 2, 3, 4, 5, 6] :: [Double]+ , "y" =: [2 * x + 1 | x <- [1, 2, 3, 4, 5, 6]] :: [Double] ] model = fit defaultLinearConfig (D.col @Double "y") sales@@ -45,7 +46,33 @@ ``` > <!-- scripths:mime text/plain -->-> 2.0 * x + 0.9999999999999989+> <interactive>:17:7: error: [GHC-83865]+> • Couldn't match expected type: [Double]+> with actual type: (Data.Text.Internal.Text, D.Column)+> • In the expression: "x" =: [1, 2, 3, ....] :: [Double]+> In the first argument of ‘D.fromColumns’, namely+> ‘["x" =: [1, 2, ....] :: [Double],+> "y" =: [2 * x + 1 | x <- [1, ....]] :: [Double]]’+> In the expression:+> D.fromColumns+> ["x" =: [1, 2, ....] :: [Double],+> "y" =: [2 * x + 1 | x <- [1, ....]] :: [Double]]+> +> <interactive>:18:7: error: [GHC-83865]+> • Couldn't match expected type: [Double]+> with actual type: (Data.Text.Internal.Text, D.Column)+> • In the expression:+> "y" =: [2 * x + 1 | x <- [1, 2, ....]] :: [Double]+> In the first argument of ‘D.fromColumns’, namely+> ‘["x" =: [1, 2, ....] :: [Double],+> "y" =: [2 * x + 1 | x <- [1, ....]] :: [Double]]’+> In the expression:+> D.fromColumns+> ["x" =: [1, 2, ....] :: [Double],+> "y" =: [2 * x + 1 | x <- [1, ....]] :: [Double]]+> +> <interactive>:24:34: error: [GHC-88464]+> Variable not in scope: model ## Type-safe linear regression @@ -67,18 +94,25 @@ ``` > <!-- scripths:mime text/plain -->-> typed model: 2.0 * x + 0.9999999999999989-> schema after: ["x","y","prediction"]+> <interactive>:35:66: error: [GHC-88464]+> Variable not in scope: sales :: D.DataFrame+> Suggested fix: Perhaps use ‘salesT’ (line 35)+> +> <interactive>:42:61: error: [GHC-88464]+> Variable not in scope: typedModel+> +> <interactive>:43:47: error: [GHC-88464]+> Variable not in scope: scored :: T.TypedDataFrame cols1 ## Decision trees The tree compiles to nested `if/then/else` over your columns: ```haskell-flowers = D.fromNamedColumns- [ ("petal_length", D.fromList ([1.4, 1.3, 1.5, 1.4, 4.5, 4.7, 4.6, 4.4, 5.5, 5.8, 5.6, 5.7] :: [Double]))- , ("petal_width", D.fromList ([0.2, 0.2, 0.1, 0.3, 1.5, 1.4, 1.6, 1.3, 2.0, 2.1, 1.9, 2.2] :: [Double]))- , ("species", D.fromList ([0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2] :: [Double]))+flowers = D.fromColumns+ [ "petal_length" =: [1.4, 1.3, 1.5, 1.4, 4.5, 4.7, 4.6, 4.4, 5.5, 5.8, 5.6, 5.7] :: [Double]+ , "petal_width" =: [0.2, 0.2, 0.1, 0.3, 1.5, 1.4, 1.6, 1.3, 2.0, 2.1, 1.9, 2.2] :: [Double]+ , "species" =: [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2] :: [Double] ] tree = fit defaultTreeConfig (D.col @Double "species") flowers@@ -86,11 +120,51 @@ ``` > <!-- scripths:mime text/plain -->-> if petal_length .<=. 2.95-> then 0.0-> else if petal_length .<=. 5.1-> then 1.0-> else 2.0+> <interactive>:52:7: error: [GHC-83865]+> • Couldn't match expected type: [Double]+> with actual type: (Data.Text.Internal.Text, D.Column)+> • In the expression:+> "petal_length" =: [1.4, 1.3, 1.5, ....] :: [Double]+> In the first argument of ‘D.fromColumns’, namely+> ‘["petal_length" =: [1.4, 1.3, ....] :: [Double],+> "petal_width" =: [0.2, 0.2, ....] :: [Double],+> "species" =: [0, 0, ....] :: [Double]]’+> In the expression:+> D.fromColumns+> ["petal_length" =: [1.4, 1.3, ....] :: [Double],+> "petal_width" =: [0.2, 0.2, ....] :: [Double],+> "species" =: [0, 0, ....] :: [Double]]+> +> <interactive>:53:7: error: [GHC-83865]+> • Couldn't match expected type: [Double]+> with actual type: (Data.Text.Internal.Text, D.Column)+> • In the expression:+> "petal_width" =: [0.2, 0.2, 0.1, ....] :: [Double]+> In the first argument of ‘D.fromColumns’, namely+> ‘["petal_length" =: [1.4, 1.3, ....] :: [Double],+> "petal_width" =: [0.2, 0.2, ....] :: [Double],+> "species" =: [0, 0, ....] :: [Double]]’+> In the expression:+> D.fromColumns+> ["petal_length" =: [1.4, 1.3, ....] :: [Double],+> "petal_width" =: [0.2, 0.2, ....] :: [Double],+> "species" =: [0, 0, ....] :: [Double]]+> +> <interactive>:54:7: error: [GHC-83865]+> • Couldn't match expected type: [Double]+> with actual type: (Data.Text.Internal.Text, D.Column)+> • In the expression: "species" =: [0, 0, 0, ....] :: [Double]+> In the first argument of ‘D.fromColumns’, namely+> ‘["petal_length" =: [1.4, 1.3, ....] :: [Double],+> "petal_width" =: [0.2, 0.2, ....] :: [Double],+> "species" =: [0, 0, ....] :: [Double]]’+> In the expression:+> D.fromColumns+> ["petal_length" =: [1.4, 1.3, ....] :: [Double],+> "petal_width" =: [0.2, 0.2, ....] :: [Double],+> "species" =: [0, 0, ....] :: [Double]]+> +> <interactive>:60:34: error: [GHC-88464] Variable not in scope: tree ## Symbolic regression discovers a formula @@ -98,9 +172,9 @@ it as a dataframe `Expr` plus the accuracy/complexity Pareto front: ```haskell-curve = D.fromNamedColumns- [ ("x", D.fromList xs)- , ("y", D.fromList [x * x + x | x <- xs])+curve = D.fromColumns+ [ "x" =: xs+ , "y" =: [x * x + x | x <- xs] ] where xs = [-3, -2, -1, 0, 1, 2, 3, 4, 5, 6] :: [Double] @@ -122,7 +196,12 @@ ``` > <!-- scripths:mime text/plain -->-> ["x","y","prediction"]+> <interactive>:87:47: error: [GHC-88464]+> Variable not in scope: model+> Suggested fix: Perhaps use ‘T.mode’ (imported from DataFrame.Typed)+> +> <interactive>:87:54: error: [GHC-88464]+> Variable not in scope: sales :: D.DataFrame ## A model and its preprocessing compose by substitution @@ -145,8 +224,19 @@ ``` > <!-- scripths:mime text/plain -->-> trained in scaled space: 3.4156502553198655 * x + 8.0-> folded to raw columns: 3.4156502553198655 * (x - 3.5) / 1.707825127659933 + 8.0+> <interactive>:95:36: error: [GHC-88464]+> Variable not in scope: sales :: D.DataFrame+> +> <interactive>:96:55: error: [GHC-88464]+> Variable not in scope: sales :: D.DataFrame+> +> <interactive>:103:61: error: [GHC-88464]+> Variable not in scope: scaledModel+> Suggested fix:+> Perhaps use data constructor ‘ScalerModel’ (imported from DataFrame.Learn)+> +> <interactive>:104:52: error: [GHC-88464]+> Variable not in scope: deployed :: D.Expr a1 The folded expression is a function of the raw `x` alone, so it scores the original frame with no preprocessing step at inference time.@@ -156,17 +246,21 @@ ``` > <!-- scripths:mime text/plain -->-> 3.6259732146947156e-16+> <interactive>:112:15: error: [GHC-88464]+> Variable not in scope: deployed :: D.Expr Double+> +> <interactive>:112:44: error: [GHC-88464]+> Variable not in scope: sales :: D.DataFrame ## Splitting the data, and evaluation ```haskell import qualified DataFrame as D -realistic = D.fromNamedColumns- [ ("id", D.fromList [fromIntegral ((i * 7919) `mod` 97) | i <- [1 .. 40 :: Int]])- , ("x", D.fromList xs)- , ("y", D.fromList [2 * x + 1 + noise i | (i, x) <- zip [0 :: Int ..] xs])+realistic = D.fromColumns+ [ "id" =: [fromIntegral ((i * 7919) `mod` 97) | i <- [1 .. 40 :: Int]]+ , "x" =: xs+ , "y" =: [2 * x + 1 + noise i | (i, x) <- zip [0 :: Int ..] xs] ] where xs = map fromIntegral [1 .. 40 :: Int] :: [Double]@@ -224,14 +318,13 @@ ``` > <!-- scripths:mime text/plain -->-> class precision recall f1 support -> 0.0 1.0 1.0 1.0 4 -> 1.0 1.0 1.0 1.0 4 -> 2.0 1.0 1.0 1.0 4 +> <interactive>:170:59: error: [GHC-88464]+> Variable not in scope: flowers :: D.DataFrame > -> accuracy = 1.0-> macro f1 = 1.0-> weighted f1 = 1.0+> <interactive>:173:49: error: [GHC-88464] Variable not in scope: clf+> +> <interactive>:173:80: error: [GHC-88464]+> Variable not in scope: flowers :: D.DataFrame ## Pipelines compose as a monoid @@ -249,7 +342,17 @@ ``` > <!-- scripths:mime text/plain -->-> ["petal_length","petal_width","species","pc1","pc2"]+> <interactive>:182:36: error: [GHC-88464]+> Variable not in scope: flowers :: D.DataFrame+> +> <interactive>:183:74: error: [GHC-88464]+> Variable not in scope: flowers :: D.DataFrame+> +> <interactive>:188:31: error: [GHC-88464]+> Variable not in scope: pipeline :: Transform+> +> <interactive>:188:40: error: [GHC-88464]+> Variable not in scope: flowers :: D.DataFrame ## Synthesize the feature you would have hand-engineered @@ -261,10 +364,10 @@ to exact — still a formula you can read: ```haskell-interactions = D.fromNamedColumns- [ ("a", D.fromList as)- , ("b", D.fromList bs)- , ("y", D.fromList (zipWith (*) as bs))+interactions = D.fromColumns+ [ "a" =: as+ , "b" =: bs+ , "y" =: zipWith (*) as bs ] where as = [-1, -1, 1, 1, -2, 2, -2, 2] :: [Double]
dataframe-learn.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: dataframe-learn-version: 2.4.2.0+version: 2.5.0.0 synopsis: Interpretable, expression-returning machine learning for the dataframe ecosystem. description: A small scikit-learn-style ML library where every model returns both an@@ -45,6 +45,7 @@ DataFrame.DecisionTree.Pool DataFrame.DecisionTree.Linear DataFrame.DecisionTree.Tao+ DataFrame.DecisionTree.Histogram DataFrame.DecisionTree.Fit DataFrame.LinearSolver DataFrame.LinearSolver.Loss@@ -61,8 +62,8 @@ containers >= 0.6.7 && < 0.10, parallel >= 3.3 && < 4, random >= 1.2 && < 2,- dataframe-core >= 2.5 && < 2.6,- dataframe-operations >= 2.5 && < 2.6,+ dataframe-core >= 2.6 && < 2.7,+ dataframe-operations >= 2.6 && < 2.7, text >= 2.1 && < 3, vector >= 0.13 && < 0.15, vector-algorithms >= 0.9 && < 0.11@@ -105,14 +106,15 @@ containers >= 0.6.7 && < 0.10, parallel >= 3.3 && < 4, random >= 1.2 && < 2,- dataframe-core >= 2.5 && < 2.6,- dataframe-core >= 2.5 && < 2.6,- dataframe-operations >= 2.5 && < 2.6,- dataframe-operations >= 2.5 && < 2.6,- dataframe-expr-serializer >= 1.2.1 && < 1.3,+ dataframe-core >= 2.6 && < 2.7,+ dataframe-core >= 2.6 && < 2.7,+ dataframe-operations >= 2.6 && < 2.7,+ dataframe-operations >= 2.6 && < 2.7,+ dataframe-expr-serializer >= 1.3 && < 1.4, dataframe-learn:internal, text >= 2.1 && < 3,- vector >= 0.13 && < 0.15+ vector >= 0.13 && < 0.15,+ vector-algorithms >= 0.9 && < 0.11 hs-source-dirs: src default-language: Haskell2010 @@ -125,6 +127,8 @@ main-is: Main.hs other-modules: DataFrameApi Cart+ NullSplits+ HistogramTrees DecisionTree TreePruning Worklist@@ -140,13 +144,13 @@ aeson >= 0.11.0.0 && < 3, bytestring >= 0.11 && < 0.14, containers >= 0.6.7 && < 0.10,- dataframe-core >= 2.5 && < 2.6,- dataframe-core >= 2.5 && < 2.6,+ dataframe-core >= 2.6 && < 2.7,+ dataframe-core >= 2.6 && < 2.7, dataframe-csv >= 2.3 && < 2.4, dataframe-learn, dataframe-learn:internal,- dataframe-operations >= 2.5 && < 2.6,- dataframe-operations >= 2.5 && < 2.6,+ dataframe-operations >= 2.6 && < 2.7,+ dataframe-operations >= 2.6 && < 2.7, HUnit >= 1.6 && < 1.8, QuickCheck >= 2 && < 3, random >= 1 && < 2,
src-internal/DataFrame/DecisionTree/Cart.hs view
@@ -4,19 +4,20 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | sklearn-faithful CART initializer used to seed TAO. One-hot encodes-categoricals and splits on exact (unsmoothed) Gini over midpoint thresholds-(@<=@ routes left), matching @DecisionTreeClassifier(criterion='gini')@.--} module DataFrame.DecisionTree.Cart ( CartFeature (..),+ NullSide (..),+ cfPred,+ splitMidpoint, CartNode (..), sortIndicesByValue, buildCartTree, cartFeatures,+ cartFeaturesByColumn, cartTargetLabels, ) where +import qualified Data.List as L import DataFrame.DecisionTree.Types (Tree (..), TreeConfig (..)) import DataFrame.Errors (DataFrameException (..), TypeErrorContext (..)) import DataFrame.Expression.Operators@@ -35,7 +36,6 @@ import Control.Exception (throw) import Data.Either (fromRight) import Data.Function (on)-import Data.List (foldl') import qualified Data.Map.Strict as M import qualified Data.Set as Set import qualified Data.Text as T@@ -45,14 +45,29 @@ import qualified Data.Vector.Unboxed as VU import Type.Reflection (TypeRep, typeRep) -{- | A one-hot feature column: per-row Double values plus the sklearn LEFT-predicate (@x <= threshold@) over the ORIGINAL DataFrame.--}+-- | A one-hot feature column with its sklearn LEFT predicate. data CartFeature = CartFeature { cfValues :: !(VU.Vector Double)- , cfPred :: !(Double -> Expr Bool)+ -- ^ One value per row. Null rows hold @+Infinity@, so they sort last.+ , cfNulls :: !(Maybe (VU.Vector Bool))+ -- ^ Which rows are null; 'Nothing' if the training data has no nulls here.+ , cfSplit :: !(Double -> NullSide -> Expr Bool)+ -- ^ The split expression, sending null rows to the given side. } +data NullSide = NullsLeft | NullsRight+ deriving (Eq, Show)++-- | The split expression with null rows sent right.+cfPred :: CartFeature -> Double -> Expr Bool+cfPred f t = cfSplit f t NullsRight++-- | The threshold between two adjacent sorted values, keeping nulls right.+splitMidpoint :: Double -> Double -> Double+splitMidpoint lo hi+ | isInfinite hi = lo+ | otherwise = (lo + hi) / 2+ -- | Pre-'Tree' CART node: a leaf class id, or a split on feature @j@. data CartNode = CLeaf !Int | CSplit !Int !Double !CartNode !CartNode @@ -66,9 +81,7 @@ , ctxMinLeaf :: !Int } -{- | Indices @0..n-1@ stably sorted by their value (ascending), ties keeping-ascending index. In-place unboxed merge sort — no boxed-list allocation.--}+-- | Indices 0..n-1 stably sorted by value, via in-place merge sort. sortIndicesByValue :: VU.Vector Double -> VU.Vector Int sortIndicesByValue vs = VU.create $ do@@ -95,10 +108,7 @@ (maxTreeDepth cfg) (max 1 (minLeafSize cfg)) -{- | Read the target column at the type the tree is being fitted at. Names the-column and both types on failure: a bare @fromIntegral@ defaults to 'Integer'-and lands here, and the old message said only that something went wrong.--}+-- | Read the target column at the fitted type, naming both types on failure. cartLabels :: forall a. (Columnable a) => DataFrame -> T.Text -> V.Vector a cartLabels df target = case interpret @a df (Col target) of Right (TColumn column) -> fromRight (throw err) (toVector @a column)@@ -170,9 +180,7 @@ rightSorted = V.map (VU.filter (\i -> vals VU.! i > thr)) sortedByFeat rec = buildCartNode ctx (depth + 1) -{- | Minimum weighted-child-Gini @(feature, threshold)@; the first feature wins-ties; 'Nothing' when no feature has a leaf-size-respecting threshold.--}+-- | Minimum weighted-child-Gini split; the first feature wins ties. bestSplit :: CartCtx -> V.Vector (VU.Vector Int) ->@@ -180,16 +188,14 @@ Int -> Maybe (Int, Double) bestSplit ctx sortedByFeat counts n =- fmap (\(_, j, t) -> (j, t)) (foldl' consider Nothing [0 .. ctxNFeats ctx - 1])+ fmap (\(_, j, t) -> (j, t)) (L.foldl' consider Nothing [0 .. ctxNFeats ctx - 1]) where total = VU.toList counts consider acc fj = case sweepFeature ctx total (sortedByFeat V.! fj) (ctxFeats ctx V.! fj) n of Just (g, thr) | maybe True (\(gB, _, _) -> g < gB) acc -> Just (g, fj, thr) _ -> acc -{- | Accumulator while sweeping a feature's sorted rows: best @(gini, thr)@ so-far, per-class left counts, rows moved left, and the previous value seen.--}+-- | Accumulator while sweeping a feature's sorted rows. data Sweep = Sweep { swBest :: !(Maybe (Double, Double)) , swLeft :: ![Int]@@ -206,7 +212,7 @@ Maybe (Double, Double) sweepFeature ctx total si feat n = swBest- ( foldl'+ ( L.foldl' step (Sweep Nothing (replicate (ctxNClasses ctx) 0) 0 (0 / 0)) [0 .. VU.length si - 1]@@ -234,7 +240,7 @@ keepBetter (swBest s) (weightedGini total (swLeft s) (swMoved s) n)- ((swPrev s + v) / 2)+ (splitMidpoint (swPrev s) v) | otherwise = swBest s keepBetter ::@@ -263,40 +269,102 @@ -- | One-hot features in @pd.get_dummies(drop_first=False)@ column order. cartFeatures :: T.Text -> DataFrame -> [CartFeature]-cartFeatures target df = concatMap (featuresOfColumn df) (filter (/= target) (columnNames df))+cartFeatures target df = map snd (cartFeaturesByColumn target df) +-- | 'cartFeatures', each paired with the column it was built from.+cartFeaturesByColumn :: T.Text -> DataFrame -> [(T.Text, CartFeature)]+cartFeaturesByColumn target df =+ [(c, f) | c <- filter (/= target) (columnNames df), f <- featuresOfColumn df c]+ featuresOfColumn :: DataFrame -> T.Text -> [CartFeature] featuresOfColumn df c = case unsafeGetColumn c df of- UnboxedColumn _ (v :: VU.Vector b) -> numericFeature @b c v- BoxedColumn _ (v :: V.Vector b) -> oneHotFeatures @b (nRows df) c v+ UnboxedColumn bm (v :: VU.Vector b) -> numericFeature @b c bm v+ BoxedColumn bm (v :: V.Vector b) -> oneHotFeatures @b c bm v pt@(PackedText _ _) -> case materializePacked pt of- BoxedColumn _ (v :: V.Vector b) -> oneHotFeatures @b (nRows df) c v+ BoxedColumn bm (v :: V.Vector b) -> oneHotFeatures @b c bm v _ -> [] mc@(MergedColumn _ _) -> case materializeMerged mc of- BoxedColumn _ (v :: V.Vector b) -> oneHotFeatures @b (nRows df) c v+ BoxedColumn bm (v :: V.Vector b) -> oneHotFeatures @b c bm v _ -> [] +nullFlags :: Int -> Bitmap -> VU.Vector Bool+nullFlags n bm = VU.generate n (not . bitmapTestBit bm)++-- | Nothing if no row is null, so null-free columns split the plain way.+trainingNulls :: VU.Vector Bool -> Maybe (VU.Vector Bool)+trainingNulls nulls+ | VU.or nulls = Just nulls+ | otherwise = Nothing+ numericFeature ::- forall b. (Columnable b, VU.Unbox b) => T.Text -> VU.Vector b -> [CartFeature]-numericFeature c v = case testEquality (typeRep @b) (typeRep @Double) of- Just Refl -> [CartFeature v (\t -> F.col @Double c .<=. F.lit t)]+ forall b.+ (Columnable b, VU.Unbox b) =>+ T.Text -> Maybe Bitmap -> VU.Vector b -> [CartFeature]+numericFeature c Nothing v = case testEquality (typeRep @b) (typeRep @Double) of+ Just Refl -> [CartFeature v Nothing (\t _ -> F.col @Double c .<=. F.lit t)] Nothing -> case sIntegral @b of STrue ->- [ CartFeature (VU.map fromIntegral v) (\t -> F.toDouble (F.col @b c) .<=. F.lit t)+ [ CartFeature+ (VU.map fromIntegral v)+ Nothing+ (\t _ -> F.toDouble (F.col @b c) .<=. F.lit t) ] SFalse -> []+numericFeature c (Just bm) v = case nullableLeq @b c of+ Just (toD, leq) ->+ [ CartFeature+ (VU.imap (\i x -> if nulls VU.! i then 1 / 0 else toD x) v)+ (trainingNulls nulls)+ (\t side -> F.fromMaybe (side == NullsLeft) (leq t))+ ]+ Nothing -> []+ where+ nulls = nullFlags (VU.length v) bm +nullableLeq ::+ forall b.+ (Columnable b) =>+ T.Text -> Maybe (b -> Double, Double -> Expr (Maybe Bool))+nullableLeq c+ | Just Refl <- testEquality (typeRep @b) (typeRep @Double) =+ Just (id, \t -> F.col @(Maybe Double) c .<= F.lit t)+ | Just Refl <- testEquality (typeRep @b) (typeRep @Int) =+ Just (fromIntegral, \t -> F.col @(Maybe Int) c .<= F.lit t)+ | otherwise = Nothing+ oneHotFeatures ::- forall b. (Columnable b) => Int -> T.Text -> V.Vector b -> [CartFeature]-oneHotFeatures nAll c v = case testEquality (typeRep @b) (typeRep @T.Text) of- Just Refl -> [oneHot nAll c v cat | cat <- Set.toList (Set.fromList (V.toList v))]+ forall b.+ (Columnable b) => T.Text -> Maybe Bitmap -> V.Vector b -> [CartFeature]+oneHotFeatures c bm v = case testEquality (typeRep @b) (typeRep @T.Text) of+ Just Refl -> [oneHot c nulls v cat | cat <- Set.toList (Set.fromList present)] Nothing -> []+ where+ nulls = fmap (nullFlags (V.length v)) bm+ present = [x | (i, x) <- zip [0 ..] (V.toList v), maybe True (not . (VU.! i)) nulls] -oneHot :: Int -> T.Text -> V.Vector T.Text -> T.Text -> CartFeature-oneHot nAll c v cat =+oneHot ::+ T.Text -> Maybe (VU.Vector Bool) -> V.Vector T.Text -> T.Text -> CartFeature+oneHot c Nothing v cat = CartFeature- (VU.generate nAll (\i -> if v V.! i == cat then 1 else 0))- (const (F.col @T.Text c ./=. F.lit cat))+ (VU.generate (V.length v) (\i -> if v V.! i == cat then 1 else 0))+ Nothing+ (\_ _ -> F.col @T.Text c ./=. F.lit cat)+oneHot c (Just nulls) v cat =+ CartFeature+ (VU.generate (V.length v) value)+ (trainingNulls nulls)+ cond+ where+ value i+ | nulls VU.! i = 1 / 0+ | v V.! i == cat = 1+ | otherwise = 0+ col' = F.col @(Maybe T.Text) c+ -- Threshold 1 passes every non-null row, so that split is null vs non-null.+ cond t side+ | t < 1 = F.fromMaybe (side == NullsLeft) (col' ./= F.lit cat)+ | side == NullsLeft = F.lit True+ | otherwise = F.isJust col' -- | Target column as string labels (matches pandas @y.astype(str)@). cartTargetLabels :: T.Text -> DataFrame -> V.Vector T.Text
src-internal/DataFrame/DecisionTree/Categorical.hs view
@@ -5,10 +5,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | Categorical split candidates: Breiman prefixes for binary targets,-subset/singleton enumeration otherwise, and cross-column equality. Each-value-list becomes an OR-of-equalities condition.--}+-- | Categorical split candidates: Breiman prefixes, subsets and equality. module DataFrame.DecisionTree.Categorical ( TargetInfo (..), mkTargetInfo,@@ -37,7 +34,6 @@ ) import DataFrame.Expression.Operators import DataFrame.Internal.Column-import DataFrame.Internal.Column.Bitmap import DataFrame.Internal.DataFrame (DataFrame, columnNames, unsafeGetColumn) import DataFrame.Internal.Expression (Expr (..)) import DataFrame.Internal.Interpreter (interpret)@@ -58,18 +54,14 @@ validBoxedValues :: Bitmap -> V.Vector a -> V.Vector a validBoxedValues bm = V.ifilter (\i _ -> bitmapTestBit bm i) -{- | Target-column summary driving the categorical generator: binary vs-multi-class, the deterministic positive class, and the raw label vector.--}+-- | Target-column summary driving the categorical generator. data TargetInfo target = TargetInfo { tiIsBinary :: !Bool , tiPositiveClass :: !(Maybe target) , tiValues :: !(V.Vector target) } -{- | Compute 'TargetInfo' once per fit. The positive class for binary targets-is the lexicographically-first distinct value, for deterministic pools.--}+-- | Compute TargetInfo once per fit. mkTargetInfo :: forall target. (Columnable target, Ord target) =>@@ -88,9 +80,7 @@ (p : _) | isBinary -> Just p _ -> Nothing -{- | Distinct values, capped: @Right vs@ (sorted) under the cap, else @Left@-the count-so-far so the caller routes to the high-cardinality path.--}+-- | Distinct values up to a cap, else the count so far. distinctValuesUpTo :: (Ord a) => Int -> V.Vector a -> Either Int [a] distinctValuesUpTo cap values = go Set.empty 0 where@@ -100,9 +90,7 @@ | Set.size s > cap = Left (Set.size s) | otherwise = go (Set.insert (V.unsafeIndex values i) s) (i + 1) -{- | OR-of-equalities for a value-list, shared by the expression and-truth-vector discrete paths so they stay byte-identical.--}+-- | OR-of-equalities for a value-list, shared by both discrete paths. orEqs :: (a -> Expr Bool) -> [a] -> Expr Bool orEqs eqLit = foldr1 (.||.) . map eqLit @@ -130,9 +118,7 @@ breimanPrefixSplits pc values targetVals distinctVals eqLit = map (orEqs eqLit) (breimanPrefixLists pc values targetVals distinctVals) -{- | Breiman's binary-target split set: sort levels by Laplace-smoothed-positive rate, then take every contiguous non-trivial prefix.--}+-- | Breiman's binary-target split set: contiguous prefixes by positive rate. breimanPrefixLists :: (Ord a, Ord target) => target -> V.Vector a -> V.Vector target -> [a] -> [[a]] breimanPrefixLists pc values targetVals distinctVals =@@ -158,9 +144,7 @@ nonTrivialPrefixes :: [a] -> [[a]] nonTrivialPrefixes = drop 1 . init . inits -{- | Value-lists a categorical column contributes; shared by the expression and-truth-vector paths so both enumerate identical candidates in the same order.--}+-- | Value-lists a categorical column contributes, in a fixed order. catValueLists :: (Ord a, Ord target) => Bool -> Maybe target -> V.Vector target -> Int -> V.Vector a -> [[a]]@@ -186,17 +170,13 @@ ascDistinct :: (Ord a) => V.Vector a -> [a] ascDistinct = Set.toAscList . Set.fromList . V.toList -{- | Truth vector of @col ∈ values@ read directly from the column; equal to-interpreting @orEqs (== v) values@ because the values are distinct.--}+-- | Truth vector of col in values, read directly from the column. membershipVec :: (Ord a) => V.Vector a -> [a] -> VU.Vector Bool membershipVec colVals vs = let !s = Set.fromList vs in VU.generate (V.length colVals) (\i -> Set.member (colVals `V.unsafeIndex` i) s) -{- | Per-fit categorical generation context bundling the target summary and-the column-ordering registry.--}+-- | Per-fit categorical generation context. data CatCtx target = CatCtx { ccBinary :: !Bool , ccPos :: !(Maybe target)@@ -225,9 +205,7 @@ STrue -> True SFalse -> False -{- | All equality-based candidate splits from non-numeric columns: per-column-categorical conditions plus cross-column equality/order conditions.--}+-- | All equality-based candidate splits from non-numeric columns. discreteConditions :: forall target. (Columnable target, Ord target) =>@@ -344,10 +322,7 @@ Just Refl -> True Nothing -> False -{- | 'discreteConditions' materialized with shared per-column reads: the-non-nullable categorical path builds truth vectors directly from one read-per column; nullable and cross-column fall back to interpret.--}+-- | discreteConditions materialized with shared per-column reads. discreteCondVecs :: forall target. (Columnable target, Ord target) =>
src-internal/DataFrame/DecisionTree/CondVec.hs view
@@ -3,10 +3,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | Cached condition truth vectors and the per-fit cache keyed by structural-form. A condition's truth over a fixed DataFrame is invariant for a whole-fit, so it is materialized once and reused.--}+-- | Cached condition truth vectors and the per-fit cache keyed by form. module DataFrame.DecisionTree.CondVec ( CondVec (..), materializeCondVec,@@ -48,9 +45,7 @@ , cvVec :: !(VU.Vector Bool) } -{- | Interpret a condition once over the DataFrame; 'Nothing' on a-type/interpret failure so the candidate is silently dropped.--}+-- | Interpret a condition once over the DataFrame; Nothing on failure. materializeCondVec :: DataFrame -> Expr Bool -> Maybe CondVec materializeCondVec df cond = case interpret @Bool df cond of Left _ -> Nothing@@ -59,15 +54,10 @@ eitherToMaybe :: Either e a -> Maybe a eitherToMaybe = either (const Nothing) Just -{- | Full-DataFrame truth vectors keyed by structural form, read-only once-built. Seeded for free from the candidate pool plus the initial tree so the-predict/loss passes index a vector instead of re-interpreting per node.--}+-- | Truth vectors keyed by structural form, read-only once built. type CondCache = M.Map T.Text (VU.Vector Bool) -{- | Structural key matching the candidate-dedup key, so a tree branch whose-condition came from the pool hits the cache (equal keys ⟹ equal vector).--}+-- | Structural key matching the candidate-dedup key. condCacheKey :: Expr Bool -> T.Text condCacheKey = T.pack . show . normalize @@ -75,9 +65,7 @@ condCacheFromVecs :: [CondVec] -> CondCache condCacheFromVecs cvs = M.fromList [(condCacheKey (cvExpr cv), cvVec cv) | cv <- cvs] -{- | Add a tree's branch-condition vectors to a cache (one interpret per-distinct, not-yet-cached condition).--}+-- | Add a tree's branch-condition vectors to a cache. addTreeCondsToCache :: DataFrame -> Tree a -> CondCache -> CondCache addTreeCondsToCache df = go where@@ -92,15 +80,13 @@ where k = condCacheKey cond -{- | A condition's truth vector: a cache hit, else interpret over the-DataFrame. 'Nothing' mirrors the interpret-failure fallback (route left).--}+-- | A condition's truth vector: a cache hit, else interpret. lookupCondVec :: CondCache -> DataFrame -> Expr Bool -> Maybe (VU.Vector Bool) lookupCondVec cache df cond = case M.lookup (condCacheKey cond) cache of hit@(Just _) -> hit Nothing -> cvVec <$> materializeCondVec df cond --- | Partition row indices by a truth vector: @True@ → left, @False@ → right.+-- | Partition row indices by a truth vector: True goes left. partitionByVec :: VU.Vector Bool -> V.Vector Int -> (V.Vector Int, V.Vector Int) partitionByVec boolVals = V.partition (boolVals VU.!) @@ -110,9 +96,7 @@ where misrouted cp = (boolVals VU.! cpIndex cp) /= (cpCorrectDir cp == GoLeft) -{- | A same-column same-direction Double threshold comparison, with a rebuild-function to swap in a new threshold.--}+-- | A same-column same-direction Double threshold comparison. data ThreshCmp = ThreshCmp { tcCol :: !T.Text , tcName :: !T.Text@@ -132,9 +116,7 @@ directionalNames :: [T.Text] directionalNames = ["lt", "leq", "gt", "geq"] -{- | Tighter (AND) or looser (OR) of two same-direction thresholds: @<@/@<=@-are left-half-spaces (AND = min), @>@/@>=@ are right-half-spaces (AND = max).--}+-- | Tighter (AND) or looser (OR) of two same-direction thresholds. chooseThreshold :: Bool -> T.Text -> Double -> Double -> Double chooseThreshold isAnd name t1 t2 | leftDir = if isAnd then min t1 t2 else max t1 t2@@ -142,9 +124,7 @@ where leftDir = name == "lt" || name == "leq" -{- | Collapse two same-column same-direction strict-Double comparisons into one-comparison (the @True@ argument selects AND, @False@ OR); 'Nothing' otherwise.--}+-- | Collapse two same-direction Double comparisons into one, if possible. consolidateThreshold :: Bool -> Expr Bool -> Expr Bool -> Maybe (Expr Bool) consolidateThreshold isAnd ea eb = do a <- asDoubleThreshold ea@@ -153,9 +133,7 @@ then Just (tcRebuild a (chooseThreshold isAnd (tcName a) (tcThr a) (tcThr b))) else Nothing -{- | AND-combine two cached conditions: idempotence and threshold consolidation-first, else the generic @F.and@; the vector is always the elementwise AND.--}+-- | AND-combine two cached conditions, consolidating thresholds if possible. combineAndVec :: CondVec -> CondVec -> CondVec combineAndVec a b | eqExpr (cvExpr a) (cvExpr b) = a@@ -166,9 +144,7 @@ (F.and (cvExpr a) (cvExpr b)) (consolidateThreshold True (cvExpr a) (cvExpr b)) -{- | OR-combine two cached conditions (see 'combineAndVec'; AND/OR direction-differs in 'consolidateThreshold').--}+-- | OR-combine two cached conditions (see combineAndVec). combineOrVec :: CondVec -> CondVec -> CondVec combineOrVec a b | eqExpr (cvExpr a) (cvExpr b) = a
src-internal/DataFrame/DecisionTree/Fit.hs view
@@ -4,10 +4,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | Top-level fitting: assemble the candidate pool, seed from CART, run TAO,-and convert the result to an expression. Also the probability-tree variant-('fitProbTree') that annotates leaves with class distributions.--}+-- | Top-level fitting: candidate pool, CART seed, TAO, then an expression. module DataFrame.DecisionTree.Fit ( treeToExpr, fitDecisionTree,@@ -25,6 +22,7 @@ probExprs, ) where +import qualified Data.List as L import DataFrame.DecisionTree.Cart (buildCartTree) import DataFrame.DecisionTree.Categorical ( TargetInfo (..),@@ -50,7 +48,7 @@ import Control.Exception (throw) import Data.Function (on)-import Data.List (foldl', maximumBy, nub, sort)+import Data.List (maximumBy, nub, sort) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import qualified Data.Text as T@@ -130,7 +128,7 @@ Left e -> throw e Right (TColumn column) -> case toVector @a column of Left e -> throw e- Right vals -> foldl' (\acc x -> M.insertWith (+) x 1 acc) M.empty (V.toList vals)+ Right vals -> L.foldl' (\acc x -> M.insertWith (+) x 1 acc) M.empty (V.toList vals) -- | The @p@-th percentile of an expression's values (@0@ on failure/empty). percentile :: Int -> Expr Double -> DataFrame -> Double@@ -167,9 +165,7 @@ indices total = fromIntegral (V.length indices) :: Double -{- | Re-label a fitted tree's leaves with class distributions, routing the-training data through the (unchanged) split conditions.--}+-- | Re-label a fitted tree's leaves with class distributions. buildProbTree :: forall a. (Columnable a, Ord a) =>
+ src-internal/DataFrame/DecisionTree/Histogram.hs view
@@ -0,0 +1,329 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE TupleSections #-}++-- | Regression trees grown from per-bin histograms, for gradient boosting.+module DataFrame.DecisionTree.Histogram (+ Binned,+ BinnedFeatures,+ binFeatures,+ TreeLimits (..),+ fitBinnedTree,+ fitBinnedTreeOn,+) where++import Control.Monad.ST (runST)+import Control.Parallel (par, pseq)+import Data.Maybe (fromMaybe, maybeToList)+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import qualified Data.Vector.Unboxed.Mutable as VUM+import Data.Word (Word16)++import DataFrame.DecisionTree.Cart (+ CartFeature (..),+ NullSide (..),+ sortIndicesByValue,+ splitMidpoint,+ )+import DataFrame.DecisionTree.Types (Tree (..))+import DataFrame.Internal.Expression (Expr)++-- | A feature's bins: rows in bin @b@ satisfy @value <= upper ! b@.+data Binned = Binned+ { bnFeature :: !CartFeature+ , bnBins :: !(VU.Vector Word16)+ -- ^ Bin per row; null rows hold 'bnNBins'.+ , bnNBins :: !Int+ , bnUpper :: !(VU.Vector Double)+ }++-- | Every feature's bins, also stored row by row for cache locality.+data BinnedFeatures = BinnedFeatures+ { bfFeatures :: !(V.Vector Binned)+ , bfRows :: !(VU.Vector Word16)+ , bfOffsets :: !(VU.Vector Int)+ -- ^ Where each feature's histogram starts in a node's histogram buffer.+ , bfSlots :: !Int+ }++-- | Bin every feature into at most @maxBins@ bins (capped at 65535).+binFeatures :: Int -> V.Vector CartFeature -> BinnedFeatures+binFeatures maxBins features = BinnedFeatures binned rows offsets (VU.last sizes)+ where+ binned = V.map (binFeature (max 2 (min 65535 maxBins))) features+ nf = V.length binned+ n = if nf == 0 then 0 else VU.length (bnBins (V.head binned))+ sizes =+ VU.prescanl'+ (+)+ 0+ (VU.fromList [4 * (bnNBins b + 1) | b <- V.toList binned] `VU.snoc` 0)+ offsets = VU.take nf sizes+ rows = VU.create $ do+ m <- VUM.unsafeNew (n * nf)+ V.iforM_ binned $ \j b ->+ VU.iforM_ (bnBins b) $ \i bin -> VUM.unsafeWrite m (i * nf + j) bin+ pure m++binFeature :: Int -> CartFeature -> Binned+binFeature maxBins f = Binned f bins nb upper+ where+ vals = cfValues f+ isNull i = maybe False (VU.! i) (cfNulls f)+ sorted =+ VU.map (vals VU.!) (VU.filter (not . isNull) (sortIndicesByValue vals))+ runs = runLengths sorted+ distinct = map fst runs+ edges+ | length runs <= maxBins =+ VU.fromList (zipWith splitMidpoint distinct (drop 1 distinct))+ | otherwise = VU.fromList (cutEdges maxBins (VU.length sorted) runs)+ nb = VU.length edges + 1+ -- Top threshold is the largest value, so null vs non-null stays valid.+ upper = VU.snoc edges (if VU.null sorted then 0 else VU.last sorted)+ bins = VU.generate (VU.length vals) binOf+ binOf i+ | isNull i = fromIntegral nb+ | otherwise = fromIntegral (lowerBound edges (vals VU.! i))++-- | Cut points giving bins of roughly equal row counts, never inside a run.+cutEdges :: Int -> Int -> [(Double, Int)] -> [Double]+cutEdges maxBins n = go 0 0+ where+ perBin = fromIntegral n / fromIntegral maxBins :: Double+ go :: Int -> Int -> [(Double, Int)] -> [Double]+ go !nCuts !acc ((v, c) : rest@((v', _) : _))+ | nCuts >= maxBins - 1 = []+ | fromIntegral (acc + c) >= perBin = splitMidpoint v v' : go (nCuts + 1) 0 rest+ | otherwise = go nCuts (acc + c) rest+ go _ _ _ = []++runLengths :: VU.Vector Double -> [(Double, Int)]+runLengths v = go 0+ where+ n = VU.length v+ go i+ | i >= n = []+ | otherwise =+ let x = v VU.! i+ j = until (\k -> k >= n || v VU.! k /= x) (+ 1) i+ in (x, j - i) : go j++-- | Number of edges below @x@: the bin @x@ falls in.+lowerBound :: VU.Vector Double -> Double -> Int+lowerBound edges x = go 0 (VU.length edges)+ where+ go lo hi+ | lo >= hi = lo+ | VU.unsafeIndex edges m < x = go (m + 1) hi+ | otherwise = go lo m+ where+ m = (lo + hi) `div` 2++-- | The stopping rules of 'DataFrame.DecisionTree.Regression.RegTreeConfig'.+data TreeLimits = TreeLimits+ { tlMaxDepth :: !Int+ , tlMinSamplesSplit :: !Int+ , tlMinLeafSize :: !Int+ , tlMinImpurityDecrease :: !Double+ , tlL2 :: !Double+ -- ^ Added to every node's total weight, shrinking leaf values and gains.+ , tlMinChildWeight :: !Double+ -- ^ Smallest total weight a child may have.+ }++-- | Per-bin weight, weighted-y sums and row count, four slots per bin.+type Hist = VU.Vector Double++-- | Every feature's histogram for a node, built in one pass over its rows.+buildHists ::+ BinnedFeatures ->+ VU.Vector Int ->+ VU.Vector Double ->+ VU.Vector Double ->+ VU.Vector Int ->+ V.Vector Hist+buildHists bf allowed w y idxs = V.imap slice (bfFeatures bf)+ where+ nf = V.length (bfFeatures bf)+ na = VU.length allowed+ slice j b = VU.slice (VU.unsafeIndex (bfOffsets bf) j) (4 * (bnNBins b + 1)) flat+ flat = runST $ do+ m <- VUM.replicate (bfSlots bf) 0+ VU.forM_ idxs $ \i -> do+ let wi = VU.unsafeIndex w i+ yi = VU.unsafeIndex y i+ wy = wi * yi+ wyy = wy * yi+ row = i * nf+ go !k+ | k >= na = pure ()+ | otherwise = do+ let j = VU.unsafeIndex allowed k+ bin = fromIntegral (VU.unsafeIndex (bfRows bf) (row + j))+ s = VU.unsafeIndex (bfOffsets bf) j + 4 * bin+ VUM.unsafeModify m (+ wi) s+ VUM.unsafeModify m (+ wy) (s + 1)+ VUM.unsafeModify m (+ wyy) (s + 2)+ VUM.unsafeModify m (+ 1) (s + 3)+ go (k + 1)+ go 0+ VU.unsafeFreeze m++data Node+ = NLeaf !Double !(VU.Vector Int)+ | NBranch !(Expr Bool) !Node !Node++-- | Fit a weighted-SSE regression tree on binned features.+fitBinnedTree ::+ TreeLimits ->+ BinnedFeatures ->+ VU.Vector Double ->+ Maybe (VU.Vector Double) ->+ (Tree Double, VU.Vector Double)+fitBinnedTree lim bf = fitBinnedTreeOn (VU.enumFromN 0 (V.length (bfFeatures bf))) lim bf++-- | 'fitBinnedTree' splitting only on the features at the given indices.+fitBinnedTreeOn ::+ VU.Vector Int ->+ TreeLimits ->+ BinnedFeatures ->+ VU.Vector Double ->+ Maybe (VU.Vector Double) ->+ (Tree Double, VU.Vector Double)+fitBinnedTreeOn allowed lim bf y mw = (toTree root, inSample)+ where+ binned = bfFeatures bf+ n = VU.length y+ w = Data.Maybe.fromMaybe (VU.replicate n 1) mw+ allIdx = VU.enumFromN 0 n+ root = node 0 allIdx (hists allIdx)+ inSample = VU.update (VU.replicate n 0) (VU.concat (leafRows root))+ leafRows (NLeaf v idxs) = [VU.map (,v) idxs]+ leafRows (NBranch _ l r) = leafRows l ++ leafRows r++ hists = buildHists bf allowed w y+ -- Every allowed feature's histogram covers all of a node's rows.+ totalsOf hs = histTotals (hs V.! VU.head allowed)++ node depth idxs hs+ | depth >= tlMaxDepth lim || VU.length idxs < tlMinSamplesSplit lim || V.null hs =+ leaf+ | otherwise = maybe leaf split (bestSplit lim binned allowed hs)+ where+ Totals tw tsy _ _ = totalsOf hs+ leaf = NLeaf (if tw + tlL2 lim == 0 then 0 else tsy / (tw + tlL2 lim)) idxs+ split (fj, b, nullLeft) =+ forceNode l `par` (forceNode r `pseq` NBranch cond l r)+ where+ bn = binned V.! fj+ goesLeft i =+ let k = fromIntegral (VU.unsafeIndex (bnBins bn) i)+ in if k == bnNBins bn then nullLeft else k <= b+ lefts = VU.filter goesLeft idxs+ rights = VU.filter (not . goesLeft) idxs+ -- Histogram the smaller child; the larger is parent minus it.+ smallIsLeft = VU.length lefts <= VU.length rights+ smallH = hists (if smallIsLeft then lefts else rights)+ bigH = V.zipWith (VU.zipWith (-)) hs smallH+ (lh, rh) = if smallIsLeft then (smallH, bigH) else (bigH, smallH)+ l = node (depth + 1) lefts lh+ r = node (depth + 1) rights rh+ side = if nullLeft then NullsLeft else NullsRight+ cond = cfSplit (bnFeature bn) (bnUpper bn VU.! b) side++data Totals = Totals !Double !Double !Double !Double++histTotals :: Hist -> Totals+histTotals h = go 0 0 0 0 0+ where+ slots = VU.length h `div` 4+ go !k !a !b !c !d+ | k >= slots = Totals a b c d+ | otherwise =+ go+ (k + 1)+ (a + h VU.! (4 * k))+ (b + h VU.! (4 * k + 1))+ (c + h VU.! (4 * k + 2))+ (d + h VU.! (4 * k + 3))++-- | Best (feature, bin, nulls left) by SSE reduction.+bestSplit ::+ TreeLimits ->+ V.Vector Binned ->+ VU.Vector Int ->+ V.Vector Hist ->+ Maybe (Int, Int, Bool)+bestSplit lim binned allowed hs+ | null candidates = Nothing+ | red > 0 && red >= tlMinImpurityDecrease lim = Just sp+ | otherwise = Nothing+ where+ Totals totW totSY totSY2 totC = histTotals (hs V.! VU.head allowed)+ nNode = round totC :: Int+ sse = penalisedSse (tlL2 lim)+ nodeSSE = sse totSY totSY2 totW+ candidates = concat [featBest fj (hs V.! fj) | fj <- VU.toList allowed]+ (red, sp) = maximumByFst candidates+ featBest fj h = [(r, (fj, b, nl)) | (b, nl, r) <- maybeToList (scan 0 0 0 0 0 Nothing)]+ where+ nb = bnNBins (binned V.! fj)+ nW = h VU.! (4 * nb)+ nSY = h VU.! (4 * nb + 1)+ nSY2 = h VU.! (4 * nb + 2)+ nC = round (h VU.! (4 * nb + 3)) :: Int+ hasNulls = nC > 0+ lastB = if hasNulls then nb - 1 else nb - 2+ score nl wl syl syl2+ | nl >= tlMinLeafSize lim+ , nNode - nl >= tlMinLeafSize lim+ , wl > 0+ , wr > 0+ , wl >= tlMinChildWeight lim+ , wr >= tlMinChildWeight lim =+ Just (nodeSSE - (sse syl syl2 wl + sse (totSY - syl) (totSY2 - syl2) wr))+ | otherwise = Nothing+ where+ wr = totW - wl+ scan ::+ Int ->+ Double ->+ Double ->+ Double ->+ Int ->+ Maybe (Int, Bool, Double) ->+ Maybe (Int, Bool, Double)+ scan !b !wl !syl !syl2 !cl best+ | b > lastB = best+ | otherwise = scan (b + 1) wl' syl' syl2' cl' best'+ where+ wl' = wl + h VU.! (4 * b)+ syl' = syl + h VU.! (4 * b + 1)+ syl2' = syl2 + h VU.! (4 * b + 2)+ cl' = cl + round (h VU.! (4 * b + 3))+ right = (,) False <$> score cl' wl' syl' syl2'+ left+ | hasNulls =+ (,) True <$> score (cl' + nC) (wl' + nW) (syl' + nSY) (syl2' + nSY2)+ | otherwise = Nothing+ consider bst (dir, r)+ | maybe True (\(_, _, rb) -> r > rb) bst = Just (b, dir, r)+ | otherwise = bst+ best' = foldl consider best (maybeToList right ++ maybeToList left)++-- | Weighted SSE of a node, with l2 added to the weight.+penalisedSse :: Double -> Double -> Double -> Double -> Double+penalisedSse l2 sumY sumSq wt = sumSq - (if wt + l2 == 0 then 0 else sumY * sumY / (wt + l2))++maximumByFst :: (Ord a) => [(a, b)] -> (a, b)+maximumByFst = foldr1 (\x@(a, _) y@(b, _) -> if a >= b then x else y)++toTree :: Node -> Tree Double+toTree (NLeaf v _) = Leaf v+toTree (NBranch c l r) = Branch c (toTree l) (toTree r)++-- | Force a subtree so the sibling's spark has real work (cf. 'Regression').+forceNode :: Node -> ()+forceNode (NLeaf v _) = v `seq` ()+forceNode (NBranch _ l r) = forceNode l `seq` forceNode r
src-internal/DataFrame/DecisionTree/Linear.hs view
@@ -1,10 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeApplications #-} -{- | Oblique split candidates: fit an L1-regularised logistic hyperplane to the-care points (class-balanced) and convert it to a boolean condition, rejecting-all-zero and degenerate (single-side) hyperplanes.--}+-- | Oblique split candidates from an L1-regularised logistic hyperplane. module DataFrame.DecisionTree.Linear ( bestLinearCandidate, fitLinearCandidate,@@ -21,6 +18,8 @@ Direction (..), TreeConfig (..), )+import DataFrame.Expression.Operators ((.*.), (.+.), (.>.))+import qualified DataFrame.Functions as F import DataFrame.Internal.Column (TypedColumn (..), toVector) import DataFrame.Internal.DataFrame (DataFrame) import DataFrame.Internal.Expression (Expr, getColumns)@@ -32,10 +31,7 @@ import qualified Data.Vector as V import qualified Data.Vector.Unboxed as VU -{- | Best oblique candidate, or 'Nothing' when the linear path is disabled or-there are too few care points to fit on. The target column is named so it can-be kept out of the feature set.--}+-- | Best oblique candidate, or Nothing when disabled or too few care points. bestLinearCandidate :: TreeConfig -> T.Text -> DataFrame -> [CarePoint] -> Maybe (Expr Bool) bestLinearCandidate cfg target df carePoints@@ -43,32 +39,53 @@ | length carePoints < minCarePointsForLinear cfg = Nothing | otherwise = fitLinearCandidate cfg target df carePoints -{- | Fit an L1 logistic regression to the care points and convert the resulting-hyperplane to a condition, or 'Nothing' when no numeric features exist or the-fitted model is all-zero or degenerate.--}+-- | Fit an L1 logistic regression to the care points, as a condition. fitLinearCandidate :: TreeConfig -> T.Text -> DataFrame -> [CarePoint] -> Maybe (Expr Bool) fitLinearCandidate cfg target df carePoints = case materializedFeatures target df carePoints of [] -> Nothing- mats -> linearFromFeatures cfg carePoints mats+ feats -> linearFromFeatures cfg carePoints feats +-- | Each feature's materialised care values with the expression behind them. materializedFeatures ::- T.Text -> DataFrame -> [CarePoint] -> [(T.Text, VU.Vector Double)]+ T.Text ->+ DataFrame ->+ [CarePoint] ->+ [(Expr Double, (T.Text, VU.Vector Double))] materializedFeatures target df carePoints =- mapMaybe (materializeFeatureForCare df carePoints) (featureCols target df)+ mapMaybe+ ( \ne ->+ (\m -> (doubleExprOf ne (snd m), m))+ <$> materializeFeatureForCare df carePoints ne+ )+ (featureCols target df) +-- | The expression behind a feature; nullable ones take the care-point mean.+doubleExprOf :: NumExpr -> VU.Vector Double -> Expr Double+doubleExprOf (NDouble e) _ = e+doubleExprOf (NMaybeDouble e) vals = F.fromMaybe (VU.sum vals / fromIntegral (max 1 (VU.length vals))) e+ featureCols :: T.Text -> DataFrame -> [NumExpr] featureCols target df = filter (notElem target . numExprCols) (numericCols df) linearFromFeatures ::- TreeConfig -> [CarePoint] -> [(T.Text, VU.Vector Double)] -> Maybe (Expr Bool)-linearFromFeatures cfg carePoints mats+ TreeConfig ->+ [CarePoint] ->+ [(Expr Double, (T.Text, VU.Vector Double))] ->+ Maybe (Expr Bool)+linearFromFeatures cfg carePoints feats | VU.all (== 0) weights = Nothing | degenerateHyperplane rows weights (LS.lmIntercept model) = Nothing- | otherwise = Just (LS.modelToExpr model)+ | otherwise = Just hyperplane where+ mats = map snd feats+ hyperplane =+ foldl+ (\acc (w, e) -> acc .+. (F.lit w .*. e))+ (F.lit (LS.lmIntercept model))+ [(w, e) | (w, (e, _)) <- zip (VU.toList weights) feats, w /= 0]+ .>. F.lit (0 :: Double) rows = careRowsFromFeatures (length carePoints) mats labels = careLabels carePoints model =@@ -82,9 +99,7 @@ solverConfigFor :: TreeConfig -> VU.Vector Double -> LS.SolverConfig solverConfigFor cfg labels = (linearSolverConfig cfg){LS.scSampleWeights = classBalancedWeights labels} -{- | Class-balanced sklearn-form weights @w_i = N / (2 · N_class)@ (mean 1), or-'Nothing' in the degenerate one-class case (uniform weighting).--}+-- | Class-balanced sklearn-form weights, or Nothing for one class. classBalancedWeights :: VU.Vector Double -> Maybe (VU.Vector Double) classBalancedWeights labels | nPos > 0 && nNeg > 0 = Just (VU.generate nCare weightAt)@@ -97,9 +112,7 @@ | VU.unsafeIndex labels i > 0 = fromIntegral nCare / (2 * fromIntegral nPos) | otherwise = fromIntegral nCare / (2 * fromIntegral nNeg) -{- | A hyperplane is degenerate when every care row scores on the same side of-zero (equivalent to an invalid split, caught upstream).--}+-- | A hyperplane is degenerate when every care row scores on one side. degenerateHyperplane :: V.Vector (VU.Vector Double) -> VU.Vector Double -> Double -> Bool degenerateHyperplane rows weights bias =@@ -111,9 +124,7 @@ nCare (\i -> VU.sum (VU.zipWith (*) weights (V.unsafeIndex rows i)) + bias) -{- | Per-care-point feature rows from materialized columns (each of length-@nCare@, so indexing is in range).--}+-- | Per-care-point feature rows from materialized columns. careRowsFromFeatures :: Int -> [(T.Text, VU.Vector Double)] -> V.Vector (VU.Vector Double) careRowsFromFeatures nCare mats =@@ -133,9 +144,7 @@ (c : _) -> c [] -> "<feat>" -{- | Replace missing values with the mean of present ones; 'Nothing' when-nothing is present so the caller can drop the feature.--}+-- | Replace missing values with the mean of present ones. imputeMean :: [Maybe Double] -> Maybe (VU.Vector Double) imputeMean careRaw = case catMaybes careRaw of [] -> Nothing@@ -154,9 +163,7 @@ Right (TColumn column) -> either (const Nothing) Just (toVector @(Maybe Double) column) _ -> Nothing -{- | Materialize a 'NumExpr' over the care rows; 'Nothing' on interpret failure-or (nullable) when no care point has a present value, else mean-imputed.--}+-- | Materialize a NumExpr over the care rows, mean-imputing nullable values. materializeFeatureForCare :: DataFrame -> [CarePoint] -> NumExpr -> Maybe (T.Text, VU.Vector Double) materializeFeatureForCare df carePoints (NDouble expr) = do
src-internal/DataFrame/DecisionTree/Numeric.hs view
@@ -5,12 +5,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | Numeric split candidates: per-column Double expressions, arithmetic-expansion, and threshold conditions. Nullable columns also contribute an-@isNothing@ candidate, so missingness is splittable directly rather than-only via the null-routing of value splits. 'numericCondVecs' materializes-the pool with one interpret per distinct expression.--}+-- | Numeric split candidates: expressions, arithmetic expansion, thresholds. module DataFrame.DecisionTree.Numeric ( NumExpr (..), numExprCols,@@ -133,9 +128,7 @@ (F.fromMaybe False) [e .<= F.lit t, e .>= F.lit t, e .< F.lit t, e .> F.lit t] -{- | Percentile thresholds for a value list: sort once, index each percentile.-Shared by 'generateNumericConds' and 'numericCondVecs' for identical results.--}+-- | Percentile thresholds for a value list: sort once, index each percentile. percentilesOf :: [Int] -> [Double] -> [Double] percentilesOf ps valsList | n == 0 = []@@ -155,10 +148,7 @@ Right (TColumn column) -> either (const Nothing) Just (toVector @(Maybe Double) column) _ -> Nothing -{- | Materialize the numeric pool with one interpret per distinct expression,-deriving each threshold/operator truth vector by direct comparison.-Byte-identical to materializing 'numericConditions' one at a time.--}+-- | Materialize the numeric pool with one interpret per distinct expression. numericCondVecs :: TreeConfig -> DataFrame -> DataFrame -> [CondVec] numericCondVecs cfg dfGen df = concatMap forExpr (numericExprsWithTerms (synthConfig cfg) dfGen)@@ -199,10 +189,7 @@ where gen p = VU.generate n (\i -> maybe False p (mvals V.! i)) -{- | Arithmetic candidate expansion, generated already-deduped: each round-combines @frontier × base@ and admits only normalized-novel candidates.-Produces @base@ plus @maxExprDepth-1@ combination rounds.--}+-- | Arithmetic candidate expansion, generated already-deduped. numericExprsWithTerms :: SynthConfig -> DataFrame -> [NumExpr] numericExprsWithTerms cfg df | not (enableArithOps cfg) = base
src-internal/DataFrame/DecisionTree/Pool.hs view
@@ -1,10 +1,7 @@ {-# LANGUAGE BangPatterns #-} {-# LANGUAGE OverloadedStrings #-} -{- | Candidate-pool scoring and boolean expansion: penalized scoring, diverse-top-K selection, AND/OR saturation, and structural/truth-vector dedup. The-per-node scoring scans run in parallel chunks.--}+-- | Candidate-pool scoring and boolean expansion with structural dedup. module DataFrame.DecisionTree.Pool ( evalWithPenaltyVec, primaryColExpr,@@ -50,18 +47,14 @@ import qualified Data.Text as T import qualified Data.Vector.Unboxed as VU -{- | Penalized score of a candidate: care-point errors plus a complexity-penalty, tie-broken by expression size.--}+-- | Penalized score of a candidate: errors plus a complexity penalty. evalWithPenaltyVec :: TreeConfig -> [CarePoint] -> CondVec -> (Int, Int) evalWithPenaltyVec cfg carePoints cv = (countErrorsByVec (cvVec cv) carePoints + penalty, sz) where sz = eSize (cvExpr cv) penalty = floor (complexityPenalty (synthConfig cfg) * fromIntegral sz) -{- | First referenced column of a condition (a sentinel for literal-only ones),-used by 'takeDiverse' to enforce per-column diversity.--}+-- | First referenced column of a condition, used for per-column diversity. primaryColExpr :: Expr Bool -> T.Text primaryColExpr e = case getColumns e of [] -> "<noncol>"@@ -70,9 +63,7 @@ primaryColCV :: CondVec -> T.Text primaryColCV = primaryColExpr . cvExpr -{- | Keep the first @k@ of an already-sorted list, admitting at most @quota@ per-primary column (@Nothing@ disables the per-column cap).--}+-- | Keep the first k of a sorted list, at most quota per primary column. takeDiverse :: Int -> Maybe Int -> (a -> T.Text) -> [a] -> [a] takeDiverse k Nothing _ = take k takeDiverse k (Just quota) primary = go M.empty 0@@ -85,15 +76,11 @@ where !col = primary x -{- | Chunk size for the parallel per-node candidate scans; tuned by an -N-sweep, not correctness-affecting.--}+-- | Chunk size for the parallel per-node candidate scans. candidateParChunk :: Int candidateParChunk = 64 -{- | Decorate candidates with their penalty in parallel chunks, forcing only-the @(Int, Int)@ key so the order (hence later sorts/minima) is preserved.--}+-- | Decorate candidates with their penalty in parallel, preserving order. decorate :: (CondVec -> (Int, Int)) -> [CondVec] -> [((Int, Int), CondVec)] decorate penaltyCV xs = zip (map penaltyCV xs `using` parListChunk candidateParChunk rdeepseq) xs @@ -123,9 +110,7 @@ [] -> Nothing xs -> Just (snd (minimumBy (compare `on` fst) (decorate penaltyCV xs))) -{- | AND/OR expansion of cached conditions to depth @maxDepth@ (each-combination is a single vector op, not an interpret).--}+-- | AND/OR expansion of cached conditions to a given depth. boolExprsVec :: [CondVec] -> [CondVec] -> Int -> Int -> [CondVec] boolExprsVec baseExprs prevExprs depth maxDepth | depth == 0 =@@ -138,9 +123,7 @@ data DedupMode = Structural | TruthVector deriving (Eq, Show) -{- | Saturate the pool with AND/OR combinations, deduplicating structurally-(byte-identical, first occurrence kept) or by truth vector (opt-in).--}+-- | Saturate the pool with AND/OR combinations, deduplicating as configured. saturateCandidates :: DedupMode -> Int -> [CondVec] -> [CondVec] saturateCandidates Structural maxDepth base = base' ++ go 1 base' seen0 where@@ -159,9 +142,7 @@ let (reps', admitted) = admitVecs reps (roundProducts frontier base) in go (depth + 1) admitted reps' -{- | One combination round: @frontier × base@ via AND then OR, skipping-self-pairs (mirrors 'boolExprsVec' for byte-identical structural output).--}+-- | One combination round: frontier by base via AND then OR, skipping pairs. roundProducts :: [CondVec] -> [CondVec] -> [CondVec] roundProducts frontier base = [ c@@ -183,9 +164,7 @@ structuralKey :: CondVec -> String structuralKey = show . normalize . cvExpr -{- | Admit candidates by distinct truth vector, keeping the smallest-expression-representative per vector.--}+-- | Admit candidates by distinct truth vector, keeping the smallest expression. admitVecs :: M.Map (VU.Vector Bool) CondVec -> [CondVec] ->
src-internal/DataFrame/DecisionTree/Predict.hs view
@@ -3,10 +3,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | Prediction, care-point identification, node validity, and tree loss. The-batched, cache-aware variants resolve each branch condition's truth vector-once per call instead of once per row.--}+-- | Prediction, care-point identification, node validity, and tree loss. module DataFrame.DecisionTree.Predict ( predictWithTree, predictManyWithTree,@@ -49,9 +46,7 @@ import qualified Data.Vector.Mutable as VM import qualified Data.Vector.Unboxed as VU -{- | A condition's truth vector over the DataFrame, or 'Nothing' on a-type/interpret failure (callers default such rows to the left child).--}+-- | A condition's truth vector over the DataFrame, or Nothing on failure. branchBool :: DataFrame -> Expr Bool -> Maybe (VU.Vector Bool) branchBool df cond = case interpret @Bool df cond of Right (TColumn column) -> either (const Nothing) Just (toVector @Bool @VU.Vector column)@@ -64,7 +59,7 @@ Right (TColumn column) -> either (const Nothing) Just (toVector @a column) _ -> Nothing --- | Predict the label for a single row by walking a fixed tree (@True@ → left).+-- | Predict the label for a single row by walking a fixed tree. predictWithTree :: forall a. (Columnable a) => T.Text -> DataFrame -> Int -> Tree a -> a predictWithTree _ _ _ (Leaf v) = v@@ -80,9 +75,7 @@ forall a. (Columnable a) => Tree a -> DataFrame -> V.Vector Int -> V.Vector a predictManyWithTree = predictManyWithTreeCached @a M.empty -{- | 'predictManyWithTree' resolving each branch condition through a 'CondCache'.-Each condition is read at most once per call rather than once per row.--}+-- | predictManyWithTree resolving each branch through a CondCache. predictManyWithTreeCached :: forall a. (Columnable a) => CondCache -> Tree a -> DataFrame -> V.Vector Int -> V.Vector a@@ -111,9 +104,7 @@ T.Text -> DataFrame -> V.Vector Int -> Tree a -> Tree a -> [CarePoint] identifyCarePoints = identifyCarePointsCached @a M.empty -{- | Rows the parent must route to a specific child for the (fixed) subtrees to-classify correctly; a 'CondCache' avoids re-interpreting subtree conditions.--}+-- | Rows the parent must route to a specific child for subtrees to classify. identifyCarePointsCached :: forall a. (Columnable a) =>@@ -151,9 +142,7 @@ Expr Bool -> DataFrame -> V.Vector Int -> (V.Vector Int, V.Vector Int) partitionIndices = partitionIndicesCached M.empty -{- | 'partitionIndices' resolving the condition through a 'CondCache'; a miss-routes every index left (matching the uncached fallback).--}+-- | partitionIndices resolving the condition through a CondCache. partitionIndicesCached :: CondCache -> Expr Bool ->
src-internal/DataFrame/DecisionTree/Prune.hs view
@@ -2,10 +2,7 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE ScopedTypeVariables #-} -{- | Post-convergence simplification of a fitted tree and its expression form:-drop branches forced by path-condition entailment, collapse identical-siblings, and fold redundant nested conditionals.--}+-- | Post-convergence simplification of a fitted tree and its expression. module DataFrame.DecisionTree.Prune ( pruneDead, treeEq,@@ -22,10 +19,7 @@ factTrue, ) -{- | Drop branches whose test is forced by the path conditions reaching them,-and collapse @Branch c t t@ to @t@. Sound for the decidable threshold subset;-other tests are left untouched.--}+-- | Drop branches whose test is forced by the path conditions reaching them. pruneDead :: forall a. (Columnable a) => Tree a -> Tree a pruneDead = go [] where@@ -54,9 +48,7 @@ treeEq (Branch c1 l1 r1) (Branch c2 l2 r2) = eqExpr c1 c2 && treeEq l1 l2 && treeEq r1 r2 treeEq _ _ = False -{- | Recursively fold @If@ expressions whose branches coincide or nest the same-condition; leave other expressions structurally unchanged.--}+-- | Fold If expressions whose branches coincide or nest the same condition. pruneExpr :: forall a. (Columnable a) => Expr a -> Expr a pruneExpr (If cond t0 f0) = collapseIf cond (pruneExpr t0) (pruneExpr f0) pruneExpr (Unary op e) = Unary op (pruneExpr e)
src-internal/DataFrame/DecisionTree/Tao.hs view
@@ -3,10 +3,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | Tree Alternating Optimization: hold the tree fixed and re-optimize one node-at a time, bottom-up, minimizing care-point misroutes. Sibling subtrees at a-depth level are independent and optimized in parallel.--}+-- | Tree Alternating Optimization: re-optimize one node at a time, bottom-up. module DataFrame.DecisionTree.Tao ( taoOptimize, taoOptimizeCV,@@ -16,6 +13,7 @@ findBestSplitTAO, ) where +import qualified Data.List as L import DataFrame.DecisionTree.CondVec import DataFrame.DecisionTree.Linear (bestLinearCandidate) import DataFrame.DecisionTree.Pool (@@ -33,15 +31,13 @@ import Control.Parallel (par, pseq) import Control.Parallel.Strategies (parListChunk, rdeepseq, using) import Data.Function (on)-import Data.List (foldl', minimumBy)+import Data.List (minimumBy) import Data.Maybe (catMaybes, mapMaybe) import qualified Data.Text as T import qualified Data.Vector as V import qualified Data.Vector.Unboxed as VU -{- | The constant per-fit context threaded through the node-optimization-recursion (the cache is rebuilt each iteration).--}+-- | The constant per-fit context threaded through node optimization. data TaoEnv = TaoEnv { teCache :: !CondCache , teCfg :: !TreeConfig@@ -64,9 +60,7 @@ taoOptimize cfg target conds df = taoOptimizeCV @a cfg target (mapMaybe (materializeCondVec df) conds) df -{- | TAO outer loop over pre-evaluated candidates: iterate until the iteration-budget or convergence tolerance is reached, then prune dead branches.--}+-- | TAO outer loop over pre-evaluated candidates, then prune dead branches. taoOptimizeCV :: forall a. (Columnable a, Ord a) =>@@ -120,7 +114,7 @@ Tree a -> Tree a taoIterationCV cache cfg target condVecs df rootIndices tree =- foldl'+ L.foldl' (optimizeDepthLevel env rootIndices) tree [treeDepth tree, treeDepth tree - 1 .. 0]@@ -142,9 +136,7 @@ Leaf v -> Leaf v Branch cond left right -> optimizeChildren @a env indices cond left right currentDepth targetDepth -{- | Optimize the two subtrees over their disjoint index sets, scoring the left-in parallel with the right (the cache is read-only, so this is pure).--}+-- | Optimize the two subtrees over their disjoint index sets in parallel. optimizeChildren :: forall a. (Columnable a, Ord a) =>@@ -156,16 +148,12 @@ left' = optimizeAtDepth @a env indicesL left (currentDepth + 1) targetDepth right' = optimizeAtDepth @a env indicesR right (currentDepth + 1) targetDepth -{- | Force a subtree's optimization work to WHNF so the parallel scheduler has-something substantial to evaluate; pure and value-preserving.--}+-- | Force a subtree's optimization work to WHNF for the parallel scheduler. forceTreeWork :: Tree a -> () forceTreeWork (Leaf v) = v `seq` () forceTreeWork (Branch c l r) = c `seq` forceTreeWork l `seq` forceTreeWork r -{- | Re-optimize one node: pick its best split, or collapse to a leaf when the-node is empty or the chosen split underflows 'minLeafSize'.--}+-- | Re-optimize one node: pick its best split, or collapse to a leaf. optimizeNode :: forall a. (Columnable a, Ord a) => TaoEnv -> V.Vector Int -> Tree a -> Tree a optimizeNode env indices tree@@ -188,9 +176,7 @@ (l, r) = partitionIndicesCached (teCache env) newCond (teDf env) indices underflows = V.length l < minLeafSize (teCfg env) || V.length r < minLeafSize (teCfg env) -{- | The lowest-penalty replacement condition for a node, falling back to the-current condition when no valid candidate beats it.--}+-- | The lowest-penalty replacement condition for a node. findBestSplitTAO :: forall a. (Columnable a) =>@@ -227,9 +213,7 @@ bestOfPool _ currentCond [] = currentCond bestOfPool penaltyCV _ pool = cvExpr (minimumBy (compare `on` penaltyCV) pool) -{- | Validity-filtered candidates the node could split on: both children must-keep at least 'minLeafSize'. Scored in parallel chunks, order preserved.--}+-- | Candidates valid for a node: both children keep the minimum leaf size. filterValidCandidates :: TreeConfig -> V.Vector Int -> [CondVec] -> [CondVec] filterValidCandidates cfg indices condVecs = map snd (filter fst (zip validity condVecs)) where@@ -247,9 +231,7 @@ (0 :: Int) indices -{- | The candidate pool to minimize over: the current condition, the best-discrete candidate, and the linear candidate, each kept only if valid.--}+-- | The candidate pool to minimize over, each kept only if valid. candidatePool :: TaoEnv -> V.Vector Int ->
src-internal/DataFrame/DecisionTree/Types.hs view
@@ -5,9 +5,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | Shared types, configuration and ordering machinery for the decision-tree-learner. Imported by every other @DataFrame.DecisionTree.*@ module.--}+-- | Shared types, configuration and ordering machinery for the tree learner. module DataFrame.DecisionTree.Types ( Tree (..), treeDepth,@@ -39,9 +37,7 @@ import System.IO.Unsafe (unsafePerformIO) import Type.Reflection (SomeTypeRep (..), typeRep) -{- | A fitted tree: a leaf value, or an internal node testing a boolean-expression with @True@ routing left.--}+-- | A fitted tree: a leaf value, or a boolean test with True routing left. data Tree a = Leaf !a | Branch !(Expr Bool) !(Tree a) !(Tree a)@@ -51,9 +47,7 @@ treeDepth (Leaf _) = 0 treeDepth (Branch _ l r) = 1 + max (treeDepth l) (treeDepth r) -{- | A row the parent node must route to a specific child for the subtrees to-classify it correctly (the TAO objective is the count of misroutes).--}+-- | A row the parent must route to a specific child (TAO counts misroutes). data CarePoint = CarePoint { cpIndex :: !Int , cpCorrectDir :: !Direction@@ -124,9 +118,7 @@ , pureReplacementLinear = False } -{- | Which column types support ordering for splits. Register a type with-'orderable' and combine with @<>@.--}+-- | Which column types support ordering for splits. newtype ColumnOrdering = ColumnOrdering (M.Map SomeTypeRep OrdDict) instance Semigroup ColumnOrdering where@@ -168,9 +160,7 @@ data OrdDict where OrdDict :: (Columnable a, Ord a) => Proxy a -> OrdDict -{- | Run @k@ with the @Ord a@ instance recovered from the ordering registry,-or 'Nothing' when @a@ is not registered.--}+-- | Run a continuation with the Ord instance from the registry, if present. withOrdFrom :: forall a r. (Columnable a) => ColumnOrdering -> ((Ord a) => r) -> Maybe r withOrdFrom (ColumnOrdering m) k = case M.lookup (SomeTypeRep (typeRep @a)) m of
src-internal/DataFrame/Featurize/Internal.hs view
@@ -3,10 +3,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | Shared internal helpers used across the model fitters: turning a 'DataFrame'-plus a target/feature 'Expr' into the numeric matrices the algorithms consume,-plus common expression builders (affine score, arg-max/arg-min over scores).--}+-- | Shared helpers turning a DataFrame into the matrices the fitters consume. module DataFrame.Featurize.Internal ( -- * Supervised extraction featureNames,@@ -59,19 +56,13 @@ featureNames (Col target) df = filter (/= target) (columnNames df) featureNames _ df = columnNames df -{- | The named columns as a row-major @n×d@ matrix, paired with the names. Every-column must already be stored as non-null 'Double'; a nullable ('Maybe Double')-or non-'Double' column is a fit-time error rather than a silent coercion.--}+-- | The named columns as a row-major matrix, paired with the names. numericMatrix :: [T.Text] -> DataFrame -> (V.Vector T.Text, Matrix) numericMatrix names df = (V.fromList names, transposeM colMajor) where colMajor = V.fromList (map (`requireDoubleColumn` df) names) -{- | Read a feature\/target column strictly as a non-null 'Double' vector. A-nullable ('Maybe Double') column, or a column of any other type, is a fit-time-error naming the column and pointing at the fix.--}+-- | Read a column strictly as a non-null Double vector. requireDoubleColumn :: T.Text -> DataFrame -> VU.Vector Double requireDoubleColumn name df = case getColumn name df of Nothing ->@@ -131,9 +122,7 @@ TypeErrorContext Double () ) -{- | The target column as a non-null 'Double' vector. A nullable or non-'Double'-target is a fit-time error, not a coercion.--}+-- | The target column as a non-null Double vector. targetDoubles :: Expr Double -> DataFrame -> VU.Vector Double targetDoubles (Col name) df = requireDoubleColumn name df targetDoubles expr df = case columnAsUnboxedVector expr df of@@ -146,10 +135,7 @@ Right v -> v Left e -> throw e -{- | The extracted feature columns of an unsupervised fit, in the shapes the-algorithms need: names, column-major vectors, the row-major matrix, and the-@(n, d)@ dimensions.--}+-- | The extracted feature columns of an unsupervised fit, in several shapes. data Features = Features { ftNames :: ![T.Text] , ftCols :: ![VU.Vector Double]@@ -182,9 +168,7 @@ Right v -> v Left err -> throw err -{- | An affine score @b + Σ wⱼ·colⱼ@ over named columns, dropping zero weights-(the shared core of linear/logistic/SVM margins).--}+-- | An affine score over named columns, dropping zero weights. affineExpr :: Double -> [(Double, T.Text)] -> Expr Double affineExpr b terms = foldr@@ -192,9 +176,7 @@ (F.lit b) [F.lit w .*. (Col n :: Expr Double) | (w, n) <- terms, w /= 0] -{- | The class whose score is greatest, as a nested-@If@ expression; ties go to-the earlier class.--}+-- | The class whose score is greatest, as a nested If; ties go earlier. argMaxExpr :: (Columnable a) => [(a, Expr Double)] -> Expr a argMaxExpr = argExtreme (.>=.)
src-internal/DataFrame/LinearAlgebra.hs view
@@ -1,9 +1,6 @@ {-# LANGUAGE BangPatterns #-} -{- | Dependency-free dense linear algebra over row-major matrices, shared by the-models in @dataframe-learn@. Solvers live in "DataFrame.LinearAlgebra.Solve"-and eigenproblems in "DataFrame.LinearAlgebra.Eigen".--}+-- | Dependency-free dense linear algebra over row-major matrices. module DataFrame.LinearAlgebra ( Matrix, dot,@@ -23,9 +20,7 @@ import qualified Data.Vector as V import qualified Data.Vector.Unboxed as VU -{- | Row-major dense matrix: an outer boxed vector of equal-length rows. An-@n×d@ matrix has @n@ rows of length @d@.--}+-- | Row-major dense matrix: an outer boxed vector of equal-length rows. type Matrix = V.Vector (VU.Vector Double) -- | Inner product of two equal-length vectors.@@ -43,11 +38,11 @@ scaleV a = VU.map (* a) {-# INLINE scaleV #-} --- | @matVec A v@ for @A@ of shape @n×d@ and @v@ of length @d@; result length @n@.+-- | Matrix-vector product A v. matVec :: Matrix -> VU.Vector Double -> VU.Vector Double matVec a v = VU.convert (V.map (`dot` v) a) --- | @tMatVec A v = Aᵀ v@ for @A@ of shape @n×d@, @v@ of length @n@; result length @d@.+-- | Transposed matrix-vector product. tMatVec :: Matrix -> VU.Vector Double -> VU.Vector Double tMatVec a v | V.null a = VU.empty@@ -105,9 +100,7 @@ (-1, 1 / 0) centers -{- | Indices @j@ (excluding @i@) within squared radius @eps²@ of row @i@, by-brute force; @O(n d)@ per query.--}+-- | Indices (excluding i) within squared radius eps squared of row i. epsNeighbors :: Double -> Matrix -> Int -> VU.Vector Int epsNeighbors eps rows i = VU.fromList
src-internal/DataFrame/LinearAlgebra/Eigen.hs view
@@ -1,9 +1,6 @@ {-# LANGUAGE BangPatterns #-} -{- | Symmetric eigenproblems in pure Haskell: cyclic Jacobi for full-decomposition (PCA covariance, @m×m@ kernels) and power iteration for the-dominant eigenpair (FISTA step sizes). Deterministic, sign-canonicalised output.--}+-- | Symmetric eigenproblems in pure Haskell: Jacobi and power iteration. module DataFrame.LinearAlgebra.Eigen ( jacobiEigenSym, powerIterTop,@@ -18,10 +15,7 @@ import qualified Data.Vector.Unboxed.Mutable as VUM import DataFrame.LinearAlgebra (Matrix, dot, matVec, scaleV) -{- | Cyclic Jacobi eigendecomposition of a symmetric matrix. Eigenvalues are-returned in descending order paired with eigenvectors as rows, each-sign-canonicalised (largest-magnitude component positive) for unique output.--}+-- | Cyclic Jacobi eigendecomposition of a symmetric matrix. jacobiEigenSym :: Matrix -> (VU.Vector Double, Matrix) jacobiEigenSym a0 | V.null a0 = (VU.empty, V.empty)@@ -102,9 +96,7 @@ let idx = VU.maxIndex (VU.map abs vec) in if vec VU.! idx < 0 then VU.map negate vec else vec -{- | Dominant eigenvalue and eigenvector of a symmetric PSD matrix via power-iteration with a deterministic all-ones start.--}+-- | Dominant eigenpair of a symmetric PSD matrix via power iteration. powerIterTop :: Int -> Matrix -> (Double, VU.Vector Double) powerIterTop iters a | V.null a = (0, VU.empty)
src-internal/DataFrame/LinearAlgebra/Solve.hs view
@@ -1,9 +1,6 @@ {-# LANGUAGE BangPatterns #-} -{- | Householder QR (for ordinary least squares) and Cholesky factorisation (for-ridge normal equations and Gaussian log-densities). Pure, deterministic, no-LAPACK; sound at the @d@ ≤ low-hundreds scales this library targets.--}+-- | Householder QR and Cholesky factorisation, pure and deterministic. module DataFrame.LinearAlgebra.Solve ( qrLeastSquares, cholesky,@@ -20,10 +17,7 @@ import qualified Data.Vector.Unboxed.Mutable as VUM import DataFrame.LinearAlgebra (Matrix) -{- | Solve @min ‖A x − b‖₂@ for an @n×d@ matrix @A@ (@n ≥ d@) via Householder QR.-@Left cols@ reports rank deficiency (near-zero @R@ diagonal) with the offending-column indices; @Right x@ is the least-squares solution.--}+-- | Least squares via Householder QR, or the rank-deficient column indices. qrLeastSquares :: Matrix -> VU.Vector Double -> Either [Int] (VU.Vector Double) qrLeastSquares a b | V.null a = Right VU.empty@@ -127,9 +121,7 @@ xj <- VUM.read x j sumAbove i (j + 1) (acc + rij * xj) -{- | Cholesky factor @L@ (lower-triangular, @A = L Lᵀ@) of a symmetric-positive-definite matrix, or 'Nothing' if a non-positive pivot is hit.--}+-- | Cholesky factor of a symmetric positive-definite matrix, if one exists. cholesky :: Matrix -> Maybe Matrix cholesky a | V.null a = Just V.empty@@ -200,9 +192,7 @@ xj <- VUM.read x j sumKnown x i (j + 1) (acc + ((l V.! j) VU.! i) * xj) -{- | Solve the SPD system @A x = b@ via Cholesky; 'Nothing' when @A@ is not-positive-definite.--}+-- | Solve an SPD system via Cholesky; Nothing when not positive-definite. choleskySolve :: Matrix -> VU.Vector Double -> Maybe (VU.Vector Double) choleskySolve a b = do l <- cholesky a
src-internal/DataFrame/LinearSolver.hs view
@@ -1,10 +1,7 @@ {-# LANGUAGE BangPatterns #-} {-# LANGUAGE ScopedTypeVariables #-} -{- | Proximal-gradient (FISTA) solver for L1/L2-regularized generalized linear-models. 'fitL1Logistic' is the binary logistic split solver; 'fitProx'-generalizes it to any 'SmoothLoss'. Features are standardized internally.--}+-- | Proximal-gradient (FISTA) solver for L1/L2-regularized linear models. module DataFrame.LinearSolver ( -- * Model LinearModel (..),@@ -45,10 +42,7 @@ import qualified Data.Vector.Unboxed as VU import qualified Data.Vector.Unboxed.Mutable as VUM -{- | A fitted linear classifier: predicts the positive class when-@sum (weights .* features) + intercept > 0@. Weights of exactly @0@ mark-features dropped by the L1 penalty (filtered out by 'modelToExpr').--}+-- | A fitted linear classifier; zero weights mark features dropped by L1. data LinearModel = LinearModel { lmWeights :: !(VU.Vector Double) , lmIntercept :: !Double@@ -61,19 +55,13 @@ { scL1Lambda :: !Double -- ^ Strength of the L1 penalty on weights (intercept is not regularized). , scL2Lambda :: !Double- {- ^ Strength of the L2 penalty @(λ₂/2)·|w|²@ (Elastic Net; Zou & Hastie- 2005). Combined with @scL1Lambda@ this is the elastic-net objective;- @0@ reduces the solver to pure L1.- -}+ -- ^ Strength of the L2 penalty (elastic net); 0 reduces to pure L1. , scMaxIter :: !Int -- ^ Maximum number of FISTA iterations. , scTol :: !Double -- ^ Convergence tolerance on the weight delta (L-inf norm). , scSampleWeights :: !(Maybe (VU.Vector Double))- {- ^ Optional per-row sample weights, length @n@ (@Nothing@ is uniform).- Weights should have mean 1 (i.e. @Σ w_i = N@) so the Lipschitz bound stays- valid; see 'fitLinearCandidate' for the class-balanced construction.- -}+ -- ^ Optional per-row sample weights with mean 1 (Nothing is uniform). } deriving (Eq, Show) @@ -87,10 +75,7 @@ , scSampleWeights = Nothing } -{- | Fit L1-regularized binary logistic regression by FISTA. Rows are feature-vectors of equal length; labels are in @{\-1,+1}@. Features are standardized-internally and weights de-standardized, so the model applies to raw values.--}+-- | Fit L1-regularized binary logistic regression by FISTA. fitL1Logistic :: SolverConfig -> V.Vector (VU.Vector Double) ->@@ -100,10 +85,7 @@ {-# INLINEABLE fitL1Logistic #-} fitL1Logistic = runFista logisticLoss (specNormLipschitz logisticLoss) -{- | Fit any 'SmoothLoss' with the elastic-net proximal-gradient engine. The-Lipschitz constant uses the spectral norm of the standardized Gram matrix-(power iteration), tight for squared and squared-hinge losses.--}+-- | Fit any SmoothLoss with the elastic-net proximal-gradient engine. fitProx :: SmoothLoss -> SolverConfig ->@@ -113,10 +95,7 @@ LinearModel fitProx loss = runFista loss (specNormLipschitz loss) -{- | FISTA smooth-part Lipschitz bound: the loss curvature bound times the-spectral norm of the standardized Gram (+1 for the intercept), via power-iteration. Tight for every smooth loss.--}+-- | FISTA smooth-part Lipschitz bound from the standardized Gram spectral norm. specNormLipschitz :: SmoothLoss -> Matrix -> Int -> Double specNormLipschitz loss xKept _ = let n = V.length xKept@@ -124,10 +103,7 @@ (specNorm, _) = powerIterTop 50 gramN in slCurvBound loss * (specNorm + 1) -{- | Shared FISTA scaffolding: standardize, drop near-constant columns, run the-inner loop, de-standardize. @lipschitzOf@ returns the smooth-part Lipschitz-bound from the kept-feature matrix; the L2 contribution @λ₂@ is added here.--}+-- | Shared FISTA scaffolding: standardize, drop constants, loop, unscale. runFista :: SmoothLoss -> (Matrix -> Int -> Double) ->@@ -170,9 +146,7 @@ !d = V.length featureNames zeroModel = LinearModel (VU.replicate d 0) 0 featureNames -{- | Indices of columns whose variance clears the near-constant threshold.-Columns below it are dropped before fitting; their weight ends up @0@.--}+-- | Indices of columns whose variance clears the near-constant threshold. keptIndices :: VU.Vector Double -> VU.Vector Int keptIndices variances = VU.fromList@@ -181,15 +155,11 @@ , VU.unsafeIndex variances j >= 1.0e-12 ] -{- | Gather the entries of @v@ at @idxs@, preserving order. unsafeIndex is-safe: every index in @idxs@ is in range by construction.--}+-- | Gather the entries of a vector at the given indices, preserving order. gatherBy :: VU.Vector Int -> VU.Vector Double -> VU.Vector Double gatherBy idxs v = VU.map (VU.unsafeIndex v) idxs -{- | Standardize one row to the kept columns only (subtract column mean, divide-by column std). unsafeIndex is safe: rows share the column layout.--}+-- | Standardize one row to the kept columns only. standardizeRowKept :: VU.Vector Int -> VU.Vector Double ->@@ -201,19 +171,14 @@ standardizeAt j = (VU.unsafeIndex row j - VU.unsafeIndex means j) / VU.unsafeIndex stds j -{- | Scatter kept-column weights back into a full-width vector, with @0@ for-the dropped (near-constant) columns.--}+-- | Scatter kept-column weights into a full-width vector, zero elsewhere. expandWeights :: Int -> VU.Vector Int -> VU.Vector Double -> VU.Vector Double expandWeights d keep wKept = VU.create $ do mv <- VUM.replicate d 0 VU.iforM_ keep $ \k j -> VUM.unsafeWrite mv j (VU.unsafeIndex wKept k) pure mv -{- | Convert a fitted model to an 'Expr Bool' over its feature columns,-dropping zero-weight features. With no non-zero weights it returns the-constant @Lit (intercept > 0)@.--}+-- | Convert a fitted model to a Bool expression, dropping zero-weight features. modelToExpr :: LinearModel -> Expr Bool modelToExpr m = case nonZero of@@ -229,10 +194,7 @@ term w n = F.lit w .*. (Col n :: Expr Double) score rest first = foldl (\acc (w, n) -> acc .+. term w n) first rest .+. F.lit b -{- | Per-column @(means, stds, variances)@ of a feature matrix. Cheaper than-'standardize' when only the statistics are needed. unsafeIndex within is-safe: all rows share width @d@.--}+-- | Per-column means, stds and variances of a feature matrix. columnStats :: V.Vector (VU.Vector Double) -> (VU.Vector Double, VU.Vector Double, VU.Vector Double)@@ -278,10 +240,7 @@ | j >= VUM.length mv = pure () | otherwise = VUM.unsafeModify mv (* factor) j >> go (j + 1) -{- | Standardize each column to zero mean and unit variance, also returning-@(means, stds, variances)@. Near-constant columns get std @1@; callers use-the raw variances to detect and drop them (see 'fitL1Logistic').--}+-- | Standardize columns to zero mean and unit variance, with their statistics. standardize :: V.Vector (VU.Vector Double) -> ( V.Vector (VU.Vector Double)@@ -299,25 +258,18 @@ (VU.unsafeIndex row j - VU.unsafeIndex means j) / VU.unsafeIndex stds j in (V.map standardizeRow x, means, stds, variances) -{- | Proximal operator for the L1 norm: shrink @v@ toward zero by @lambda@,-clamping at zero.--}+-- | Proximal operator for the L1 norm: shrink toward zero, clamping at zero. softThreshold :: Double -> Double -> Double softThreshold lambda v | v > lambda = v - lambda | v < -lambda = v + lambda | otherwise = 0 -{- | Dot product of two unboxed vectors. Caller must ensure equal length;-lengths are not checked.--}+-- | Dot product of two unboxed vectors; lengths are not checked. dotProduct :: VU.Vector Double -> VU.Vector Double -> Double dotProduct u v = VU.sum (VU.zipWith (*) u v) -{- | Gradient of the average loss at @(w, b)@, returning @(gradW, gradB)@.-When @sampleWeights@ is @Just ws@ each row is scaled by @ws[i]@; with mean-1-weights the @1/N@ normalisation is preserved exactly.--}+-- | Gradient of the average loss, optionally scaled by per-row sample weights. lossGradient :: SmoothLoss -> Maybe (VU.Vector Double) ->@@ -333,9 +285,7 @@ !gradW = accumulateGradW (VU.length w) features coeffs !gradB = VU.sum coeffs -{- | Per-row loss coefficient @c_i = ℓ'(y_i, z_i) / N@ at margin-@z_i = w·x_i + b@, optionally scaled by @ws[i]@.--}+-- | Per-row loss coefficient at the current margin, optionally weighted. rowCoeffs :: SmoothLoss -> Maybe (VU.Vector Double) ->@@ -355,9 +305,7 @@ Nothing -> base Just ws -> base * VU.unsafeIndex ws i -{- | Accumulate the weight gradient in one pass over every (row, feature)-pair, scattering into a length-@d@ mutable vector.--}+-- | Accumulate the weight gradient in one pass over every row and feature. accumulateGradW :: Int -> V.Vector (VU.Vector Double) -> VU.Vector Double -> VU.Vector Double accumulateGradW d features coeffs = runST $ do@@ -367,10 +315,7 @@ in VU.iforM_ row $ \j v -> VUM.unsafeModify mv (+ c * v) j VU.unsafeFreeze mv -{- | Inner FISTA loop over standardized features, returning the final @(w, b)@-(the caller de-standardizes). @lambda1@/@lambda2@ are the L1/L2 strengths and-@lp@ the smooth-part Lipschitz constant driving the elastic-net prox step.--}+-- | Inner FISTA loop over standardized features, returning the final weights. fistaLoop :: SmoothLoss -> Double ->@@ -400,10 +345,7 @@ !delta = if VU.null xW then 0 else deltaInf xWPrev xW (!yWNew, !yBNew, !tNew) = fistaMomentum t xWPrev xBPrev xW xB -{- | One fused FISTA prox step: gradient step plus the Elastic-Net proximal-operator @softThreshold(z, λ₁/lp) / (1 + λ₂/lp)@ (soft-threshold then ridge-shrinkage). The intercept is unregularised.--}+-- | One fused FISTA prox step: gradient step plus the elastic-net prox. fistaProxStep :: SmoothLoss -> Maybe (VU.Vector Double) ->@@ -425,9 +367,7 @@ !bNew = yB - gB * stepInv in (wNew, bNew) -{- | Nesterov momentum extrapolation: new look-ahead point @(yW, yB)@ and the-updated step size @t@.--}+-- | Nesterov momentum extrapolation: new look-ahead point and step size. fistaMomentum :: Double -> VU.Vector Double ->@@ -442,9 +382,7 @@ !yB = xB + mom * (xB - xBPrev) in (yW, yB, tNew) -{- | L-inf norm of the weight delta. unsafeIndex is safe: both vectors share-the same length by construction.--}+-- | L-inf norm of the weight delta. {-# INLINE deltaInf #-} deltaInf :: VU.Vector Double -> VU.Vector Double -> Double deltaInf xWPrev = VU.ifoldl' (\acc i x -> max acc (abs (x - VU.unsafeIndex xWPrev i))) 0
src-internal/DataFrame/LinearSolver/Loss.hs view
@@ -1,9 +1,6 @@ {-# LANGUAGE OverloadedStrings #-} -{- | Smooth losses for the proximal-gradient engine. Each carries its-derivative @∂ℓ/∂z@ at @z = w·x + b@ and a global bound on the curvature-@∂²ℓ/∂z²@ (used for the FISTA step size).--}+-- | Smooth losses for the proximal-gradient engine. module DataFrame.LinearSolver.Loss ( SmoothLoss (..), sigmoid,@@ -14,9 +11,7 @@ import qualified Data.Text as T -{- | A convex, @C¹@ per-sample loss @ℓ(y, z)@. 'slGradZ' is @∂ℓ/∂z@;-'slCurvBound' bounds @∂²ℓ/∂z²@ over all @(y, z)@.--}+-- | A convex per-sample loss with its gradient and a curvature bound. data SmoothLoss = SmoothLoss { slName :: !T.Text , slGradZ :: Double -> Double -> Double
src-internal/DataFrame/Random.hs view
@@ -1,9 +1,6 @@ {-# LANGUAGE CPP #-} -{- | Deterministic, platform-independent random sampling for the stochastic-fitters. Built on @random@'s SplitMix 'StdGen'; the distributions here are our-own so a seeded fit is bit-reproducible across platforms.--}+-- | Deterministic, platform-independent random sampling for the fitters. module DataFrame.Random ( Gen, mkGen,@@ -52,9 +49,7 @@ d = fromIntegral (w `shiftR` 11) * (1 / 9007199254740992) in (d, g') -{- | Uniform 'Int' in the inclusive range @[lo, hi]@ by rejection sampling-(unbiased). Returns @lo@ when @hi <= lo@.--}+-- | Uniform Int in an inclusive range by rejection sampling (unbiased). nextIntR :: (Int, Int) -> Gen -> (Int, Gen) nextIntR (lo, hi) g | hi <= lo = (lo, g)@@ -68,9 +63,7 @@ then (lo + fromIntegral (w `mod` range), gg') else loop gg' -{- | A pair of independent standard normals via Box-Muller, consuming exactly two-uniforms so stream offsets stay data-independent.--}+-- | A pair of independent standard normals via Box-Muller. gaussianPair :: Gen -> ((Double, Double), Gen) gaussianPair g = let (u1, g1) = nextDouble g@@ -90,9 +83,7 @@ let ((z0, z1), g') = gaussianPair g in go (k - 2) g' (z1 : z0 : acc) -{- | A uniformly random permutation of @[0 .. n-1]@ (Fisher-Yates), threading the-generator purely.--}+-- | A uniformly random permutation of 0 .. n-1 (Fisher-Yates). shuffleInts :: Int -> Gen -> (VU.Vector Int, Gen) shuffleInts n g0 | n <= 1 = (VU.enumFromN 0 (max 0 n), g0)@@ -110,9 +101,7 @@ let (j, g') = nextIntR (0, i) g in genSwaps (i - 1) g' ((i, j) : acc) -{- | @sampleIndices k n@ draws @k@ distinct indices from @[0 .. n-1]@ (the first-@k@ of a full shuffle); returns all @n@ when @k >= n@.--}+-- | Draw k distinct indices from 0 .. n-1; returns all n when k >= n. sampleIndices :: Int -> Int -> Gen -> (VU.Vector Int, Gen) sampleIndices k n g = let (perm, g') = shuffleInts n g
src-internal/DataFrame/SymbolicRegression/Expr.hs view
@@ -1,9 +1,6 @@ {-# LANGUAGE FlexibleContexts #-} -{- | The symbolic-regression expression tree: a small first-order ADT with-vectorized evaluation and a total translation to a dataframe 'Expr Double'.-Division, log, and sqrt are protected so evaluation never produces @NaN@.--}+-- | The symbolic-regression expression tree with protected, total operators. module DataFrame.SymbolicRegression.Expr ( SRExpr (..), BinOp (..),@@ -45,9 +42,7 @@ allUnOps :: [UnOp] allUnOps = [minBound .. maxBound] -{- | Evaluate over a feature matrix given column-major (@feats ! j@ is feature-@j@ across all rows). Protected operators keep results finite.--}+-- | Evaluate over a column-major feature matrix with protected operators. evalSR :: V.Vector (VU.Vector Double) -> Int -> SRExpr -> VU.Vector Double evalSR feats n = go where
src-internal/DataFrame/SymbolicRegression/GP.hs view
@@ -1,13 +1,11 @@-{- | A compact generational genetic-programming search over 'SRExpr': ramped-initialization, tournament selection, subtree crossover/mutation, elitism, and a-complexity-keyed Pareto archive. Deterministic given the seed.--}+-- | A compact generational genetic-programming search over SRExpr. module DataFrame.SymbolicRegression.GP ( GPParams (..), runGP, ) where -import Data.List (foldl', minimumBy, sortBy)+import Data.List (minimumBy, sortBy)+import qualified Data.List as L import qualified Data.Map.Strict as M import Data.Ord (comparing) import qualified Data.Vector as V@@ -45,7 +43,7 @@ runGP p g0 = let (pop0, g1) = initPop p g0 scored0 = map (scoreOf p) pop0- arch0 = foldl' (archiveInsert p) M.empty scored0+ arch0 = L.foldl' (archiveInsert p) M.empty scored0 (_, finalArch, gN, _) = iterate' 0 scored0 arch0 g1 best = bestOfArchive finalArch@@ -59,7 +57,7 @@ | gen >= gpGenerations p = (pop, arch, gen, g) | otherwise = let (pop', g') = nextGen p pop g- arch' = foldl' (archiveInsert p) arch pop'+ arch' = L.foldl' (archiveInsert p) arch pop' in iterate' (gen + 1) pop' arch' g' fst3 (a, _, _) = a snd3 (_, b, _) = b
src-internal/DataFrame/SymbolicRegression/Optimize.hs view
@@ -1,9 +1,6 @@ {-# LANGUAGE BangPatterns #-} -{- | Constant optimization for symbolic-regression candidates: finite-difference-gradient descent with backtracking line search on the embedded constants. Pure-and dependency-free; effective at the one-to-few constants a tree carries.--}+-- | Constant optimization for symbolic-regression candidates. module DataFrame.SymbolicRegression.Optimize ( optimizeConstants, meanSquaredError,
src-internal/DataFrame/SymbolicRegression/Simplify.hs view
@@ -1,7 +1,4 @@-{- | A fuel-bounded, deterministic algebraic simplifier — the dependency-light-stand-in for equality saturation. Used as a canonical dedup key for the Pareto-archive and to tidy reported expressions; total and evaluation-preserving.--}+-- | A fuel-bounded, deterministic algebraic simplifier. module DataFrame.SymbolicRegression.Simplify ( simplify, ) where
src/DataFrame/Boosting.hs view
@@ -1,6 +1,4 @@-{- | Tree ensembles: gradient boosting (the recommended default) and AdaBoost-(SAMME). Re-exports the focused submodules.--}+-- | Tree ensembles: gradient boosting and AdaBoost (SAMME). module DataFrame.Boosting ( module DataFrame.Boosting.GBM, module DataFrame.Boosting.AdaBoost,
src/DataFrame/Boosting/AdaBoost.hs view
@@ -8,11 +8,7 @@ {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-} -{- | AdaBoost (SAMME) over short, sample-weighted classification trees. The-weighted-Gini stump fitter here is self-contained (it reuses the CART feature-encoding but not the unweighted CART recursion), so the existing decision-tree-path is untouched. 'predict' is the arg-max of weighted votes.--}+-- | AdaBoost (SAMME) over short, sample-weighted classification trees. module DataFrame.Boosting.AdaBoost ( module DataFrame.Model, AdaBoostConfig (..),@@ -31,7 +27,9 @@ import DataFrame.DecisionTree.Cart ( CartFeature (..), cartFeatures,+ cfPred, sortIndicesByValue,+ splitMidpoint, ) import DataFrame.DecisionTree.Fit (treeToExpr) import DataFrame.DecisionTree.Types (Tree (..))@@ -188,7 +186,7 @@ valid = vCur /= vNext && wl > 0 && wr > 0 best' = if valid && maybe True (\(_, s) -> score < s) best- then Just ((vCur + vNext) / 2, score)+ then Just (splitMidpoint vCur vNext, score) else best in go0 (k + 1) leftW' best' @@ -202,9 +200,7 @@ minimum3 :: (Ord a) => [(a, b, c)] -> (a, b, c) minimum3 = foldr1 (\x@(a, _, _) y@(b, _, _) -> if a <= b then x else y) -{- | Compile to an arg-max-of-weighted-votes expression over the class set:-@argmax_c Σ_m αₘ·[stumpₘ = c]@.--}+-- | Compile to an arg-max-of-weighted-votes expression over the class set. adaBoostExpr :: (Columnable a, Ord a) => AdaBoostModel a -> Expr a adaBoostExpr m = argMaxExpr (zip classes scores) where
src/DataFrame/Boosting/GBM.hs view
@@ -3,15 +3,9 @@ {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilies #-} -{- | Gradient boosting of regression trees (Friedman). Trees are fitted to the-negative gradient of the loss each round and accumulated with a shrinkage-factor; squared error gives regression, logistic deviance gives binary-classification. 'predict' is the additive score; 'gbProbaExpr' /-'gbDecisionExpr' give the classification probability / decision.--}+-- | Gradient boosting of regression trees on per-feature histograms. module DataFrame.Boosting.GBM ( module DataFrame.Model, GBLoss (..),@@ -24,25 +18,28 @@ ) where import Control.Exception (throw)-import Data.Either (fromRight) import qualified Data.Map.Strict as M 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.DecisionTree.Cart (cartFeatures)+import Data.Maybe (fromMaybe)+import DataFrame.DecisionTree.Cart (cartFeaturesByColumn) import DataFrame.DecisionTree.Fit (treeToExpr)-import DataFrame.DecisionTree.Regression (RegTreeConfig (..), fitRegTreeOn)+import DataFrame.DecisionTree.Histogram (+ TreeLimits (..),+ binFeatures,+ fitBinnedTreeOn,+ ) import DataFrame.DecisionTree.Types (Tree) import DataFrame.Expression.Operators ((.*.), (.+.), (.>.)) import DataFrame.Featurize.Internal (targetDoubles) import qualified DataFrame.Functions as F-import DataFrame.Internal.Column (TypedColumn (..), toVector) import DataFrame.Internal.DataFrame (DataFrame) import DataFrame.Internal.Expression (Expr (..), getColumns)-import DataFrame.Internal.Interpreter (interpret) import DataFrame.Model+import System.Random (StdGen, mkStdGen, randoms) -- | The boosting loss. data GBLoss = SquaredError | LogisticDeviance@@ -53,9 +50,23 @@ , gbNEstimators :: !Int , gbLearningRate :: !Double , gbMaxDepth :: !Int+ , gbMaxBins :: !Int+ -- ^ Bins per feature for split finding. , gbSeed :: !Int+ , gbL2 :: !Double+ -- ^ L2 penalty on leaf values (XGBoost's @lambda@).+ , gbMinChildWeight :: !Double+ -- ^ Smallest Hessian sum a leaf may have (XGBoost's @min_child_weight@).+ , gbFeatureGroups :: ![[T.Text]]+ -- ^ Column groups: tree m splits only on group m mod the group count.+ , gbBaseScore :: !(Maybe (Expr Double))+ -- ^ Per-row scores to boost from instead of a constant.+ , gbSubsample :: !Double+ -- ^ Fraction of rows each tree is fitted on (1 = all).+ , gbColsample :: !Double+ -- ^ Fraction of allowed features each tree may split on (1 = all). }- deriving (Eq, Show)+ deriving (Show) defaultGBConfig :: GBConfig defaultGBConfig =@@ -64,13 +75,17 @@ , gbNEstimators = 100 , gbLearningRate = 0.1 , gbMaxDepth = 3+ , gbMaxBins = 1024 , gbSeed = 0+ , gbL2 = 0+ , gbMinChildWeight = 0+ , gbFeatureGroups = []+ , gbBaseScore = Nothing+ , gbSubsample = 1+ , gbColsample = 1 } -{- | A fitted gradient-boosting model. 'gbInit' is the constant initial score-(mean, or log-odds for classification); 'gbTrees' are the staged regression-trees.--}+-- | A fitted gradient-boosting model: initial score plus staged trees. data GBModel = GBModel { gbInit :: !Double , gbTrees :: !(V.Vector (Tree Double))@@ -78,6 +93,7 @@ , gbModelLoss :: !GBLoss , gbTrainScore :: !(VU.Vector Double) , gbFeatureUsage :: !(M.Map T.Text Int)+ , gbBase :: !(Maybe (Expr Double)) } deriving (Show) @@ -89,7 +105,7 @@ type Prediction GBModel = Expr Double predict = gbExpr --- | Fit a gradient-boosting ensemble predicting @target@ from the other columns.+-- | Fit a gradient-boosting ensemble predicting the target column. fitGBM :: GBConfig -> Expr Double -> DataFrame -> GBModel fitGBM cfg target@(Col name) df = GBModel@@ -99,33 +115,70 @@ (gbLoss cfg) (VU.fromList (reverse scores)) usage+ (gbBaseScore cfg) where- feats = V.fromList (cartFeatures name df)+ byColumn = cartFeaturesByColumn name df+ binned = binFeatures (gbMaxBins cfg) (V.fromList (map snd byColumn))+ allFeatures = VU.enumFromN 0 (length byColumn)+ groupFeatures =+ [ VU.fromList [j | (j, (c, _)) <- zip [0 ..] byColumn, c `elem` g]+ | g <- gbFeatureGroups cfg+ ]+ featuresFor m =+ let allowed = case groupFeatures of+ [] -> allFeatures+ gs -> gs !! (m `mod` length gs)+ in if gbColsample cfg >= 1+ then allowed+ else+ keepFraction+ (gbColsample cfg)+ (mkStdGen (7919 * gbSeed cfg + 2 * m + 1))+ allowed+ rowMask m+ | gbSubsample cfg >= 1 = Nothing+ | otherwise =+ Just+ ( VU.fromList+ ( take+ n+ ( map+ (\u -> if u < gbSubsample cfg then 1 else 0)+ (randoms (mkStdGen (7919 * gbSeed cfg + 2 * m)) :: [Double])+ )+ )+ ) y = targetDoubles target df n = VU.length y lr = gbLearningRate cfg- rtCfg =- RegTreeConfig- { rtMaxDepth = gbMaxDepth cfg- , rtMinSamplesSplit = 2- , rtMinLeafSize = 1- , rtMinImpurityDecrease = 0.0+ limits =+ TreeLimits+ { tlMaxDepth = gbMaxDepth cfg+ , tlMinSamplesSplit = 2+ , tlMinLeafSize = 1+ , tlMinImpurityDecrease = 0.0+ , tlL2 = gbL2 cfg+ , tlMinChildWeight = gbMinChildWeight cfg }- f0 = case gbLoss cfg of- SquaredError -> VU.sum y / fromIntegral (max 1 n)- LogisticDeviance ->+ baseScores = fmap (`targetDoubles` df) (gbBaseScore cfg)+ f0 = case (baseScores, gbLoss cfg) of+ (Just _, _) -> 0+ (Nothing, SquaredError) -> VU.sum y / fromIntegral (max 1 n)+ (Nothing, LogisticDeviance) -> let p = clamp01 (VU.sum y / fromIntegral (max 1 n)) in log (p / (1 - p))- (trees, scores, usage) = boost 0 (VU.replicate n f0) [] [] M.empty+ (trees, scores, usage) = boost 0 (fromMaybe (VU.replicate n f0) baseScores) [] [] M.empty boost !m fScores ts ss usageAcc | m >= gbNEstimators cfg = (ts, ss, usageAcc) | otherwise =- let (target', weights) = newtonStep (gbLoss cfg) y fScores- tree = fitRegTreeOn rtCfg feats target' weights- pred = predictTree df tree+ let (target', weights0) = newtonStep (gbLoss cfg) y fScores+ weights = case rowMask m of+ Nothing -> weights0+ Just mask -> Just (VU.zipWith (*) mask (fromMaybe (VU.replicate n 1) weights0))+ (tree, pred) = fitBinnedTreeOn (featuresFor m) limits binned target' weights fScores' = VU.zipWith (\f p -> f + lr * p) fScores pred- score = lossValue (gbLoss cfg) y fScores'- usage' = foldr (\c -> M.insertWith (+) c 1) usageAcc (treeColumns tree)+ !score = lossValue (gbLoss cfg) y fScores'+ !usage' = foldr (\c -> M.insertWith (+) c 1) usageAcc (treeColumns tree) in boost (m + 1) fScores' (tree : ts) (score : ss) usage' fitGBM _ expr _ = throw (NonColumnReferenceException ("fitGBM: " <> T.pack (show expr)))@@ -174,11 +227,6 @@ clamp01 :: Double -> Double clamp01 p = max 1e-12 (min (1 - 1e-12) p) -predictTree :: DataFrame -> Tree Double -> VU.Vector Double-predictTree df t = case interpret @Double df (treeToExpr t) of- Right (TColumn c) -> fromRight VU.empty (toVector @Double @VU.Vector c)- Left e -> throw e- treeColumns :: Tree Double -> [T.Text] treeColumns = getColumns . treeToExpr @@ -194,8 +242,9 @@ stageExpr :: Int -> GBModel -> Expr Double stageExpr k m =- foldr ((.+.) . scaled) (F.lit (gbInit m)) (take k (V.toList (gbTrees m)))+ foldr ((.+.) . scaled) start (take k (V.toList (gbTrees m))) where+ start = maybe (F.lit (gbInit m)) (\b -> b .+. F.lit (gbInit m)) (gbBase m) scaled t = F.lit (gbRate m) .*. treeToExpr t -- | Probability expression for classification: @sigmoid(score)@.@@ -205,3 +254,11 @@ -- | Decision expression for classification: positive class when score > 0. gbDecisionExpr :: GBModel -> Expr Bool gbDecisionExpr m = gbExpr m .>. F.lit 0++-- | A uniformly chosen subset of the given size fraction, in order.+keepFraction :: Double -> StdGen -> VU.Vector Int -> VU.Vector Int+keepFraction frac g xs =+ let k = max 1 (round (frac * fromIntegral (VU.length xs)))+ keyed = zip (take (VU.length xs) (randoms g :: [Double])) (VU.toList xs)+ chosen = map snd (take k (M.toAscList (M.fromList keyed)))+ in VU.fromList (M.keys (M.fromList [(x, ()) | x <- chosen]))
src/DataFrame/DBSCAN.hs view
@@ -5,12 +5,7 @@ {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilies #-} -{- | Density-based clustering (DBSCAN). Brute-force @O(n²)@ region queries, no-spatial index — suitable for the in-memory scales this library targets. DBSCAN-is transductive: it has a 'Fit' instance but deliberately no 'Predict' instance-(there is no honest single prediction expression). 'dbscanSurrogateExpr' fits an-interpretable decision-tree surrogate on the cluster labels instead.--}+-- | Density-based clustering (DBSCAN) with brute-force region queries. module DataFrame.DBSCAN ( module DataFrame.Model, DBSCANConfig (..),@@ -34,7 +29,7 @@ ) import qualified DataFrame.Functions as F import qualified DataFrame.Internal.Column as DI-import DataFrame.Internal.DataFrame (DataFrame, fromNamedColumns)+import DataFrame.Internal.DataFrame (DataFrame, fromColumns) import DataFrame.Internal.Expression (Expr) import DataFrame.LinearAlgebra (epsNeighbors) import DataFrame.Model@@ -48,9 +43,7 @@ defaultDBSCANConfig :: DBSCANConfig defaultDBSCANConfig = DBSCANConfig{dbEps = 0.5, dbMinSamples = 5} -{- | A fitted DBSCAN labelling. 'dbLabels' uses @-1@ for noise (sklearn's-@labels_@); 'dbCoreSampleIndices' are the core points.--}+-- | A fitted DBSCAN labelling, with -1 marking noise. data DBSCANModel = DBSCANModel { dbLabels :: !(VU.Vector Int) , dbCoreSampleIndices :: !(VU.Vector Int)@@ -106,9 +99,7 @@ seedLoop 0 0 VU.freeze lab -{- | Fit a decision-tree surrogate on the DBSCAN labels so new rows can be-assigned an (approximate) cluster. Noise (@-1@) is its own class.--}+-- | Fit a decision-tree surrogate on the DBSCAN labels. dbscanSurrogateExpr :: TreeConfig -> [Expr Double] -> DBSCANModel -> DataFrame -> Expr Int dbscanSurrogateExpr cfg features model df =@@ -117,6 +108,6 @@ clusterCol = "__cluster__" cols = map (\e -> (columnExprName e, materializeColumn df e)) features augmented =- fromNamedColumns $- [(n, DI.fromList (VU.toList v)) | (n, v) <- cols]- ++ [(clusterCol, DI.fromList (VU.toList (dbLabels model)))]+ fromColumns $+ [(n, DI.fromUnboxedVector v) | (n, v) <- cols]+ ++ [(clusterCol, DI.fromUnboxedVector (dbLabels model))]
src/DataFrame/DecisionTree.hs view
@@ -1,13 +1,8 @@-{- | Interpretable decision trees on DataFrames (CART refined by TAO). The-curated public surface: classifier/regressor configs, fitted-model records,-and their 'Fit'\/'Predict' instances.--}+-- | Interpretable decision trees on DataFrames (CART refined by TAO). module DataFrame.DecisionTree ( -- * Estimators - {- | Fitted-model records with their @Fit@\/@Predict@ instances and the- estimator classes (via "DataFrame.Model").- -}+ -- | Fitted-model records with their Fit and Predict instances. module DataFrame.DecisionTree.Model, -- * Classifier configuration
src/DataFrame/DecisionTree/Model.hs view
@@ -5,13 +5,7 @@ {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-} -{- | sklearn-style standalone tree estimators returning inspectable records-(depth, leaf count, per-feature split usage). 'fit' trains a classifier (from a-'TreeConfig') or a regressor (from a 'RegTreeConfig'); 'predict' is the compiled-tree expression, and the record exposes the raw 'Tree' too. The bare-'DataFrame.DecisionTree.Fit.fitDecisionTree' remains for callers that only want-the classifier @Expr@.--}+-- | Standalone tree estimators returning inspectable fitted records. module DataFrame.DecisionTree.Model ( module DataFrame.Model, DecisionTreeClassifier (..),@@ -27,7 +21,11 @@ import DataFrame.DecisionTree.Cart (cartFeatures) import DataFrame.DecisionTree.Fit (fitDecisionTree, treeToExpr)-import DataFrame.DecisionTree.Regression (RegTreeConfig, fitRegTreeOn)+import DataFrame.DecisionTree.Regression (+ RegFit (..),+ RegTreeConfig,+ fitRegTree,+ ) import DataFrame.DecisionTree.Types (Tree (..), TreeConfig) import DataFrame.Featurize.Internal (targetDoubles) import DataFrame.Internal.Column (Columnable)@@ -80,11 +78,13 @@ where t = case target of Col name ->- fitRegTreeOn- cfg- (V.fromList (cartFeatures name df))- (targetDoubles target df)- Nothing+ rfTree+ ( fitRegTree+ cfg+ (V.fromList (cartFeatures name df))+ (targetDoubles target df)+ Nothing+ ) _ -> throw ( NonColumnReferenceException
src/DataFrame/DecisionTree/Regression.hs view
@@ -1,15 +1,14 @@ {-# LANGUAGE BangPatterns #-} {-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TupleSections #-} -{- | Variance-reduction (weighted-SSE) regression trees over the CART feature-machinery; leaves predict the weighted mean of their rows. 'fitRegTreeOn' lets-gradient boosting refit on residuals without re-extracting features.--}+-- | Variance-reduction (weighted-SSE) regression trees. module DataFrame.DecisionTree.Regression ( RegTreeConfig (..), defaultRegTreeConfig,+ RegFit (..), -- | Implementation verb used by the fit\/predict instances and boosting.- fitRegTreeOn,+ fitRegTree, ) where import Control.Parallel (par, pseq)@@ -17,8 +16,14 @@ import qualified Data.Vector as V import qualified Data.Vector.Unboxed as VU -import DataFrame.DecisionTree.Cart (CartFeature (..), sortIndicesByValue)+import DataFrame.DecisionTree.Cart (+ CartFeature (..),+ NullSide (..),+ sortIndicesByValue,+ splitMidpoint,+ ) import DataFrame.DecisionTree.Types (Tree (..))+import DataFrame.Internal.Expression (Expr) -- | Stopping criteria for the regression tree. data RegTreeConfig = RegTreeConfig@@ -38,35 +43,50 @@ , rtMinImpurityDecrease = 0.0 } -{- | Fit on pre-extracted features, a target vector, and optional per-row-weights (length @n@). Used by gradient boosting on residual targets.--}-fitRegTreeOn ::+data RegFit = RegFit+ { rfTree :: !(Tree Double)+ , rfFitted :: !(VU.Vector Double)+ }++-- | Fit on pre-extracted features, a target vector and optional weights.+fitRegTree :: RegTreeConfig -> V.Vector CartFeature -> VU.Vector Double -> Maybe (VU.Vector Double) ->- Tree Double-fitRegTreeOn cfg feats y mw = buildNode 0 (VU.enumFromN 0 n) featSorted+ RegFit+fitRegTree cfg feats y mw = RegFit (toTree root) inSample where+ root = buildNode 0 (VU.enumFromN 0 n) featSorted+ inSample = VU.update (VU.replicate n 0) (VU.concat (leafRows root))+ leafRows (FLeaf v idxs) = [VU.map (,v) idxs]+ leafRows (FBranch _ l r) = leafRows l ++ leafRows r n = VU.length y weightAt i = maybe 1 (VU.! i) mw- featSorted = V.map (sortIndicesByValue . cfValues) feats+ -- For each feature, row indices sorted by value, with null rows left out.+ featSorted = V.map presentSorted feats+ presentSorted f = case cfNulls f of+ Nothing -> sortIndicesByValue (cfValues f)+ Just nulls -> VU.filter (not . (nulls VU.!)) (sortIndicesByValue (cfValues f)) buildNode depth idxs sortedByFeat | depth >= rtMaxDepth cfg || VU.length idxs < rtMinSamplesSplit cfg = leaf | otherwise = maybe leaf (splitNode depth idxs sortedByFeat) (bestSplit idxs sortedByFeat) where- leaf = Leaf (weightedMean idxs)+ leaf = FLeaf (weightedMean idxs) idxs - splitNode depth idxs sortedByFeat (fj, thr)- | VU.null lefts || VU.null rights = Leaf (weightedMean idxs)+ splitNode depth idxs sortedByFeat (fj, thr, side)+ | VU.null lefts || VU.null rights = FLeaf (weightedMean idxs) idxs | otherwise =- forceTree l `par` (forceTree r `pseq` Branch (cfPred (feats V.! fj) thr) l r)+ forceTree l `par`+ (forceTree r `pseq` FBranch (cfSplit feat thr side) l r) where- vals = cfValues (feats V.! fj)- goesLeft i = vals VU.! i <= thr+ feat = feats V.! fj+ vals = cfValues feat+ goesLeft i = case cfNulls feat of+ Just nulls | nulls VU.! i -> side == NullsLeft+ _ -> vals VU.! i <= thr lefts = VU.filter goesLeft idxs rights = VU.filter (not . goesLeft) idxs l = buildNode (depth + 1) lefts (V.map (VU.filter goesLeft) sortedByFeat)@@ -80,41 +100,64 @@ bestSplit idxs sortedByFeat | null candidates = Nothing- | red > 0 && red >= rtMinImpurityDecrease cfg = Just (fj, thr)+ | red > 0 && red >= rtMinImpurityDecrease cfg = Just split | otherwise = Nothing where+ nNode = VU.length idxs (totW, totSY, totSY2) = moments idxs nodeSSE = sse totSY totSY2 totW candidates =- [ (red', fj', thr')- | fj' <- [0 .. V.length feats - 1]- , (thr', red') <-- bestThreshold fj' (sortedByFeat V.! fj') totW totSY totSY2 nodeSSE- ]- (red, fj, thr) = maximumByFst candidates+ concat+ [ bestThreshold fj (sortedByFeat V.! fj) nNode totW totSY totSY2 nodeSSE+ | fj <- [0 .. V.length feats - 1]+ ]+ (red, split) = maximumByFst candidates - bestThreshold fj sorted totW totSY totSY2 nodeSSE = maybeToList (go 0 0 0 0 Nothing)+ -- Best (threshold, null side) for feature @fj@ at this node.+ bestThreshold fj sorted nNode totW totSY totSY2 nodeSSE =+ [(r, (fj, thr, dir)) | (thr, dir, r) <- maybeToList (go 0 0 0 0 Nothing)] where vals = cfValues (feats V.! fj) m = VU.length sorted+ nNull = nNode - m+ (pW, pSY, pSY2) = moments sorted+ (nW, nSY, nSY2) = (totW - pW, totSY - pSY, totSY2 - pSY2)+ hasNulls = nNull > 0+ -- Last split to try; without nulls the final row splits nothing.+ lastK = if hasNulls then m - 1 else m - 2+ score nl wl syl syl2 =+ let wr = totW - wl+ ok =+ nl >= rtMinLeafSize cfg && nNode - nl >= rtMinLeafSize cfg && wl > 0 && wr > 0+ in if ok+ then Just (nodeSSE - (sse syl syl2 wl + sse (totSY - syl) (totSY2 - syl2) wr))+ else Nothing go !k !wl !syl !syl2 best- | k >= m - 1 = best+ | k > lastK || k >= m = best | otherwise = go (k + 1) wl' syl' syl2' best' where i = sorted VU.! k- next = sorted VU.! (k + 1) wi = weightAt i yi = y VU.! i wl' = wl + wi syl' = syl + wi * yi syl2' = syl2 + wi * yi * yi- wr = totW - wl'- leafSizesOk = k + 1 >= rtMinLeafSize cfg && m - (k + 1) >= rtMinLeafSize cfg- splittable = vals VU.! i /= vals VU.! next && leafSizesOk && wl' > 0 && wr > 0- reduction = nodeSSE - (sse syl' syl2' wl' + sse (totSY - syl') (totSY2 - syl2') wr)+ boundary+ | k + 1 < m = vals VU.! i /= vals VU.! (sorted VU.! (k + 1))+ | otherwise = True+ thr+ | k + 1 < m = splitMidpoint (vals VU.! i) (vals VU.! (sorted VU.! (k + 1)))+ | otherwise = vals VU.! i+ right = (,) NullsRight <$> score (k + 1) wl' syl' syl2'+ left+ | hasNulls =+ (,) NullsLeft <$> score (k + 1 + nNull) (wl' + nW) (syl' + nSY) (syl2' + nSY2)+ | otherwise = Nothing+ consider b (dir, r)+ | maybe True (\(_, _, rb) -> r > rb) b = Just (thr, dir, r)+ | otherwise = b best'- | splittable && maybe True ((reduction >) . snd) best =- Just ((vals VU.! i + vals VU.! next) / 2, reduction)+ | boundary = foldl consider best (maybeToList right ++ maybeToList left) | otherwise = best moments = VU.foldl' step (0, 0, 0)@@ -126,16 +169,22 @@ safeDiv :: Double -> Double -> Double safeDiv a b = if b == 0 then 0 else a / b --- | Weighted SSE of a node from its Σy, Σy², and total weight: @Σy² − (Σy)²/w@.+-- | Weighted SSE of a node from its sums and total weight. sse :: Double -> Double -> Double -> Double sse sumY sumSq w = sumSq - safeDiv (sumY * sumY) w -{- | Force a subtree to WHNF throughout so the spark scoring the sibling has-substantial work to evaluate; pure and value-preserving (cf. 'Tao').--}-forceTree :: Tree Double -> ()-forceTree (Leaf v) = v `seq` ()-forceTree (Branch _ l r) = forceTree l `seq` forceTree r+data FitNode+ = FLeaf !Double !(VU.Vector Int)+ | FBranch !(Expr Bool) !FitNode !FitNode -maximumByFst :: (Ord a) => [(a, b, c)] -> (a, b, c)-maximumByFst = foldr1 (\x@(a, _, _) y@(b, _, _) -> if a >= b then x else y)+toTree :: FitNode -> Tree Double+toTree (FLeaf v _) = Leaf v+toTree (FBranch c l r) = Branch c (toTree l) (toTree r)++-- | Force a subtree to WHNF throughout; pure and value-preserving.+forceTree :: FitNode -> ()+forceTree (FLeaf v _) = v `seq` ()+forceTree (FBranch _ l r) = forceTree l `seq` forceTree r++maximumByFst :: (Ord a) => [(a, b)] -> (a, b)+maximumByFst = foldr1 (\x@(a, _) y@(b, _) -> if a >= b then x else y)
src/DataFrame/GMM.hs view
@@ -5,12 +5,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeFamilies #-} -{- | Gaussian mixture models fitted by EM. Full covariance by default (with a-diagonal option and an automatic fall-back when a covariance is not positive-definite), log-space responsibilities, and Cholesky-based densities for-stability. 'predict' is the hard (arg-max) component assignment; per-component-log-densities are available via 'gmmLogDensityExprs'.--}+-- | Gaussian mixture models fitted by EM, full or diagonal covariance. module DataFrame.GMM ( module DataFrame.Model, CovType (..),@@ -63,7 +58,7 @@ , gmmSeed = 0 } --- | A fitted mixture. 'gmmCovariances' are the per-component covariance matrices.+-- | A fitted mixture with per-component covariance matrices. data GMMModel = GMMModel { gmmWeights :: !(VU.Vector Double) , gmmMeans :: !(V.Vector (VU.Vector Double))
src/DataFrame/KMeans.hs view
@@ -4,11 +4,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeFamilies #-} -{- | k-means clustering (Lloyd's algorithm with k-means++ seeding and multiple-restarts). 'fit' trains a 'KMeansModel' (inspectable centres); 'predict' is the-arg-min cluster assignment. Per-cluster distance features are available via-'kmeansDistanceExprs' / 'kmeansTransform'.--}+-- | k-means clustering (Lloyd with k-means++ seeding and restarts). module DataFrame.KMeans ( module DataFrame.Model, KMeansConfig (..),@@ -29,7 +25,7 @@ import DataFrame.Featurize.Internal (Features (..), argMinExpr, extractFeatures) import qualified DataFrame.Functions as F import DataFrame.Internal.DataFrame (DataFrame)-import DataFrame.Internal.Expression (Expr (..), UExpr (..))+import DataFrame.Internal.Expression (Expr (..), SomeExpr (..)) import DataFrame.LinearAlgebra (Matrix, nearestCenter, sqDist) import DataFrame.Model import DataFrame.Random (Gen, mkGen, nextDouble, nextIntR, splitGen)@@ -82,9 +78,7 @@ genSeeds :: Gen -> [Gen] genSeeds g = let (g1, g2) = splitGen g in g1 : genSeeds g2 -{- | One k-means run: k-means++ seeding then Lloyd iterations. Returns-@(centers, labels, inertia, nIter)@.--}+-- | One k-means run: k-means++ seeding then Lloyd iterations. lloyd :: KMeansConfig -> Int ->@@ -158,4 +152,4 @@ -- | The per-cluster distance features as a composable fitted 'Transform'. kmeansTransform :: KMeansModel -> Transform-kmeansTransform m = Transform [(n, UExpr e) | (n, e) <- kmeansDistanceExprs m]+kmeansTransform m = Transform [(n, SomeExpr e) | (n, e) <- kmeansDistanceExprs m]
src/DataFrame/Learn.hs view
@@ -1,13 +1,4 @@-{- | The one import an application needs for the @dataframe-learn@ estimators:-the @fit@\/@predict@ verbs, every model config and fitted-model record, and all-their 'Fit'\/'Predict' instances.--@-import DataFrame.Learn-...-fit defaultRegTreeConfig target df-@--}+-- | The one import an application needs for the dataframe-learn estimators. module DataFrame.Learn ( module DataFrame.Model, module DataFrame.LinearModel,
src/DataFrame/LinearModel.hs view
@@ -1,12 +1,8 @@-{- | Linear models for the dataframe ecosystem: regression (OLS, ridge, lasso,-elastic net) and one-vs-rest logistic classification. Re-exports the focused-submodules.--}+-- | Linear regression (OLS, ridge, lasso, elastic net) and logistic models. module DataFrame.LinearModel ( module DataFrame.LinearModel.Regression, module DataFrame.LinearModel.Logistic,- -- Re-exported from the internal solver so the public configs' and records'- -- @SolverConfig@\/@LinearModel@ fields stay usable.+ -- Re-exported so the configs' @SolverConfig@\/@LinearModel@ fields work. SolverConfig (..), defaultSolverConfig, LinearModel (..),
src/DataFrame/LinearModel/Logistic.hs view
@@ -4,11 +4,7 @@ {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-} -{- | Logistic regression: binary and one-vs-rest multiclass over the FISTA-solver. 'fit' trains a 'LogisticModel'; 'predict' is the arg-max class decision.-Per-class margins and (normalized) probabilities stay available as named-auxiliary expressions.--}+-- | Logistic regression: binary and one-vs-rest multiclass via FISTA. module DataFrame.LinearModel.Logistic ( module DataFrame.Model, LogisticConfig (..),@@ -50,9 +46,7 @@ defaultLogisticConfig :: LogisticConfig defaultLogisticConfig = LogisticConfig defaultSolverConfig -{- | A fitted (one-vs-rest) logistic model: parallel vectors of class labels and-their binary sub-models. 'lgModels' carries sklearn's per-class @coef_@.--}+-- | A fitted one-vs-rest logistic model of class labels and sub-models. data LogisticModel a = LogisticModel { lgClasses :: !(V.Vector a) , lgModels :: !(V.Vector LinearModel)@@ -67,7 +61,7 @@ type Prediction (LogisticModel a) = Expr a predict m = argMaxExpr (labelledMargins m) --- | Fit one-vs-rest logistic regression; the target column supplies the classes.+-- | Fit one-vs-rest logistic regression; the target supplies the classes. fitLogistic :: (Columnable a, Ord a) => LogisticConfig -> Expr a -> DataFrame -> LogisticModel a@@ -83,22 +77,17 @@ let labels = VU.generate (V.length ys) (\i -> if ys V.! i == c then 1 else -1) in fitL1Logistic cfg mat labels nameVec- -- Binary one-vs-rest fits two sign-mirrored models; solve once and negate.- -- Multiclass fits the per-class binary problems in parallel.+ -- Binary fits two sign-mirrored models; solve once and negate. models = case classes of [c0, _c1] -> let m0 = fitOne c0 in [m0, negateModel m0] _ -> map fitOne classes `using` parList forceModel -{- | Sign-flip a fitted binary model (the other one-vs-rest class in a 2-class-problem is its exact mirror).--}+-- | Sign-flip a fitted binary model. negateModel :: LinearModel -> LinearModel negateModel m = m{lmWeights = VU.map negate (lmWeights m), lmIntercept = negate (lmIntercept m)} -{- | Force a fitted model's solver work by evaluating its (unboxed, strict)-weight vector, so 'parList' actually runs the per-class fits in parallel.--}+-- | Force a fitted model's weight vector so per-class fits run in parallel. forceModel :: Strategy LinearModel forceModel m = lmWeights m `seq` rseq m
src/DataFrame/LinearModel/Regression.hs view
@@ -3,10 +3,7 @@ {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE TypeFamilies #-} -{- | Linear regression with the standard penalties: OLS (QR), ridge (Cholesky),-and lasso\/elastic net (FISTA). 'fit' produces a 'LinearRegressor'; 'predict'-compiles it to an @Expr Double@ over the raw feature columns.--}+-- | Linear regression: OLS, ridge and lasso/elastic net. module DataFrame.LinearModel.Regression ( module DataFrame.Model, Penalty (..),@@ -37,7 +34,7 @@ import DataFrame.LinearSolver.Loss (squaredLoss) import DataFrame.Model --- | Regularization choice. @alpha@ is the penalty strength; @l1Ratio@ mixes L1/L2.+-- | Regularization choice: penalty strength and L1/L2 mix. data Penalty = OLS | Ridge !Double@@ -45,7 +42,7 @@ | ElasticNet !Double !Double deriving (Eq, Show) --- | Hyperparameters for linear regression: the penalty and the FISTA solver config.+-- | Hyperparameters for linear regression: penalty and solver config. data LinearConfig = LinearConfig { lcPenalty :: !Penalty , lcSolver :: !SolverConfig@@ -55,9 +52,7 @@ defaultLinearConfig :: LinearConfig defaultLinearConfig = LinearConfig{lcPenalty = OLS, lcSolver = defaultSolverConfig} -{- | A fitted linear regressor. @regCoef@ and @regIntercept@ are sklearn's-@coef_@ / @intercept_@ in raw feature space.--}+-- | A fitted linear regressor with coefficients in raw feature space. data LinearRegressor = LinearRegressor { regCoef :: !(VU.Vector Double) , regIntercept :: !Double@@ -103,9 +98,7 @@ where augmented = V.map (VU.cons 1) mat -{- | Ridge via Cholesky on @(XcᵀXc + αI) w = Xcᵀ yc@ over centred data; the-intercept is recovered from the column/target means.--}+-- | Ridge via Cholesky over centred data, intercept from the means. ridgeSolve :: Double -> Matrix -> VU.Vector Double -> (VU.Vector Double, Double) ridgeSolve alpha mat y = case choleskySolve a rhs of
src/DataFrame/Metrics.hs view
@@ -1,14 +1,9 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE OverloadedStrings #-} {-# 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'.--}+-- | Evaluation metrics for fitted models. module DataFrame.Metrics ( -- * Metric type + evaluation Metric,@@ -40,11 +35,13 @@ import Control.Exception (throw) import Data.Either (fromRight)-import Data.List (nub, sort, sortBy)+import Data.List (nub, sort) import Data.Ord (comparing) import qualified Data.Text as T+import qualified Data.Vector.Algorithms.Intro as VA import qualified Data.Vector.Unboxed as VU +import DataFrame.Errors (DataFrameException (..)) import DataFrame.Internal.Column (TypedColumn (..), toVector) import DataFrame.Internal.DataFrame (DataFrame) import DataFrame.Internal.Expression (Expr)@@ -54,11 +51,7 @@ -- | 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 a model's prediction expression against a truth column. evaluate :: Metric -> Expr Double -> Expr Double -> DataFrame -> Double evaluate metric predExpr truthExpr df = metric (columnOf df predExpr) (columnOf df truthExpr)@@ -73,15 +66,18 @@ Right (TColumn c) -> fromRight VU.empty (toVector @Double @VU.Vector c) Left err -> throw err -n2 :: VU.Vector Double -> Double-n2 = fromIntegral . VU.length+-- | Number of compared pairs, the shorter of the two vectors' lengths.+nCompared :: VU.Vector Double -> VU.Vector Double -> Double+nCompared preds truth = fromIntegral (min (VU.length preds) (VU.length truth)) -- | Mean squared error. mse :: Metric mse preds truth- | VU.null truth = 0+ | n == 0 = throw (EmptyDataSetException "mse") | otherwise =- VU.sum (VU.zipWith (\p t -> (p - t) ^ (2 :: Int)) preds truth) / n2 truth+ VU.sum (VU.zipWith (\p t -> (p - t) ^ (2 :: Int)) preds truth) / n+ where+ n = nCompared preds truth -- | Root mean squared error. rmse :: Metric@@ -90,30 +86,37 @@ -- | 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+ | n == 0 = throw (EmptyDataSetException "mae")+ | otherwise = VU.sum (VU.zipWith (\p t -> abs (p - t)) preds truth) / n+ where+ n = nCompared preds truth -- | Coefficient of determination @R²@. r2 :: Metric r2 preds truth- | VU.null truth || ssTot == 0 = 0+ | n == 0 = throw (EmptyDataSetException "r2")+ | 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)+ n = nCompared preds truth+ truth' = VU.take (min (VU.length preds) (VU.length truth)) truth+ mean = VU.sum truth' / n+ 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+ | n == 0 = throw (EmptyDataSetException "accuracy") | otherwise =- fromIntegral (VU.length (VU.filter id (VU.zipWith (==) preds truth))) / n2 truth+ fromIntegral (VU.length (VU.filter id (VU.zipWith (==) preds truth))) / n+ where+ n = nCompared preds truth -- | Binary log loss; probabilities clamped away from @0@/@1@. logLoss :: Metric logLoss probs truth- | VU.null truth = 0+ | n == 0 = throw (EmptyDataSetException "logLoss") | otherwise = negate ( VU.sum@@ -123,8 +126,9 @@ truth ) )- / n2 truth+ / n where+ n = nCompared probs truth clampP p = max 1e-15 (min (1 - 1e-15) p) -- | Averaging strategy for multiclass precision/recall/F1.@@ -207,32 +211,22 @@ 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@.--}+-- | Binary ROC-AUC (Mann-Whitney), tied scores sharing their average rank. 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+ n = VU.length scores+ sorted = VU.modify (VA.sortBy (comparing fst)) (VU.zip scores truth)+ nPos = fromIntegral (VU.length (VU.filter (== 1) truth))+ nNeg = fromIntegral n - nPos+ rankSum = go 0 0+ go !start !acc+ | start >= n = acc+ | otherwise =+ let v = fst (VU.unsafeIndex sorted start)+ end = maybe n (+ start) (VU.findIndex ((/= v) . fst) (VU.drop start sorted))+ rank = fromIntegral (start + 1 + end) / 2+ positives = VU.length (VU.filter ((== 1) . snd) (VU.slice start (end - start) sorted))+ in go end (acc + rank * fromIntegral positives)
src/DataFrame/Metrics/Report.hs view
@@ -1,8 +1,4 @@-{- | Bundled, pretty-printing evaluation summaries: a labelled confusion matrix-and scikit-learn-style regression / classification reports. The @*Expr@ variants-take a model's prediction expression and a truth column directly, so a full-report is a one-liner after fitting.--}+-- | Pretty-printing evaluation summaries: confusion matrix and reports. module DataFrame.Metrics.Report ( ConfusionMatrix (..), confusionMatrix,@@ -37,7 +33,7 @@ rmse, ) --- | A labelled confusion matrix: class order plus row-major @actual×predicted@.+-- | A labelled confusion matrix: class order plus row-major counts. data ConfusionMatrix = ConfusionMatrix { cmClasses :: ![Double] , cmCounts :: ![[Int]]@@ -115,9 +111,7 @@ } deriving (Eq, Show) -{- | A scikit-learn-style classification report: per-class stats plus accuracy-and macro/weighted F1.--}+-- | A scikit-learn-style classification report. data ClassificationReport = ClassificationReport { crPerClass :: ![(Double, ClassStats)] , crAccuracy :: !Double
src/DataFrame/Model.hs view
@@ -8,33 +8,7 @@ {-# 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.--}+-- | The two verbs every model speaks: fit and predict. module DataFrame.Model ( Fit (..), ToDataFrame (..),@@ -59,64 +33,33 @@ import GHC.TypeLits (Symbol) -{- | 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.).--}+-- | A model trained on a typed frame, carrying the schema as a phantom. newtype Fitted (cols :: [(Symbol, 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.--}+-- | The type fit returns: the bare model, or a schema-tagged Fitted. 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.--}+-- | The frame a training input is fit against, sharing its schema if typed. 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'.--}+-- | The schema requirement a model places on its training frame. 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.--}+-- | Turn a model's FrameKind requirement into a constraint on the frame. 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.--}+-- | Train a model from a config, an input and a frame. 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.- -}+ -- | The model this config and input train. type ModelOf cfg input :: Type type FrameReq cfg input :: FrameKind@@ -128,9 +71,7 @@ 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'.--}+-- | Lift a model fittable on an untyped target to a typed target. 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)@@ -142,10 +83,7 @@ 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@.--}+-- | Compile a fitted model's prediction to an expression over raw columns. class Predict model where type Prediction model :: Type predict :: model -> Prediction model
src/DataFrame/ModelSelection.hs view
@@ -1,29 +1,26 @@-{- | Cross-validation and grid search for hyperparameter tuning. The model-fitters have heterogeneous types, so these helpers are parameterized by a-user-supplied @train -> test -> score@ closure; the search maximizes the mean-cross-validated score (use a negated error metric to minimize). Splitting reuses-the deterministic 'kFolds' from @dataframe-operations@.--}+-- | Cross-validation and grid search for hyperparameter tuning. module DataFrame.ModelSelection ( crossValScore, crossValidate,+ stratifiedFoldIds,+ outOfFold, GridSearchResult (..), gridSearch, ) where -import Data.List (maximumBy)+import Data.List (maximumBy, sortOn) import Data.Ord (comparing)-import System.Random (mkStdGen)+import qualified Data.Set as S+import qualified Data.Vector.Unboxed as VU+import System.Random (mkStdGen, randoms) import DataFrame.Internal.DataFrame (DataFrame) import DataFrame.Internal.Expression (Expr) import DataFrame.Metrics (Metric, evaluate) import DataFrame.Operations.Merge ()-import DataFrame.Operations.Subset (kFolds)+import DataFrame.Operations.Subset (kFolds, rowsAtIndices) -{- | Per-fold scores from k-fold cross-validation. @scoreFn train test@ fits on-the training rows and returns a score on the held-out fold.--}+-- | Per-fold scores from k-fold cross-validation. crossValScore :: Int -> Int -> (DataFrame -> DataFrame -> Double) -> DataFrame -> [Double] crossValScore folds seed scoreFn df =@@ -36,15 +33,7 @@ others i = [f | (j, f) <- zip [0 ..] fs, j /= i] combine = foldr1 (<>) -{- | scikit-learn @cross_val_score@: fit a model on each training fold and score-its prediction expression against a truth column on the held-out fold.--@fitPredict train@ fits on the training frame and returns the prediction-expression; @truth@ is the target column. Returns the per-fold metric values.--> crossValidate 5 0 rmse (F.col @Double "target")-> (\tr -> predict (fit defaultLinearConfig (F.col @Double "target") tr)) df--}+-- | Fit on each training fold and score predictions on the held-out fold. crossValidate :: Int -> Int ->@@ -58,6 +47,35 @@ where score train = evaluate metric (fitPredict train) truth +-- | A class-balanced fold number in [0, k) for every row.+stratifiedFoldIds :: Int -> Int -> VU.Vector Double -> VU.Vector Int+stratifiedFoldIds k seed labels = VU.update (VU.replicate n 0) (VU.fromList assigned)+ where+ n = VU.length labels+ shuffled = map snd (sortOn fst (zip (randoms (mkStdGen seed) :: [Double]) [0 .. n - 1]))+ assigned =+ concat+ [ zip [i | i <- shuffled, labels VU.! i == c] (cycle [0 .. k - 1])+ | c <- S.toList (S.fromList (VU.toList labels))+ ]++-- | Out-of-fold scores: each row is scored by a model not trained on it.+outOfFold ::+ VU.Vector Int ->+ (DataFrame -> DataFrame -> VU.Vector Double) ->+ DataFrame ->+ VU.Vector Double+outOfFold folds fitScore df =+ VU.update+ (VU.replicate (VU.length folds) 0)+ (VU.concat (map score [0 .. k - 1]))+ where+ k = if VU.null folds then 0 else VU.maximum folds + 1+ score f =+ let test = VU.findIndices (== f) folds+ train = VU.findIndices (/= f) folds+ in VU.zip test (fitScore (rowsAtIndices train df) (rowsAtIndices test df))+ -- | The outcome of a grid search: the best config, its score, and all results. data GridSearchResult c = GridSearchResult { gsBest :: !c@@ -66,9 +84,7 @@ } deriving (Show) -{- | Search configurations by mean cross-validated score, returning the-maximizer. @scoreFn cfg train test@ fits @cfg@ on @train@ and scores on @test@.--}+-- | Search configurations by mean cross-validated score, returning the best. gridSearch :: Int -> Int ->
src/DataFrame/PCA.hs view
@@ -4,11 +4,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeFamilies #-} -{- | Principal component analysis via the symmetric Jacobi eigensolver on the-covariance of the (optionally standardized) feature columns. 'fit' trains a-'PCAModel' (components + explained variance); the projection is exposed as-'pcaExprs' / 'pcaTransform' (PCA is a transformer, so it has no 'Predict').--}+-- | Principal component analysis via a Jacobi eigensolver on the covariance. module DataFrame.PCA ( module DataFrame.Model, NComponents (..),@@ -27,7 +23,7 @@ import DataFrame.Featurize.Internal (Features (..), extractFeatures) import qualified DataFrame.Functions as F import DataFrame.Internal.DataFrame (DataFrame)-import DataFrame.Internal.Expression (Expr (..), UExpr (..))+import DataFrame.Internal.Expression (Expr (..), SomeExpr (..)) import DataFrame.LinearAlgebra (gram) import DataFrame.LinearAlgebra.Eigen (jacobiEigenSym) import DataFrame.Model@@ -46,10 +42,7 @@ defaultPCAConfig :: PCAConfig defaultPCAConfig = PCAConfig{pcaNComponents = NComp 2, pcaStandardize = False} -{- | A fitted PCA. 'pcaComponents' are sklearn's @components_@ (row @i@ is the-@i@-th loading vector); 'pcaScale' is @Just@ the per-column std when-standardizing.--}+-- | A fitted PCA with components and explained variance. data PCAModel = PCAModel { pcaComponents :: !(V.Vector (VU.Vector Double)) , pcaExplainedVariance :: !(VU.Vector Double)@@ -118,7 +111,7 @@ -- | The PCA projection as a composable fitted 'Transform'. pcaTransform :: PCAModel -> Transform-pcaTransform m = Transform [(n, UExpr e) | (n, e) <- pcaExprs m]+pcaTransform m = Transform [(n, SomeExpr e) | (n, e) <- pcaExprs m] resolveK :: NComponents -> Int -> VU.Vector Double -> Int resolveK (NComp k) d _ = max 1 (min k d)
src/DataFrame/PCA/Kernel.hs view
@@ -3,11 +3,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeFamilies #-} -{- | Kernel PCA with an RBF kernel, solved on a set of landmark points (Nyström).-Exact kernel PCA when the landmark count covers every row, a principled-approximation otherwise. 'fit' trains the model; the projection is exposed as-'kernelPCAExprs' / 'kernelPcaTransform' (a transformer, so no 'Predict').--}+-- | Kernel PCA with an RBF kernel, solved on Nystrom landmark points. module DataFrame.PCA.Kernel ( module DataFrame.Model, KernelPCAConfig (..),@@ -25,7 +21,7 @@ import DataFrame.Featurize.Internal (Features (..), extractFeatures) import qualified DataFrame.Functions as F import DataFrame.Internal.DataFrame (DataFrame)-import DataFrame.Internal.Expression (Expr (..), UExpr (..))+import DataFrame.Internal.Expression (Expr (..), SomeExpr (..)) import DataFrame.LinearAlgebra (sqDist) import DataFrame.LinearAlgebra.Eigen (jacobiEigenSym) import DataFrame.Model@@ -49,9 +45,7 @@ , kpcaSeed = 0 } -{- | A fitted kernel PCA. Each component is @Σ_l βₗ·K(x, landmarkₗ) + cᵢ@ with an-RBF kernel of bandwidth 'kpcaGammaUsed'.--}+-- | A fitted kernel PCA using an RBF kernel on landmark points. data KernelPCAModel = KernelPCAModel { kpcaLandmarks :: !(V.Vector (VU.Vector Double)) , kpcaBetas :: !(V.Vector (VU.Vector Double))@@ -136,4 +130,4 @@ -- | The kernel-PCA projection as a composable fitted 'Transform'. kernelPcaTransform :: KernelPCAModel -> Transform-kernelPcaTransform m = Transform [(n, UExpr e) | (n, e) <- kernelPCAExprs m]+kernelPcaTransform m = Transform [(n, SomeExpr e) | (n, e) <- kernelPCAExprs m]
src/DataFrame/SVM.hs view
@@ -4,10 +4,7 @@ {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-} -{- | Linear support vector classification: L2-regularized squared hinge via-FISTA (sklearn's LinearSVC default). 'fit' trains a one-vs-rest 'LinearSVCModel';-'predict' is the arg-max class margin (no @predict_proba@, as in sklearn).--}+-- | Linear support vector classification via FISTA (squared hinge, L2). module DataFrame.SVM ( module DataFrame.Model, LinearSVCModel (..),@@ -37,7 +34,7 @@ import DataFrame.LinearSolver.Loss (sqHingeLoss) import DataFrame.Model --- | Hyper-parameters. @svcC@ is the inverse regularization strength (sklearn @C@).+-- | Hyper-parameters; svcC is the inverse regularization strength. data SVCConfig = SVCConfig { svcC :: !Double , svcMaxIter :: !Int
src/DataFrame/SVM/RFF.hs view
@@ -6,11 +6,7 @@ {-# 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.--}+-- | Approximate RBF-kernel SVM via Random Fourier Features. module DataFrame.SVM.RFF ( module DataFrame.Model, RFFConfig (..),@@ -58,9 +54,7 @@ , rffSeed = 0 } -{- | A fitted RFF SVM (binary). 'rffW' / 'rffB' are the random projection;-'rffCoef' / 'rffIntercept' the linear SVC in feature space.--}+-- | A fitted binary RFF SVM: random projection plus a linear SVC. data RFFSVMModel a = RFFSVMModel { rffW :: !(V.Vector (VU.Vector Double)) , rffB :: !(VU.Vector Double)
src/DataFrame/Segmented.hs view
@@ -9,10 +9,7 @@ {-# LANGUAGE TypeOperators #-} {-# LANGUAGE UndecidableInstances #-} -{- | Fit a separate base model per categorical value-combination, routing each-row to its segment at predict time; unseen or too-small segments fall back to a-global fit. Optional partial pooling (linear base) shrinks small segments.--}+-- | Fit a separate base model per categorical value-combination. module DataFrame.Segmented ( module DataFrame.Model, Segmented (..),@@ -25,7 +22,8 @@ ) where import Control.Exception (throw)-import Data.List (foldl', (\\))+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@@ -59,10 +57,7 @@ import DataFrame.Operations.Subset (exclude, rowsAtIndices) import DataFrame.SymbolicRegression (SRConfig) -{- | A base estimator @cfg@ wrapped to fit one model per categorical-value-combination. @segOn@ picks the columns ('Nothing' = auto-detect),-@segMinRows@ the smallest own-model segment, @segPool@ the pooling strength @λ@.--}+-- | A base estimator wrapped to fit one model per categorical combination. data Segmented cfg = Segmented { segBase :: !cfg , segOn :: !(Maybe [T.Text])@@ -72,11 +67,11 @@ } deriving (Eq, Show) --- | Wrap a base config with the defaults: auto-detect, cap 32, min 30 rows, no pooling.+-- | 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 columns (each must be Text), overriding auto-detect.+-- | Segment only on the named Text columns, overriding auto-detect. segmentOn :: Segmented cfg -> [T.Text] -> Segmented cfg segmentOn s cols = s{segOn = Just cols} @@ -92,10 +87,7 @@ } deriving (Show) -{- | A fitted segmented model: the columns segmented on, the per-segment models-(ascending key order), the observed combinations that fell back (key + row-count), the global fallback model, and the compiled routing expression.--}+-- | A fitted segmented model with per-segment, fallback and routing parts. data SegmentedModel a model = SegmentedModel { smCatCols :: ![T.Text] , smSegments :: ![Segment model]@@ -105,10 +97,7 @@ } deriving (Show) -{- | How a base estimator fits its per-segment models under pooling strength @λ@.-The default fits each segment independently and rejects @λ > 0@; the linear-instance overrides it with closed-form shrinkage. Every base model needs an instance.--}+-- | 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)]@@ -185,14 +174,11 @@ expr = buildExpr catCols segments globalM in SegmentedModel catCols segments fellBack globalM expr --- | 'span' over a predicate that need not hold contiguously (a filter partition).+-- | '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 of @key == value@ conjuncts,-ending in the fallback model's prediction. Keys are disjoint, so order is-immaterial.--}+-- | Compile the routing: a right-folded If ladder ending in the fallback. buildExpr :: (Columnable a, Predict model, Prediction model ~ Expr a) => [T.Text] ->@@ -210,10 +196,7 @@ keyCond catCols vals = foldr1 (.&&.) [(Col c :: Expr T.Text) .==. Lit v | (c, v) <- zip catCols vals] -{- | The Text feature columns to segment on, chosen from the frame's Text features-@textCols@. An explicit list is validated to be all-Text; auto-detect keeps Text-columns with at most @maxCard@ distinct values.--}+-- | 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@@ -235,10 +218,7 @@ distinctCount df c = Set.size (Set.fromList (V.toList (columnToTextVec (unsafeGetColumn c df)))) -{- | Reject feature columns that are neither Text (dropped\/segmented) nor-non-null 'Double', naming each with its fix — clearer than the base fitter's-raw type mismatch.--}+-- | Reject feature columns that are neither Text nor non-null Double. guardNumeric :: DataFrame -> [T.Text] -> Expr a -> () guardNumeric df textCols target = case problems of@@ -271,9 +251,7 @@ _ -> False _ -> False -{- | Group row indices by their composite categorical key, dropping rows whose key-has a null in any segmented column (served by the fallback). Ascending key order.--}+-- | 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)@@ -285,15 +263,12 @@ validRow i = all (maybe True (`bitmapTestBit` i)) bitmaps keyOf i = [tv V.! i | tv <- textVecs] grouped =- foldl'+ 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. @λ = 0@ is independent OLS;-@λ > 0@ shrinks each segment's coefficients toward the @n_g@-weighted mean of the-per-segment fits.--}+-- | Linear segments with exact closed-form pooling. instance SegmentFit LinearConfig Double where fitSegments cfg lam target dfs | lam == 0 = map (fit cfg target) dfs
src/DataFrame/SymbolicRegression.hs view
@@ -3,11 +3,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeFamilies #-} -{- | Symbolic regression by genetic programming (modelled on the-@symbolic-regression@ library, ported dependency-light: no e-graphs, no NLOPT).-'predict' is the best discovered @Expr Double@; the search also returns the-accuracy-vs-complexity Pareto front. Deterministic given the seed.--}+-- | Symbolic regression by genetic programming, returning a Pareto front. module DataFrame.SymbolicRegression ( module DataFrame.Model, UnOp (..),@@ -63,9 +59,7 @@ , srUnaryOps = allUnOps } -{- | A fitted symbolic regressor. 'srBest' is the lowest-error expression;-'srPareto' is the @(complexity, mse, expr)@ frontier.--}+-- | A fitted symbolic regressor: best expression plus Pareto frontier. data SRModel = SRModel { srBest :: !(Expr Double) , srBestMSE :: !Double
src/DataFrame/Synthesis.hs view
@@ -5,38 +5,7 @@ {-# 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/.--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 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.--}+-- | Feature synthesis by bottom-up enumerative search over expressions. module DataFrame.Synthesis ( module DataFrame.Model, LossFunction (..),@@ -72,7 +41,7 @@ import DataFrame.Model import DataFrame.Operations.Core (columnAsDoubleVector) --- | How a candidate's output column is scored against the target (higher is better).+-- | How a candidate's output column is scored against the target. data LossFunction = -- | Pearson @r²@: scale-invariant, the default for derived features. PearsonCorrelation@@ -106,9 +75,7 @@ , synMaxAllocBytes = 8 * 1024 * 1024 * 1024 } -{- | A synthesized feature. 'sfExpr' is the best-scoring expression and 'sfFeatures'-is the ranked, observationally-distinct bank (expression and its score).--}+-- | A synthesized feature: the best expression and a ranked bank. data SynthesizedFeature = SynthesizedFeature { sfExpr :: !(Expr Double) , sfScore :: !Double@@ -156,7 +123,7 @@ ((e, s) : _) -> (e, s) [] -> (Lit 0, negate (1 / 0)) --- | Size-1 programs: numeric feature columns and a pool of constants, OE-deduped.+-- | Size-1 programs: numeric feature columns and a pool of constants. mkLeaves :: DataFrame -> [T.Text] -> Int -> [Prog] mkLeaves df feats n = fst (dedupProgs M.empty candidates) where@@ -167,9 +134,7 @@ ] ++ [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.)--}+-- | Domain-informed constants: column quartiles, variance, std, small ints. constantPool :: DataFrame -> [T.Text] -> [Double] constantPool df feats = [0, 1, 2, -1]@@ -244,7 +209,7 @@ , (F.max, max) ] --- | Ordered operand pairs whose sizes sum to @size-1@ (for non-commutative ops).+-- | Ordered operand pairs whose sizes sum to size-1. orderedPairs :: Int -> [Prog] -> [(Prog, Prog)] orderedPairs size bank = [ (a, b)@@ -272,9 +237,7 @@ 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.--}+-- | Keep valid, observationally-novel candidates of a layer, capped by score. absorb :: SynthesisConfig -> Output -> Seen -> [Prog] -> ([Prog], Seen) absorb cfg tgt seen0 cands = (capLayer cfg tgt fresh, seen')@@ -306,8 +269,7 @@ where budget = synMaxAllocBytes cfg bytesPerProg = 8 * max 1 n- -- 4 commutative ops over unordered pairs plus sub and div over ordered- -- pairs, all quadratic in the bank; unaries and powers are lower order.+ -- Quadratic in the bank: 4 commutative ops plus ordered sub and div. binaryOpCount = 4 :: Int -- Layer k pairs the capped bank with itself over the binary operators. candidatesAt k@@ -324,7 +286,7 @@ ] ) --- | When a layer has more distinct programs than the cap, keep the best-scoring.+-- | When a layer exceeds the cap, keep the best-scoring programs. capLayer :: SynthesisConfig -> Output -> [Prog] -> [Prog] capLayer cfg tgt progs | length progs <= synBankCap cfg = progs@@ -342,15 +304,11 @@ 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.+ -- Drop non-finite scores so they 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@.--}+-- | Pearson r-squared via the stable centered two-pass formula. pearsonR2 :: Output -> Output -> Maybe Double pearsonR2 ys xs | n < 2 = Nothing@@ -369,18 +327,11 @@ 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 an output to nine significant digits to absorb float noise. 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.--}+-- | The observational-equivalence class set, bucketed by output fingerprint. type Seen = M.Map Int [Output] -- | FNV-1a fingerprint of an already-quantized output's bit patterns.@@ -404,9 +355,7 @@ 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.--}+-- | Keep the first valid program of each observational-equivalence class. dedupProgs :: Seen -> [Prog] -> ([Prog], Seen) dedupProgs = go [] where
src/DataFrame/Transform.hs view
@@ -2,15 +2,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | Fitted column transforms as a composable monoid. A 'Transform' is a list of-named output expressions; @s <> t@ means \"apply @s@, then @t@\", fusing @t@'s-references to @s@'s outputs by simultaneous substitution. 'applyTransform' runs-one against a frame; 'compileThrough' folds a transform into a model's-prediction expression so the result is a single expression over the raw inputs.--Every right-hand side must be row-wise (no aggregation/window), and within one-transform each expression reads the original frame.--}+-- | Fitted column transforms as a composable monoid. module DataFrame.Transform ( Transform (..), applyTransform,@@ -33,7 +25,7 @@ import DataFrame.Internal.Expression ( Expr (..), NamedExpr,- UExpr (..),+ SomeExpr (..), substituteColumns, ) import DataFrame.Operations.Core (columnAsDoubleVector)@@ -46,8 +38,8 @@ Transform s <> Transform t = Transform (s ++ map (subst (M.fromList s)) t) where- subst :: M.Map T.Text UExpr -> NamedExpr -> NamedExpr- subst m (nm, UExpr e) = (nm, UExpr (substituteColumns m e))+ subst :: M.Map T.Text SomeExpr -> NamedExpr -> NamedExpr+ subst m (nm, SomeExpr e) = (nm, SomeExpr (substituteColumns m e)) instance Monoid Transform where mempty = Transform []@@ -56,9 +48,7 @@ applyTransform :: Transform -> DataFrame -> DataFrame applyTransform (Transform os) = deriveMany os -{- | Fold a preprocessing transform into a model's prediction expression,-yielding one expression over the transform's input columns.--}+-- | Fold a preprocessing transform into a model's prediction expression. compileThrough :: (Columnable a) => Transform -> Expr a -> Expr a compileThrough (Transform os) = substituteColumns (M.fromList os) @@ -94,7 +84,7 @@ scalerTransform :: ScalerModel -> Transform scalerTransform m = Transform- [ (n, UExpr ((F.col @Double n .-. F.lit mu) ./. F.lit sigma))+ [ (n, SomeExpr ((F.col @Double n .-. F.lit mu) ./. F.lit sigma)) | (n, mu, sigma) <- zip3 (V.toList (smColumns m))
src/DataFrame/Transform/Serialize.hs view
@@ -1,13 +1,4 @@-{- | Persist and reload a fitted 'Transform'.--A 'Transform' is just an ordered list of named output expressions, so it-serializes through the same JSON wire format as any pipeline (see-"DataFrame.Expr.Serialize"). Save a fitted preprocessing/feature transform in one-process and reload it in another with 'applyTransform' to run inference:--> Right t <- loadTransformFromFile "scaler.json"-> let scored = applyTransform t newData--}+-- | Persist and reload a fitted Transform. module DataFrame.Transform.Serialize ( encodeTransform, decodeTransform,
tests-internal/Cart.hs view
@@ -1,10 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeApplications #-} -{- | Agreement tests: 'buildCartTree' must predict identically to sklearn's-@DecisionTreeClassifier(random_state=0, max_depth=4)@ on shared folds (golden-fixtures from @bench/export_cart_fixtures.py@); missing fixtures SKIP.--}+-- | Agreement tests: buildCartTree must predict identically to sklearn. module Cart (tests) where import Control.Exception (SomeException, try)@@ -35,8 +32,7 @@ instance FromJSON Fixture where parseJSON = withObject "fixture" $ \o -> Fixture <$> o .: "test_index" <*> o .: "test_pred" --- sklearn cart_d4 params: max_depth 4, min_samples_leaf 1 (min_samples_split--- is fixed at 2 inside buildCartTree).+-- sklearn cart_d4 params: max_depth 4, min_samples_leaf 1. cartCfg :: TreeConfig cartCfg = defaultTreeConfig{maxTreeDepth = 4, minLeafSize = 1} @@ -57,9 +53,7 @@ Left _ -> Left "missing" Right raw -> eitherDecode raw --- wine is tie-free so sklearn is deterministic (exact match); bcw/adult have--- equal-gain ties sklearn breaks via a seeded feature permutation, so we only--- report the match fraction rather than chase its RNG.+-- wine is tie-free (exact match); bcw/adult report the match fraction. runCase :: String -> Int -> IO () runCase name i = do efx <- readJson ("tests/fixtures/cart/" ++ name ++ "_fold" ++ show i ++ ".json")
tests-internal/DataFrameApi.hs view
@@ -1,10 +1,7 @@-{- | The umbrella subset these internal tests use. The suite cannot depend on-the meta @dataframe@ package (that would be a package-level cycle), so the-names the tests reach for are re-exported under one alias here.--}+-- | The umbrella subset these internal tests use, re-exported under one alias. module DataFrameApi ( DataFrame,- fromNamedColumns,+ fromColumns, fromUnnamedColumns, dimensions, nRows,@@ -15,7 +12,7 @@ readCsv, ) where -import DataFrame.Core (DataFrame, fromNamedColumns)+import DataFrame.Core (DataFrame, fromColumns) import DataFrame.IO.CSV (readCsv) import DataFrame.Operations.Core (dimensions, fromUnnamedColumns, nRows, rename) import DataFrame.Operations.Subset (exclude, randomSplit)
tests-internal/DecisionTree.hs view
@@ -25,6 +25,7 @@ probExprs, probsFromIndices, )+import DataFrame.DecisionTree.Linear (fitLinearCandidate) import DataFrame.DecisionTree.Numeric ( NumExpr (NMaybeDouble), generateNumericConds,@@ -46,7 +47,6 @@ import DataFrame.Expression.Operators import qualified DataFrame.Functions as F import qualified DataFrame.Internal.Column as DI-import qualified DataFrame.Internal.Column.Bitmap as DI import DataFrame.Internal.Data.PackedText (mkPackedContiguous) import DataFrame.Internal.Expression (Expr (..), eqExpr, getColumns) import DataFrame.Internal.Interpreter (interpret)@@ -66,13 +66,7 @@ import Data.Word (Word8) import Test.HUnit ---------------------------------------------------------------------------- Shared fixtures---------------------------------------------------------------------------{- | Build a 'TargetInfo' or fail loudly; the test fixtures always satisfy-'mkTargetInfo', so a 'Nothing' here is a broken test, not a runtime case.--}+-- | Build a TargetInfo or fail loudly; fixtures always satisfy mkTargetInfo. requireTargetInfo :: T.Text -> D.DataFrame -> TargetInfo T.Text requireTargetInfo target df = case mkTargetInfo @T.Text target df of Just ti -> ti@@ -81,7 +75,7 @@ -- 4 rows: label = ["A","B","A","C"], x = [1.0,2.0,3.0,4.0] fixtureDF :: D.DataFrame fixtureDF =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (["A", "B", "A", "C"] :: [T.Text])) , ("x", DI.fromList ([1.0, 2.0, 3.0, 4.0] :: [Double])) ]@@ -104,10 +98,6 @@ carePoints3 = identifyCarePoints @T.Text "label" fixtureDF allIndices leftTree rightTree ---------------------------------------------------------------------------- Unit tests: identifyCarePoints-------------------------------------------------------------------------- carePointsBothWrong :: Test carePointsBothWrong = TestCase $@@ -149,7 +139,7 @@ carePointsBothCorrect :: Test carePointsBothCorrect = TestCase $ do let df2 =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (["A", "A"] :: [T.Text])) , ("x", DI.fromList ([1.0, 2.0] :: [Double])) ]@@ -162,14 +152,10 @@ (Leaf "A") assertEqual "no care points when both subtrees agree" 0 (length cps) ---------------------------------------------------------------------------- Unit tests: majorityValueFromIndices-------------------------------------------------------------------------- majorityVoteTest :: Test majorityVoteTest = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (["cat", "dog", "cat", "cat"] :: [T.Text])) , ("x", DI.fromList ([1.0, 2.0, 3.0, 4.0] :: [Double])) ]@@ -181,21 +167,17 @@ majorityVoteSubset :: Test majorityVoteSubset = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (["cat", "dog", "cat", "cat"] :: [T.Text])) , ("x", DI.fromList ([1.0, 2.0, 3.0, 4.0] :: [Double])) ] result = majorityValueFromIndices @T.Text "label" df (V.fromList [0, 1, 3]) assertEqual "majority from subset [0,1,3] is cat" "cat" result ---------------------------------------------------------------------------- Unit tests: computeTreeLoss-------------------------------------------------------------------------- computeLossZero :: Test computeLossZero = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (["A", "A", "B", "B"] :: [T.Text])) , ("x", DI.fromList ([1.0, 2.0, 3.0, 4.0] :: [Double])) ]@@ -206,7 +188,7 @@ computeLossHalf :: Test computeLossHalf = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (["A", "A", "B", "B"] :: [T.Text])) , ("x", DI.fromList ([1.0, 2.0, 3.0, 4.0] :: [Double])) ]@@ -214,10 +196,6 @@ loss = computeTreeLoss @T.Text "label" df (V.fromList [0, 1, 2, 3]) constTree assertEqual "constant leaf misclassifies half of balanced data" 0.5 loss ---------------------------------------------------------------------------- Unit tests: partitionIndices-------------------------------------------------------------------------- partitionDisjoint :: Test partitionDisjoint = TestCase $ do let (lft, rgt) = partitionIndices splitCond fixtureDF allIndices@@ -235,10 +213,6 @@ [0, 1, 2, 3] combined ---------------------------------------------------------------------------- Unit tests: countCarePointErrors-------------------------------------------------------------------------- countErrorsAllCorrect :: Test countErrorsAllCorrect = TestCase $ do let cps = [CarePoint 0 GoLeft, CarePoint 1 GoRight]@@ -253,10 +227,6 @@ errs = countCarePointErrors cond fixtureDF cps assertEqual "reversed condition misroutes all care points" 2 errs ---------------------------------------------------------------------------- Unit tests: predictWithTree-------------------------------------------------------------------------- predictLeaf :: Test predictLeaf = TestCase $@@ -277,16 +247,12 @@ "B" (predictWithTree @T.Text "label" fixtureDF 3 stump) ---------------------------------------------------------------------------- Integration tests-------------------------------------------------------------------------- -- 20-row, linearly separable: x in [1..10] -> "pos", x in [11..20] -> "neg" sepDF :: D.DataFrame sepDF = let xs = map fromIntegral [1 .. 20 :: Int] :: [Double] labels = map (\x -> if x <= 10.0 then "pos" else "neg") xs :: [T.Text]- in D.fromNamedColumns+ in D.fromColumns [ ("label", DI.fromList labels) , ("x", DI.fromList xs) ]@@ -343,7 +309,7 @@ taoConvergesPureLabels :: Test taoConvergesPureLabels = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (replicate 10 ("A" :: T.Text))) , ("x", DI.fromList ([1.0 .. 10.0] :: [Double])) ]@@ -373,7 +339,7 @@ gridBaseDF :: D.DataFrame gridBaseDF =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList (map fst gridPairs)) , ("y", DI.fromList (map snd gridPairs)) ]@@ -497,11 +463,10 @@ 0.0 finalLoss --- Cleanly separable nullable column (no actual nulls): Just 1..6 -> "pos",--- Just 7..12 -> "neg". Exercises the nullable numeric path.+-- Separable nullable column, no actual nulls: Just 1..6 pos, Just 7..12 neg. nullableSepDF :: D.DataFrame nullableSepDF =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (replicate 6 "pos" ++ replicate 6 "neg" :: [T.Text])) , ( "x"@@ -517,7 +482,7 @@ -- DF with genuine nulls interspersed. nullsMixedDF :: D.DataFrame nullsMixedDF =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (["pos", "pos", "pos", "neg", "neg", "neg"] :: [T.Text])) , ( "x"@@ -538,11 +503,11 @@ "numericCols finds NMaybeDouble for DI.fromVector (Maybe Double)" hasMD --- numericCols picks up DI.fromVector (Maybe Int) as NMaybeDouble (via whenPresent).+-- numericCols picks up DI.fromVector (Maybe Int) as NMaybeDouble. numericColsNullableIntTest :: Test numericColsNullableIntTest = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (["pos", "neg"] :: [T.Text])) , ( "n"@@ -552,7 +517,7 @@ hasMD = any (\case NMaybeDouble _ -> True; _ -> False) (numericCols df) assertBool "numericCols finds NMaybeDouble for DI.fromVector (Maybe Int)" hasMD --- generateNumericConds is non-empty for a DF with an DI.fromVector (Maybe Double).+-- generateNumericConds is non-empty for a nullable (Maybe Double) column. numericCondsNullableNonEmptyTest :: Test numericCondsNullableNonEmptyTest = TestCase $@@ -560,11 +525,11 @@ "generateNumericConds non-empty for DI.fromVector (Maybe Double)" (not (null (generateNumericConds defaultTreeConfig nullableSepDF))) --- Null values evaluate to False for threshold conditions (null rows route right).+-- Null values fail threshold conditions, so null rows route right. nullValueRoutesFalseTest :: Test nullValueRoutesFalseTest = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (["A", "B"] :: [T.Text])) , ( "x"@@ -577,7 +542,7 @@ assertBool "null row (idx 0) routes to right (false) partition" (0 `V.elem` rgt) assertBool "Just 5.0 <= 6.0 routes to left (true) partition" (1 `V.elem` lft) --- Nullable feature (no actual nulls) achieves zero loss on cleanly separable data.+-- Nullable feature with no actual nulls reaches zero loss on separable data. nullableFitZeroLossTest :: Test nullableFitZeroLossTest = TestCase $ do let cfg = defaultTreeConfig{taoIterations = 5, expressionPairs = 4, minLeafSize = 1}@@ -603,12 +568,11 @@ "loss is in [0,1] with null values present" (loss >= 0.0 && loss <= 1.0) --- numericExprsWithTerms produces cross-column combinations when one col is--- DI.fromVector (Maybe Double) and another is a plain UnboxedColumn Double.+-- numericExprsWithTerms crosses a Maybe Double column with a plain Double one. numericExprsWithTermsMixedTest :: Test numericExprsWithTermsMixedTest = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ( "x" , DI.fromVector@@ -629,9 +593,12 @@ missingnessCondsTest = TestCase $ do let conds = missingnessConditions (D.exclude ["label"] nullsMixedDF) assertEqual "one nullable column -> one missingness cond" 1 (length conds)- assertBool- "cond is isNothing x"- (eqExpr (head conds) (F.isNothing (F.col @(Maybe Double) "x")))+ case conds of+ [c] ->+ assertBool+ "cond is isNothing x"+ (eqExpr c (F.isNothing (F.col @(Maybe Double) "x")))+ _ -> assertFailure "expected exactly one condition" assertBool "no missingness conds for a non-nullable DataFrame" (null (missingnessConditions fixtureDF))@@ -662,7 +629,7 @@ observedOnlyThresholdsTest :: Test observedOnlyThresholdsTest = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ( "x" , DI.fromVector@@ -694,22 +661,23 @@ zipWithM_ (A.unsafeWrite m) [0 ..] ws pure m --- G5: nullable PackedText columns (what CSV ingest emits for nullable text)--- get a missingness candidate; non-nullable PackedText does not.+-- G5: nullable PackedText columns get a missingness candidate; others do not. packedMissingnessTest :: Test packedMissingnessTest = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [("s", packedFromTexts (Just [1, 3]) ["yes", "", "no", ""])] isMissingS = F.isNothing (F.col @(Maybe T.Text) "s") conds = missingnessConditions df assertEqual "one missingness cond for nullable PackedText" 1 (length conds)- assertBool "cond is isNothing s" (eqExpr (head conds) isMissingS)+ case conds of+ [c] -> assertBool "cond is isNothing s" (eqExpr c isMissingS)+ _ -> assertFailure "expected exactly one condition" assertBool "no missingness cond for non-nullable PackedText" ( null ( missingnessConditions- (D.fromNamedColumns [("t", packedFromTexts Nothing ["a", "b"])])+ (D.fromColumns [("t", packedFromTexts Nothing ["a", "b"])]) ) ) case filter (eqExpr isMissingS . cvExpr) (numericCondVecs defaultTreeConfig df df) of@@ -723,7 +691,7 @@ probsFromIndicesBasic :: Test probsFromIndicesBasic = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (["A", "A", "B"] :: [T.Text])) , ("x", DI.fromList ([1.0, 2.0, 3.0] :: [Double])) ]@@ -735,7 +703,7 @@ probsFromIndicesSubset :: Test probsFromIndicesSubset = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (["A", "A", "B", "B"] :: [T.Text])) , ("x", DI.fromList ([1.0, 2.0, 3.0, 4.0] :: [Double])) ]@@ -752,7 +720,7 @@ buildProbTreeLeaf :: Test buildProbTreeLeaf = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList (["A", "A", "A"] :: [T.Text])) , ("x", DI.fromList ([1.0, 2.0, 3.0] :: [Double])) ]@@ -950,7 +918,7 @@ | i <- [0 .. n - 1] ] df =- D.fromNamedColumns+ D.fromColumns [ ("label", DI.fromList labels) , ("a", DI.fromList avals) , ("b", DI.fromList bs)@@ -985,7 +953,7 @@ bs = [fromIntegral (i `mod` 3) :: Double | i <- [0 .. n - 1]] targets = [if even i then 1.0 else 0.0 :: Double | i <- [0 .. n - 1]] df =- D.fromNamedColumns+ D.fromColumns [ ("target", DI.fromList targets) , ("a", DI.fromList as) , ("b", DI.fromList bs)@@ -1018,8 +986,7 @@ r2 = fitDecisionTree @T.Text cfg (Col "label") sepDF assertBool "fitDecisionTree is deterministic on the same input" (eqExpr r1 r2) --- D1: One care point — solver must not crash; integration should fall back--- gracefully (via minCarePointsForLinear) and rely on the discrete path.+-- D1: One care point; the solver must not crash and should fall back. taoLinearTinyCareSet :: Test taoLinearTinyCareSet = TestCase $ do let cfg =@@ -1173,7 +1140,7 @@ -- A small synthetic DataFrame to materialize CondVecs against. threshFixtureDF :: D.DataFrame threshFixtureDF =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList ([0.0, 1.0, 2.0, 3.0, 4.0, 5.0] :: [Double])) , ("y", DI.fromList ([5.0, 4.0, 3.0, 2.0, 1.0, 0.0] :: [Double])) ]@@ -1334,8 +1301,7 @@ (F.and (cvExpr a) (cvExpr b)) (cvExpr r) --- Semantic-preservation spot check: the consolidated cvVec matches the--- elementwise AND/OR of the inputs at every row of a synthetic DataFrame.+-- The consolidated cvVec equals the elementwise AND/OR of the inputs. threshSemanticPreservation :: Test threshSemanticPreservation = TestCase $ do let a = materializeOrFail (F.col @Double "x" .>. F.lit (1.0 :: Double))@@ -1353,13 +1319,34 @@ expectedOr (cvVec rOr) ---------------------------------------------------------------------------- Test list-------------------------------------------------------------------------+-- Regression: oblique candidates on Int features were silently dropped.+obliqueOnIntColumns :: Test+obliqueOnIntColumns = TestCase $ do+ let as = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 2, 4, 6, 8, 1, 3, 5, 7, 9] :: [Int]+ bs = [9, 7, 5, 3, 1, 8, 6, 4, 2, 0, 1, 3, 5, 7, 9, 0, 2, 4, 6, 8] :: [Int]+ labels = [if a + b > 9 then "yes" else "no" | (a, b) <- zip as bs] :: [T.Text]+ df =+ D.fromColumns+ [("a", DI.fromList as), ("b", DI.fromList bs), ("label", DI.fromList labels)]+ cps =+ [ CarePoint i (if l == "yes" then GoRight else GoLeft)+ | (i, l) <- zip [0 ..] labels+ ]+ case fitLinearCandidate defaultTreeConfig "label" df cps of+ Nothing -> assertFailure "no oblique candidate on Int features"+ Just cond -> case interpret @Bool df cond of+ Left err -> assertFailure ("oblique condition does not evaluate: " ++ show err)+ Right (DI.TColumn col) -> case DI.toVector @Bool @V.Vector col of+ Left err -> assertFailure (show err)+ Right v -> do+ let sides = V.toList (v :: V.Vector Bool)+ assertBool "both sides non-empty" (or sides && not (and sides))+ assertBool "uses both features" (length (getColumns cond) == 2) tests :: [Test] tests =- [ TestLabel "carePointsBothWrong" carePointsBothWrong+ [ TestLabel "obliqueOnIntColumns" obliqueOnIntColumns+ , TestLabel "carePointsBothWrong" carePointsBothWrong , TestLabel "carePointsLeftCorrect" carePointsLeftCorrect , TestLabel "carePointsRightCorrect" carePointsRightCorrect , TestLabel "carePointsMixed" carePointsMixed
+ tests-internal/HistogramTrees.hs view
@@ -0,0 +1,124 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++-- | Histogram trees: compiled routing must match fitting, and exact trees.+module HistogramTrees (tests) where++import Data.Either (fromRight)+import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import Test.HUnit++import DataFrame.DecisionTree.Cart (cartFeatures)+import DataFrame.DecisionTree.Fit (treeToExpr)+import DataFrame.DecisionTree.Histogram (+ TreeLimits (..),+ binFeatures,+ fitBinnedTree,+ )+import DataFrame.DecisionTree.Regression (+ RegFit (..),+ RegTreeConfig (..),+ defaultRegTreeConfig,+ fitRegTree,+ )+import DataFrame.DecisionTree.Types (Tree)+import qualified DataFrame.Internal.Column as DI+import DataFrame.Internal.Interpreter (interpret)+import qualified DataFrameApi as D++tests :: [Test]+tests =+ [ TestLabel+ "histogram tree: in-sample == interpreted (mixed nullable frame)"+ inSampleMatchesInterpret+ , TestLabel+ "histogram tree: in-sample == interpreted (more values than bins)"+ coarseBinsMatchInterpret+ , TestLabel "histogram tree: same partition as the exact tree" matchesExactTree+ ]++limits :: Int -> TreeLimits+limits depth = TreeLimits depth 2 1 0 0 0++fitBinned ::+ Int -> Int -> D.DataFrame -> VU.Vector Double -> (Tree Double, VU.Vector Double)+fitBinned bins depth df y =+ fitBinnedTree+ (limits depth)+ (binFeatures bins (V.fromList (cartFeatures "y" df)))+ y+ Nothing++interpreted :: D.DataFrame -> Tree Double -> VU.Vector Double+interpreted df t = case interpret @Double df (treeToExpr t) of+ Right (DI.TColumn c) -> fromRight VU.empty (DI.toVector @Double @VU.Vector c)+ Left e -> error (show e)++maybeCol :: (DI.Columnable a) => [Maybe a] -> DI.Column+maybeCol = DI.fromVector . V.fromList++-- Nullable Double, Int and Text columns plus a plain one, as in NullSplits.+mixedFrame :: Int -> ([Double], D.DataFrame)+mixedFrame n = (y, D.fromColumns (("y", DI.fromList y) : cols))+ where+ rows = [0 .. n - 1]+ md =+ [ if i `mod` 7 == 0+ then Nothing+ else Just (fromIntegral ((i * 37) `mod` 23) :: Double)+ | i <- rows+ ]+ mi =+ [if i `mod` 5 == 2 then Nothing else Just ((i * 11) `mod` 9 :: Int) | i <- rows]+ mt =+ [ if i `mod` 6 == 4+ then Nothing+ else Just (["a", "b", "c"] !! (i `mod` 3) :: T.Text)+ | i <- rows+ ]+ z = [fromIntegral ((i * 13) `mod` 17) :: Double | i <- rows]+ y = [fromIntegral ((i * 29) `mod` 31) / 7 :: Double | i <- rows]+ cols =+ [ ("md", maybeCol md)+ , ("mi", maybeCol mi)+ , ("mt", maybeCol mt)+ , ("z", DI.fromList z)+ ]++inSampleMatchesInterpret :: Test+inSampleMatchesInterpret = TestCase $ do+ let (y, df) = mixedFrame 60+ (t, inSample) = fitBinned 1024 6 df (VU.fromList y)+ assertEqual+ "in-sample predictions equal the interpreted tree"+ (interpreted df t)+ inSample++-- 23 distinct md values squeezed into 4 bins must still route consistently.+coarseBinsMatchInterpret :: Test+coarseBinsMatchInterpret = TestCase $ do+ let (y, df) = mixedFrame 200+ (t, inSample) = fitBinned 4 5 df (VU.fromList y)+ assertEqual+ "in-sample predictions equal the interpreted tree"+ (interpreted df t)+ inSample++matchesExactTree :: Test+matchesExactTree = TestCase $ do+ let (y, df) = mixedFrame 120+ yv = VU.fromList y+ (_, binned) = fitBinned 1024 4 df yv+ exact =+ rfFitted+ ( fitRegTree+ defaultRegTreeConfig{rtMaxDepth = 4}+ (V.fromList (cartFeatures "y" df))+ yv+ Nothing+ )+ gap = VU.maximum (VU.zipWith (\a b -> abs (a - b)) binned exact)+ assertBool ("same leaves up to rounding, max gap " ++ show gap) (gap < 1e-9)
tests-internal/Learn/EdgeCases.hs view
@@ -2,10 +2,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | Edge-case / degeneracy tests (category 7) and numerical-stability tests-(category 8) for @dataframe-learn@. Each asserts the mathematically correct-result or a specific documented degenerate behaviour — never "it ran".--}+-- | Edge-case and numerical-stability tests for dataframe-learn. module Learn.EdgeCases (tests) where import qualified Data.Map.Strict as M@@ -28,12 +25,10 @@ import DataFrame.LinearSolver (sigmoid) import DataFrame.PCA -import DataFrame.Internal.Statistics (correlation', variance')+import DataFrame.Internal.Statistics (correlation', meanSquaredError, variance') import Test.HUnit --- Helpers (mirrors Learn.Models) --------------------------------------------- interpD :: D.DataFrame -> Expr Double -> [Double] interpD df e = case interpret @Double df e of Right (TColumn c) -> either (const []) VU.toList (toVector @Double @VU.Vector c)@@ -53,13 +48,7 @@ finite :: Double -> Bool finite x = not (isNaN x) && not (isInfinite x) --- ===========================================================================--- Category 8: numerical stability--- ===========================================================================--{- logSumExp on a large-positive cluster: log(e^1000+e^1001+e^1002) =- 1002 + log(e^-2+e^-1+1) ≈ 1002.40760596. A naive log.sum.map exp- overflows to +Inf here. -}+-- logSumExp on a large positive cluster must not overflow to +Inf. testLogSumExpLargePositive :: Test testLogSumExpLargePositive = TestCase $ do let lse = logSumExp (VU.fromList [1000, 1001, 1002])@@ -70,9 +59,7 @@ (close 1e-9 lse expected) assertBool "lse literal ~ 1002.40760596" (close 1e-7 lse 1002.4076059644443) -{- logSumExp on a large-negative cluster: log(e^-1000+e^-1001+e^-1002) =- -1000 + log(1+e^-1+e^-2) ≈ -999.59239403. A naive impl underflows every- term to 0 -> log 0 = -Inf. -}+-- logSumExp on a large negative cluster must not underflow to -Inf. testLogSumExpLargeNegative :: Test testLogSumExpLargeNegative = TestCase $ do let lse = logSumExp (VU.fromList [-1000, -1001, -1002])@@ -83,14 +70,12 @@ (close 1e-9 lse expected) assertBool "lse literal ~ -999.59239403" (close 1e-7 lse (-999.5923940355557)) -{- logSumExp of a single element is that element exactly (m + log 1). -}+-- logSumExp of a single element is that element exactly. testLogSumExpSingleton :: Test testLogSumExpSingleton = TestCase $ do assertBool "lse [42] == 42" (close 0 (logSumExp (VU.fromList [42])) 42) -{- sigmoid at extreme arguments must stay in [0,1] and not NaN/Inf:- sigmoid(1000) ≈ 1, sigmoid(-1000) ≈ 0. The stable branch avoids the- overflow a naive 1/(1+exp(-z)) hits. -}+-- sigmoid at extreme arguments stays in [0,1] and finite. testSigmoidExtreme :: Test testSigmoidExtreme = TestCase $ do let sp = sigmoid 1000@@ -106,9 +91,7 @@ "sigmoid antisymmetric at 3" (close 1e-12 (sigmoid (-3)) (1 - sigmoid 3)) -{- Variance of large-but-low-variance data [1e8+1,+2,+3]: true sample variance- is 1.0. A naive sum-of-squares formula loses all precision (catastrophic- cancellation); Welford keeps it ~1. -}+-- Variance of large low-variance data is 1.0, not lost to cancellation. testVarianceCatastrophicCancellation :: Test testVarianceCatastrophicCancellation = TestCase $ do let xs = VU.fromList [1e8 + 1, 1e8 + 2, 1e8 + 3] :: VU.Vector Double@@ -117,15 +100,13 @@ assertBool "variance is positive" (v > 0) assertBool "variance of shifted {1,2,3} == 1.0" (close 1e-6 v 1.0) -{- Variance of an identical column is exactly 0 (not a tiny negative from- cancellation). -}+-- Variance of an identical column is exactly 0. testVarianceConstant :: Test testVarianceConstant = TestCase $ do let v = variance' (VU.replicate 100 (7.0 :: Double)) assertEqual "variance of constant column is 0" 0 v -{- Variance of fewer than two samples is defined to be 0 (computeVariance guard),- not NaN from a /0. -}+-- Variance of fewer than two samples is 0, not NaN. testVarianceSingleton :: Test testVarianceSingleton = TestCase $ do assertEqual@@ -133,8 +114,7 @@ 0 (variance' (VU.fromList [3.5 :: Double])) -{- Correlation of a perfectly linear pair is exactly +1 (and -1 reversed),- computed stably. y = 2x+1 over a spread of x. -}+-- Correlation of a perfectly linear pair is exactly +1 (and -1 reversed). testCorrelationPerfect :: Test testCorrelationPerfect = TestCase $ do let xs = VU.fromList [1, 2, 3, 4, 5] :: VU.Vector Double@@ -147,9 +127,7 @@ Just r -> assertBool "corr(x, -2x+1) == -1" (close 1e-9 r (-1)) Nothing -> assertFailure "expected a correlation" -{- Degeneracy: correlation against a constant column. The denominator is 0, so- the library returns Just NaN — this pins the actual (non-throwing) behaviour;- a future Nothing/0 would flag the contract change. -}+-- Correlation against a constant column is Just NaN; pins current behaviour. testCorrelationConstantColumnIsNaN :: Test testCorrelationConstantColumnIsNaN = TestCase $ do let xs = VU.fromList [1, 2, 3, 4, 5] :: VU.Vector Double@@ -161,7 +139,7 @@ (isNaN r) Nothing -> assertFailure "correlation' returned Nothing (contract changed)" -{- correlation' on n<2 returns Nothing (guarded), not a NaN. -}+-- correlation' on n<2 returns Nothing, not NaN. testCorrelationTooFew :: Test testCorrelationTooFew = TestCase $ do assertEqual@@ -169,24 +147,38 @@ Nothing (correlation' (VU.fromList [1]) (VU.fromList [2])) --- ===========================================================================--- Category 8: stability inside the model expr layer--- ===========================================================================+-- meanSquaredError refuses length mismatches and empty inputs.+testMeanSquaredErrorGuards :: Test+testMeanSquaredErrorGuards = TestCase $ do+ assertEqual+ "mse of mismatched lengths is Nothing"+ Nothing+ (meanSquaredError (VU.fromList [0, 0, 0, 0]) (VU.fromList [2, 2]))+ assertEqual+ "mse with the longer prediction does not index out of bounds"+ Nothing+ (meanSquaredError (VU.fromList [1]) (VU.fromList [1, 2, 3]))+ assertEqual+ "mse of empty inputs is Nothing"+ Nothing+ (meanSquaredError VU.empty VU.empty)+ assertEqual+ "mse of equal-length inputs is the plain mean"+ (Just 4.0)+ (meanSquaredError (VU.fromList [0, 0]) (VU.fromList [2, 2])) -{- Logistic probability expressions at extreme feature values (|x| ~ 1e6) must- remain finite and in [0,1]; a non-stable 1/(1+exp(-margin)) would overflow.- Also checks both one-vs-rest class probabilities are present. -}+-- Logistic probabilities at extreme features stay finite and in [0,1]. testLogisticProbsExtremeFeatures :: Test testLogisticProbsExtremeFeatures = TestCase $ do let trainDf =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList ([-3, -2, -1, -0.5, 0.5, 1, 2, 3] :: [Double])) , ("label", DI.fromList ([0, 0, 0, 0, 1, 1, 1, 1] :: [Int])) ] m = fit defaultLogisticConfig (F.col @Int "label") trainDf probs = logisticProbExprs m extremeDf =- D.fromNamedColumns+ D.fromColumns [("x", DI.fromList ([-1e6, -1, 0, 1, 1e6] :: [Double]))] assertEqual "two one-vs-rest probability exprs" 2 (M.size probs) let allProbVals =@@ -196,17 +188,11 @@ "all logistic probabilities in [0,1]" (all (\p -> p >= 0 && p <= 1) allProbVals) --- ===========================================================================--- Category 7: edge cases / degeneracy on models--- ===========================================================================--{- One-row OLS (x=2, y=7): under-determined, so olsSolve falls back to- ridge(1e-8). The fit must interpolate the single point (predict 7 at x=2)- and never be NaN/Inf. -}+-- One-row OLS falls back to ridge and interpolates the point, never NaN. testOLSOneRow :: Test testOLSOneRow = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList ([2] :: [Double])) , ("y", DI.fromList ([7] :: [Double])) ]@@ -217,13 +203,11 @@ "OLS fits the single training point" (case preds of [p] -> close 1e-3 p 7; _ -> False) -{- All-identical labels in logistic regression: every row is class 1. There is- exactly one class, so predict must return that class for every row (no- division-by-zero, no crash, no spurious second class). -}+-- Logistic regression with a single class predicts that class everywhere. testLogisticSingleClass :: Test testLogisticSingleClass = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList ([-2, -1, 0, 1, 2] :: [Double])) , ("label", DI.fromList ([1, 1, 1, 1, 1] :: [Int])) ]@@ -234,13 +218,11 @@ [1, 1, 1, 1, 1] preds -{- Perfectly separable logistic data plus a constant (zero-variance) feature:- the constant column is dropped, the informative one still separates, and- prediction recovers the labels exactly. -}+-- A constant feature is dropped; the informative one still separates. testLogisticConstantFeatureIgnored :: Test testLogisticConstantFeatureIgnored = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList ([-3, -2, -1, -0.5, 0.5, 1, 2, 3] :: [Double])) , ("const", DI.fromList (replicate 8 (5.0 :: Double))) , ("label", DI.fromList ([0, 0, 0, 0, 1, 1, 1, 1] :: [Int]))@@ -252,14 +234,12 @@ [0, 0, 0, 0, 1, 1, 1, 1] preds -{- Linear regression with an irrelevant constant feature: the constant column is- near-constant (variance 0) and must get a zero weight, while the informative- coefficient is recovered. y = 3x + 4 exactly. -}+-- A constant feature gets zero weight; y = 3x + 4 is recovered exactly. testLinearConstantFeatureZeroWeight :: Test testLinearConstantFeatureZeroWeight = TestCase $ do let xs = [1, 2, 3, 4, 5, 6] :: [Double] df =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList xs) , ("const", DI.fromList (replicate 6 (5.0 :: Double))) , ("y", DI.fromList [3 * x + 4 | x <- xs])@@ -277,13 +257,11 @@ "regression with constant feature still fits y=3x+4" (and (zipWith (close 1e-4) preds truth)) -{- k-means with k > n: the library clamps k = min k (max 1 n), so 3 rows with- kmK=10 must yield exactly 3 centres, in-range labels, and finite centres —- not a crash or degenerate centres. -}+-- k-means with k > n clamps k, giving finite centres and in-range labels. testKMeansKGreaterThanN :: Test testKMeansKGreaterThanN = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("a", DI.fromList ([0, 5, 10] :: [Double])) , ("b", DI.fromList ([0, 5, 10] :: [Double])) ]@@ -297,13 +275,11 @@ (all (VU.all finite) (V.toList (kmCenters m))) assertBool "k=n clustering has ~zero inertia" (close 1e-9 (kmInertia m) 0) -{- k-means on an all-identical feature set: any clustering has inertia 0 and- centres equal that point, staying finite (no NaN from an empty cluster or a- zero distance sum in k-means++ sampling). -}+-- k-means on identical points has inertia 0 and finite centres. testKMeansAllIdentical :: Test testKMeansAllIdentical = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("a", DI.fromList (replicate 6 (4.0 :: Double))) , ("b", DI.fromList (replicate 6 (4.0 :: Double))) ]@@ -316,13 +292,11 @@ let assigns = interpI df (predict m) assertBool "assignments in [0,1]" (all (\l -> l >= 0 && l < 2) assigns) -{- GMM with k > n: clamped like k-means (k = min k (max 1 n)). Two rows, gmmK=5- must yield 2 components, finite weights summing to ~1, and a finite log- likelihood -- not a crash or NaN. -}+-- GMM with k > n clamps k; weights sum to ~1 and the likelihood is finite. testGMMKGreaterThanN :: Test testGMMKGreaterThanN = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("a", DI.fromList ([0, 10] :: [Double])) , ("b", DI.fromList ([0, 10] :: [Double])) ]@@ -333,13 +307,11 @@ assertBool "GMM weights sum to ~1" (close 1e-6 (VU.sum (gmmWeights m)) 1) assertBool "GMM log-likelihood finite" (finite (gmmLogLikelihood m)) -{- PCA on one informative axis plus a constant column: the constant's explained- ratio must be ~0 while ratios sum to ~1 and stay finite. Catches a- divide-by-zero in ratio normalisation when an eigenvalue is 0. -}+-- PCA with a constant column: its explained ratio is ~0 and all are finite. testPCAConstantColumn :: Test testPCAConstantColumn = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList ([-2, -1, 0, 1, 2] :: [Double])) , ("const", DI.fromList (replicate 5 (3.0 :: Double))) ]@@ -360,13 +332,11 @@ "pca exprs finite on constant column" (all (all finite . interpD df) es) -{- PCA on extreme-scale features (1e8-shifted), no standardisation: components- must stay unit-length and finite — catches cancellation in the covariance /- eigendecomposition at large magnitudes. -}+-- PCA on extreme-scale features keeps components unit-length and finite. testPCAExtremeScale :: Test testPCAExtremeScale = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList ([1e8 + 1, 1e8 + 2, 1e8 + 3, 1e8 + 4] :: [Double])) , ("y", DI.fromList ([1e8 + 1, 1e8 + 2, 1e8 + 3, 1e8 + 4] :: [Double])) ]@@ -383,12 +353,7 @@ "explained ratio finite at 1e8 scale" (VU.all finite (pcaExplainedVarianceRatio m)) --- ===========================================================================--- Category 7/8: linear-algebra degeneracy--- ===========================================================================--{- choleskySolve on a non-positive-definite (zero) matrix returns Nothing rather- than crashing or producing NaNs. A 2x2 all-zero matrix has a zero pivot. -}+-- choleskySolve on a non-positive-definite matrix returns Nothing. testCholeskyNonPD :: Test testCholeskyNonPD = TestCase $ do assertEqual@@ -396,17 +361,14 @@ Nothing (choleskySolve (mat [[0, 0], [0, 0]]) (VU.fromList [1, 1])) -{- Jacobi eigendecomposition of a 1x1 matrix (one-column degenerate case):- eigenvalue is the single entry, eigenvector is [1] (sign-canonicalised). -}+-- Jacobi on a 1x1 matrix: eigenvalue is the entry, eigenvector is [1]. testJacobi1x1 :: Test testJacobi1x1 = TestCase $ do let (ev, vecs) = jacobiEigenSym (mat [[5]]) assertBool "1x1 eigenvalue is the entry" (close 1e-12 (ev VU.! 0) 5) assertBool "1x1 eigenvector is [1]" (close 1e-12 ((vecs V.! 0) VU.! 0) 1) -{- Jacobi on an identity matrix: both eigenvalues are exactly 1 and the result is- finite (the rotation angle code must not divide by zero when off-diagonals are- already 0). -}+-- Jacobi on an identity matrix gives eigenvalues exactly 1, all finite. testJacobiIdentity :: Test testJacobiIdentity = TestCase $ do let (ev, vecs) = jacobiEigenSym (mat [[1, 0], [0, 1]])@@ -425,6 +387,7 @@ , testCorrelationPerfect , testCorrelationConstantColumnIsNaN , testCorrelationTooFew+ , testMeanSquaredErrorGuards , testLogisticProbsExtremeFeatures , testOLSOneRow , testLogisticSingleClass
tests-internal/Learn/NumericalRigor.hs view
@@ -2,10 +2,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | Numerical-rigor suite: gradient checks (cat 4), reproducibility of the-stochastic models (cat 16), and statistical properties of the RNG and splitters-(cat 5). Every test is constructed to FAIL on a real bug.--}+-- | Numerical-rigor suite: gradient checks, reproducibility, RNG properties. module Learn.NumericalRigor (tests) where import qualified DataFrame.Functions as F@@ -28,12 +25,6 @@ import System.Random (mkStdGen) import Test.HUnit --- ------------------------------------------------------------------------------ Independent reference loss VALUE functions. The library exposes only the--- gradient 'slGradZ'; these reconstruct the scalar value so the finite--- difference is computed independently of the analytic gradient.--- ---------------------------------------------------------------------------- -- | @½ (z - y)²@ (squaredLoss). squaredValue :: Double -> Double -> Double squaredValue y z = 0.5 * (z - y) * (z - y)@@ -55,10 +46,7 @@ (Double -> Double -> Double) -> Double -> Double -> Double -> Double centralDiff f eps y z = (f y (z + eps) - f y (z - eps)) / (2 * eps) -{- | Gradient-check one 'SmoothLoss' against its reference value fn over a grid-of @(y, z)@ points. Fails if the analytic gradient differs from the finite-difference anywhere beyond @tol@.--}+-- | Gradient-check one SmoothLoss against its reference over a grid of points. gradCheck :: String -> SmoothLoss ->@@ -95,14 +83,11 @@ testLogisticGradient :: Test testLogisticGradient = gradCheck "logistic" logisticLoss logisticValue 1e-5 --- | The squared-hinge gradient @-2y·max(0,1-yz)@ checked against (max 0 (1-yz))².+-- | The squared-hinge gradient checked against its closed form. testSqHingeGradient :: Test testSqHingeGradient = gradCheck "squared_hinge" sqHingeLoss sqHingeValue 1e-5 -{- | Sign sanity that a self-referential check can't pass: where the loss rises-in @z@ the gradient must be positive (and vice versa). Catches a flipped-sign-gradient even with the right magnitude.--}+-- | Sign sanity: where the loss rises in z the gradient must be positive. testGradientSigns :: Test testGradientSigns = TestCase $ do assertBool@@ -124,12 +109,6 @@ "sqHinge satisfied margin: grad==0" (slGradZ sqHingeLoss 1.0 5.0 == 0) --- ------------------------------------------------------------------------------ Statistical / distributional tests (cat 5). Tolerances are CI-derived from--- the sample size (N = 100k → 6σ band ~ 5e-3). A broken sampler — constant or--- biased — falls well outside.--- ---------------------------------------------------------------------------- -- | Draw @n@ uniforms threading the generator; returns the sample list. drawUniforms :: Int -> Gen -> [Double] drawUniforms n g0 = go n g0 []@@ -145,10 +124,7 @@ let m = mean xs in sum [(x - m) * (x - m) | x <- xs] / fromIntegral (length xs) -{- | @nextDouble@ is ~uniform on [0,1): mean ≈ 0.5 and variance ≈ 1/12, each-within a 6σ CI for 100k samples, AND every draw is in [0,1). A constant or-out-of-range sampler fails; a biased one (mean drifts) fails.--}+-- | nextDouble is roughly uniform on [0,1): mean, variance and range checks. testUniformDistribution :: Test testUniformDistribution = TestCase $ do let n = 100000 :: Int@@ -165,10 +141,7 @@ (abs (v - 1 / 12) <= 0.01) assertBool "uniform actually varies" (maximum xs - minimum xs > 0.9) -{- | Box-Muller @gaussianVector@ produces standard normals: sample mean ≈ 0 and-variance ≈ 1 over 100k draws (6σ band ~ 2e-2). Catches a Box-Muller bug that-shifts the mean or scales the variance.--}+-- | Box-Muller gaussianVector yields standard normals: mean near 0, variance 1. testGaussianMoments :: Test testGaussianMoments = TestCase $ do let n = 100000 :: Int@@ -192,15 +165,12 @@ ("gaussian |x|>2 frequency ~0.0455, got " ++ show tail2) (abs (tail2 - 0.0455) <= 0.01) -{- | @randomSplit frac@ over many seeds: the realized train fraction matches-@frac@ within a binomial CI, and train+test always sums to the input row count-(cat 2 invariant). A split that loses/dupes rows, or ignores @frac@, fails.--}+-- | randomSplit train fraction matches frac and train plus test sums to input. testSplitProportions :: Test testSplitProportions = TestCase $ do let nRows = 4000 frac = 0.7 :: Double- df = D.fromNamedColumns [("x", DI.fromList [1 .. nRows :: Int])]+ df = D.fromColumns [("x", DI.fromList [1 .. nRows :: Int])] seeds = [1 .. 25] :: [Int] seFrac = sqrt (frac * (1 - frac) / fromIntegral nRows) mapM_@@ -225,16 +195,13 @@ ) seeds -{- | k-means inertia on a clean, well-separated blob is stable across seeds and-never beats the true within-cluster sum of squares of the generating partition.-A broken inertia would report values below the optimum or wildly seed-dependent.--}+-- | k-means inertia is seed-stable and never beats the generating partition. testKMeansInertiaStable :: Test testKMeansInertiaStable = TestCase $ do let as = [0, 0.1, -0.1, 0.05, -0.05, 10, 10.1, 9.9, 10.05, 9.95] :: [Double] bs = [0, -0.1, 0.1, 0.05, -0.05, 10, 9.9, 10.1, 9.95, 10.05] :: [Double]- df = D.fromNamedColumns [("a", DI.fromList as), ("b", DI.fromList bs)]+ df = D.fromColumns [("a", DI.fromList as), ("b", DI.fromList bs)] fitSeed s = kmInertia $ fit@@ -269,15 +236,9 @@ ) (worst - best <= 1e-6 + 1e-3 * optimum) --- ------------------------------------------------------------------------------ Reproducibility tests (cat 16). Each compares two fits with the same seed--- (determinism) and asserts a different seed CAN change the model, so a--- constant-returning stub wouldn't pass.--- ---------------------------------------------------------------------------- blobsDF :: D.DataFrame blobsDF =- D.fromNamedColumns+ D.fromColumns [ ( "a" , DI.fromList ([0, 0.2, -0.1, 0.1, 8, 8.1, 7.9, 8.2, 0.05, 8.05] :: [Double])@@ -291,15 +252,12 @@ -- | Many-cluster frame so k-means++ seeding genuinely depends on the seed. spreadDF :: D.DataFrame spreadDF =- D.fromNamedColumns+ D.fromColumns [ ("a", DI.fromList ([0, 1, 2, 10, 11, 12, 20, 21, 22, 30, 31, 32] :: [Double])) , ("b", DI.fromList ([0, 1, 0, 10, 11, 10, 0, 1, 0, 10, 11, 10] :: [Double])) ] -{- | k-means: same seed → identical KMeansModel (Eq derived); a different seed-on a multi-blob frame CAN yield different centers (the seed actually drives-k-means++ init). Single-init so the seed is not washed out by nInit restarts.--}+-- | k-means: same seed gives an identical model; a different seed can differ. testKMeansReproducible :: Test testKMeansReproducible = TestCase $ do let cfg s = defaultKMeansConfig{kmK = 4, kmNInit = 1, kmMaxIter = 1, kmSeed = s}@@ -312,9 +270,7 @@ anyDiffer = any (\s -> centersFor s /= base) [2, 3, 5, 7, 11, 13] assertBool "k-means: a different seed changes the model" anyDiffer -{- | GMM: same seed → identical GMMModel (Eq derived); a different seed CAN move-the fitted means (seed drives the responsibility init via sampleIndices).--}+-- | GMM: same seed gives an identical model; a different seed can move means. testGMMReproducible :: Test testGMMReproducible = TestCase $ do let cfg s = defaultGMMConfig{gmmK = 2, gmmMaxIter = 1, gmmSeed = s}@@ -327,14 +283,11 @@ anyDiffer = any (\s -> meansFor s /= base) [2, 3, 4, 5, 6, 7, 8] assertBool "GMM: a different seed changes the means" anyDiffer -{- | RFF-SVM: same seed → identical random projection AND identical fitted SVC-coefficients; a different seed changes the random Fourier features. The model-type only derives Show, so compare representative numeric fields directly.--}+-- | RFF-SVM: same seed gives identical projection and SVC coefficients. testRFFReproducible :: Test testRFFReproducible = TestCase $ do let clsDF =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList ([-3, -2, -1, -0.5, 0.5, 1, 2, 3] :: [Double])) , ("label", DI.fromList ([0, 0, 0, 0, 1, 1, 1, 1] :: [Int])) ]@@ -350,17 +303,9 @@ anyDiffer = any (\s -> projFor s /= base) [1, 2, 3, 6, 7, 8] assertBool "RFF: a different seed changes the projection" anyDiffer --- ------------------------------------------------------------------------------ randomSplit determinism + row-count invariant (cat 16 + 2).--- -----------------------------------------------------------------------------{- | @randomSplit@ with the same seed is bit-identical (compared via the-prettyPrinted frames), preserves total rows, and a different seed CAN change the-partition.--} testSplitReproducible :: Test testSplitReproducible = TestCase $ do- let df = D.fromNamedColumns [("x", DI.fromList [1 .. 200 :: Int])]+ let df = D.fromColumns [("x", DI.fromList [1 .. 200 :: Int])] (tr1, te1) = D.randomSplit (mkStdGen 42) 0.6 df (tr2, te2) = D.randomSplit (mkStdGen 42) 0.6 df assertBool "randomSplit same seed: same train" (tr1 == tr2)@@ -374,11 +319,6 @@ anyDiffer = any (\s -> trainFor s /= base) [1, 2, 3, 7, 99] assertBool "randomSplit: a different seed changes the partition" anyDiffer --- ------------------------------------------------------------------------------ A self-consistency sanity for the helpers above so a broken reference value--- fn cannot make the gradient checks vacuous: the reference squared value must--- actually be ½(z-y)² at a known point.--- --------------------------------------------------------------------------- testReferenceValueSanity :: Test testReferenceValueSanity = TestCase $ do assertBool
tests-internal/Learn/Numerics.hs view
@@ -4,7 +4,6 @@ import qualified Data.Vector as V import qualified Data.Vector.Unboxed as VU-import DataFrame.Model (fit, predict) import Test.HUnit import DataFrame.LinearAlgebra
tests-internal/Learn/Symbolic.hs view
@@ -35,7 +35,7 @@ testSRRecovers = TestCase $ do let xs = [-3, -2, -1, 0, 1, 2, 3, 4, 5, 6] :: [Double] df =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList xs) , ("y", DI.fromList [x * x + x | x <- xs]) ]@@ -59,7 +59,7 @@ testSRDeterminism = TestCase $ do let xs = [1 .. 8] :: [Double] df =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList xs) , ("y", DI.fromList (map (\x -> 2 * x + 1) xs)) ]@@ -93,7 +93,7 @@ testKernelPCA :: Test testKernelPCA = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("a", DI.fromList ([0, 0.2, -0.1, 0.1, 8, 8.1, 7.9, 8.2] :: [Double])) , ("b", DI.fromList ([0, -0.1, 0.2, 0.0, 5, 5.2, 4.9, 5.1] :: [Double])) ]@@ -116,7 +116,7 @@ testRFFSVM :: Test testRFFSVM = TestCase $ do let df =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList ([-3, -2, -1, -0.5, 0.5, 1, 2, 3] :: [Double])) , ("label", DI.fromList ([0, 0, 0, 0, 1, 1, 1, 1] :: [Int])) ]
tests-internal/LinearSolver.hs view
@@ -17,7 +17,7 @@ import System.Random (mkStdGen, randomR) import Test.HUnit --- Generate n points with d features, each value uniform in [-1, 1], from a seed.+-- Generate n points of d features, each uniform in [-1, 1], from a seed. syntheticPoints :: Int -> Int -> Int -> V.Vector (VU.Vector Double) syntheticPoints seed n d = let (rows, _) = foldr step ([], mkStdGen seed) [1 .. n]@@ -65,8 +65,7 @@ predictStandardized w b x = if dotProduct w x + b > 0 then 1 else -1 --- Average binary logistic loss at (w, b); the branch on @margin@ keeps--- @log(1 + exp(-margin))@ numerically stable.+-- Average binary logistic loss at (w, b), computed stably via @margin@. logisticLoss :: V.Vector (VU.Vector Double) -> VU.Vector Double ->@@ -363,7 +362,7 @@ } expr = modelToExpr model df =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList ([0.0, 1.0, 2.0] :: [Double])) , ("y", DI.fromList ([0.0, 0.0, 5.0] :: [Double])) ]@@ -505,8 +504,7 @@ badB = isNaN (lmIntercept m1) || isInfinite (lmIntercept m1) assertBool "no NaN/Inf after one iteration" (not (badW || badB)) --- Generate two correlated features f0, f1 with correlation ρ, plus--- noise features f2..f7. Truth is sign(f0 + f1).+-- Features f0, f1 correlated by ρ plus noise f2..f7; truth is sign(f0 + f1). correlatedPairData :: Int -> Double -> (V.Vector (VU.Vector Double), VU.Vector Double) correlatedPairData seed rho =
tests-internal/Main.hs view
@@ -9,11 +9,13 @@ import qualified Cart import qualified DecisionTree+import qualified HistogramTrees import qualified Learn.EdgeCases import qualified Learn.NumericalRigor import qualified Learn.Numerics import qualified Learn.Symbolic import qualified LinearSolver+import qualified NullSplits import qualified Properties.Simplify import qualified TreePruning import qualified Worklist@@ -23,7 +25,9 @@ TestList $ Cart.tests ++ DecisionTree.tests+ ++ HistogramTrees.tests ++ LinearSolver.tests+ ++ NullSplits.tests ++ TreePruning.tests ++ Worklist.tests ++ Learn.Numerics.tests
+ tests-internal/NullSplits.hs view
@@ -0,0 +1,155 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++-- | Tree learners on nullable columns route null rows as fitting did.+module NullSplits (tests) where++import Data.Either (fromRight)+import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import Test.HUnit++import DataFrame.Boosting.AdaBoost (AdaBoostConfig (..), defaultAdaBoostConfig)+import DataFrame.DecisionTree.Cart (buildCartTree, cartFeatures)+import DataFrame.DecisionTree.Fit (treeToExpr)+import DataFrame.DecisionTree.Regression (+ RegFit (..),+ RegTreeConfig (..),+ defaultRegTreeConfig,+ fitRegTree,+ )+import DataFrame.DecisionTree.Types (Tree, TreeConfig (..), defaultTreeConfig)+import qualified DataFrame.Internal.Column as DI+import DataFrame.Internal.Expression (Expr (..))+import DataFrame.Internal.Interpreter (interpret)+import DataFrame.Model (Fit (..), Predict (..))+import qualified DataFrameApi as D++tests :: [Test]+tests =+ [ TestLabel+ "null splits: in-sample == interpreted (mixed nullable frame)"+ inSampleMatchesInterpret+ , TestLabel "null splits: nulls join the side they fit" learnedDirection+ , TestLabel "null splits: null-indicator split" nullIndicator+ , TestLabel "null splits: unseen nulls still route" unseenNullsRoute+ , TestLabel "null splits: CART null indicator" cartNullIndicator+ , TestLabel "null splits: AdaBoost null indicator" adaBoostNullIndicator+ ]++fitAll ::+ Int -> D.DataFrame -> VU.Vector Double -> (Tree Double, VU.Vector Double)+fitAll depth df y = (rfTree fitted, rfFitted fitted)+ where+ fitted =+ fitRegTree+ defaultRegTreeConfig{rtMaxDepth = depth}+ (V.fromList (cartFeatures "y" df))+ y+ Nothing++interpreted :: D.DataFrame -> Tree Double -> VU.Vector Double+interpreted df = interpretedExpr df . treeToExpr++interpretedExpr :: D.DataFrame -> Expr Double -> VU.Vector Double+interpretedExpr df e = case interpret @Double df e of+ Right (DI.TColumn c) -> fromRight VU.empty (DI.toVector @Double @VU.Vector c)+ Left e -> error (show e)++withTarget :: [Double] -> [(T.Text, DI.Column)] -> D.DataFrame+withTarget y cols = D.fromColumns (("y", DI.fromList y) : cols)++maybeCol :: (DI.Columnable a) => [Maybe a] -> DI.Column+maybeCol = DI.fromVector . V.fromList++inSampleMatchesInterpret :: Test+inSampleMatchesInterpret = TestCase $ do+ let n = 60 :: Int+ rows = [0 .. n - 1]+ md =+ [ if i `mod` 7 == 0+ then Nothing+ else Just (fromIntegral ((i * 37) `mod` 23) :: Double)+ | i <- rows+ ]+ mi =+ [if i `mod` 5 == 2 then Nothing else Just ((i * 11) `mod` 9 :: Int) | i <- rows]+ mt =+ [ if i `mod` 6 == 4+ then Nothing+ else Just (["a", "b", "c"] !! (i `mod` 3) :: T.Text)+ | i <- rows+ ]+ z = [fromIntegral ((i * 13) `mod` 17) :: Double | i <- rows]+ y = [fromIntegral ((i * 29) `mod` 31) / 7 :: Double | i <- rows]+ df =+ withTarget+ y+ [ ("md", maybeCol md)+ , ("mi", maybeCol mi)+ , ("mt", maybeCol mt)+ , ("z", DI.fromList z)+ ]+ (t, inSample) = fitAll 6 df (VU.fromList y)+ assertEqual+ "in-sample predictions equal the interpreted tree"+ (interpreted df t)+ inSample++-- Nulls share the low x values' target, so an exact split sends nulls left.+learnedDirection :: Test+learnedDirection = TestCase $ do+ let y = [0, 0, 0, 1, 1, 1, 0, 0]+ x = map Just [1, 2, 3, 4, 5, 6] ++ [Nothing, Nothing] :: [Maybe Double]+ df = withTarget y [("x", maybeCol x)]+ (t, inSample) = fitAll 1 df (VU.fromList y)+ assertEqual "fits exactly" (VU.fromList y) inSample+ assertEqual "interpreted agrees" (VU.fromList y) (interpreted df t)++nullIndicator :: Test+nullIndicator = TestCase $ do+ let y = [0, 0, 0, 1, 1]+ x = [Just 1, Just 2, Just 3, Nothing, Nothing] :: [Maybe Int]+ df = withTarget y [("x", maybeCol x)]+ (t, inSample) = fitAll 1 df (VU.fromList y)+ assertEqual "fits exactly" (VU.fromList y) inSample+ assertEqual "interpreted agrees" (VU.fromList y) (interpreted df t)++unseenNullsRoute :: Test+unseenNullsRoute = TestCase $ do+ let y = [0, 0, 0, 1, 1, 1]+ x = map Just [1, 2, 3, 4, 5, 6] :: [Maybe Int]+ (t, inSample) = fitAll 1 (withTarget y [("x", maybeCol x)]) (VU.fromList y)+ testDf = withTarget y [("x", maybeCol (take 4 x ++ [Nothing, Nothing]))]+ scored = interpreted testDf t+ assertEqual "fits exactly" (VU.fromList y) inSample+ assertEqual "present rows unchanged" (VU.take 4 inSample) (VU.take 4 scored)+ assertBool+ "null rows land in a leaf"+ (VU.all (`elem` [0, 1]) (VU.drop 4 scored))++-- The target depends only on whether x is null.+indicatorFrame :: ([Double], D.DataFrame)+indicatorFrame = (y, withTarget y [("x", maybeCol x)])+ where+ y = [0, 0, 0, 1, 1]+ x = [Just 1, Just 2, Just 3, Nothing, Nothing] :: [Maybe Int]++cartNullIndicator :: Test+cartNullIndicator = TestCase $ do+ let (y, df) = indicatorFrame+ t =+ buildCartTree @Double+ defaultTreeConfig{maxTreeDepth = 1, minLeafSize = 1}+ "y"+ df+ assertEqual "fits exactly" (VU.fromList y) (interpreted df t)++adaBoostNullIndicator :: Test+adaBoostNullIndicator = TestCase $ do+ let (y, df) = indicatorFrame+ cfg = defaultAdaBoostConfig{abNEstimators = 1, abMaxDepth = 1}+ model = fit cfg (Col @Double "y") df+ assertEqual "fits exactly" (VU.fromList y) (interpretedExpr df (predict model))
tests-internal/Properties/Simplify.hs view
@@ -2,10 +2,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{- | Property tests for the simplifier and tree-pruning pass: @simplify@-preserves denotation (over Bool and Maybe Bool, incl. NaN\/null\/boundary rows)-and is idempotent; @pruneDead@ preserves the function the tree computes.--}+-- | Property tests for the simplifier and tree-pruning pass. module Properties.Simplify (tests) where import DataFrame.DecisionTree.Predict (predictWithTree)@@ -28,7 +25,7 @@ -- A fixture spanning the interesting rows: exact thresholds, gaps, NaN, null. fixtureDF :: D.DataFrame fixtureDF =- D.fromNamedColumns+ D.fromColumns [ ( "x" , DI.fromList ([10, 20, 25, 30, 35, 40, 50, 0 / 0, -(1 / 0), 1 / 0] :: [Double])
tests-internal/TreePruning.hs view
@@ -1,9 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeApplications #-} -{- | Specification for the fitted-tree pruning pass ('pruneDead'): path-condition-entailment, the false-edge NaN gate, and same-branch collapse.--}+-- | Specification for the fitted-tree pruning pass (pruneDead). module TreePruning (tests) where import DataFrame.DecisionTree.Prune (pruneDead)
tests-internal/Worklist.hs view
@@ -1,10 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeApplications #-} -{- | TDD spec for the saturation worklist 'saturateCandidates' replacing the-lazy generate-all 'boolExprsVec', which is kept as the behaviour-preservation-oracle.--}+-- | Spec for the saturateCandidates worklist against the boolExprsVec oracle. module Worklist (tests, props) where import DataFrame.DecisionTree.CondVec (@@ -38,12 +35,10 @@ import Test.HUnit import Test.QuickCheck --- Fixture: x = 0..5, y = 5..0 (anti-correlated), z scrambled (independent of both).--- Note x>2 and y<3 share the truth vector [F,F,F,T,T,T], so the truth-vector mode--- must collapse them; z gives a third column for non-consolidating cross-column combos.+-- Fixture: x = 0..5, y = 5..0, z scrambled; x>2 and y<3 share a truth vector. fixtureDF :: D.DataFrame fixtureDF =- D.fromNamedColumns+ D.fromColumns [ ("x", DI.fromList ([0, 1, 2, 3, 4, 5] :: [Double])) , ("y", DI.fromList ([5, 4, 3, 2, 1, 0] :: [Double])) , ("z", DI.fromList ([2, 5, 1, 4, 0, 3] :: [Double]))@@ -63,8 +58,7 @@ zGt n = mat (F.col @Double "z" .>. F.lit n) zLt n = mat (F.col @Double "z" .<. F.lit n) --- Same truth vector as 'xGt 2' ([F,F,F,T,T,T]) but eSize 4 vs 3 — a non-degenerate--- truth-vector collision for the min-eSize representative rule.+-- Same truth vector as 'xGt 2' but eSize 4 vs 3: a non-degenerate collision. notLe2 :: CondVec notLe2 = mat (F.not (F.col @Double "x" .<=. F.lit 2)) @@ -80,15 +74,14 @@ truthSet :: [CondVec] -> Set.Set [Bool] truthSet = Set.fromList . map (VU.toList . cvVec) --- Mirrors 'evalWithPenaltyVec' (DecisionTree.hs): score = (#care-point errors, eSize),--- depending only on the cached vector + size, so distinct same-vector same-size atoms tie.+-- Mirrors 'evalWithPenaltyVec': score = (#care-point errors, eSize). penBy :: [Bool] -> CondVec -> (Int, Int) penBy lbls cv = ( length (filter id (zipWith (/=) lbls (VU.toList (cvVec cv)))) , eSize (cvExpr cv) ) --- The candidate 'bestDiscreteCandidate' would select: the first 'minimumBy penalty' winner.+-- The candidate 'bestDiscreteCandidate' would select (first minimumBy winner). argminKey :: [Bool] -> [CondVec] -> String argminKey lbls = keyOf . minimumBy (compare `on` penBy lbls) @@ -99,16 +92,14 @@ base3 :: [CondVec] base3 = [xGt 2, xGt 4, yGt 2] --- x>2 and y<3 share the truth vector [F,F,F,T,T,T], so truth-vector mode collapses--- this 3-atom base to 2 distinct vectors while structural mode keeps all three.+-- x>2 and y<3 share a truth vector, so truth-vector mode collapses this base. collBase :: [CondVec] collBase = [xGt 2, yLt 3, yGt 2] --- Wider fixture (3 independent-ish columns, 10 rows) yielding many distinct truth--- vectors — broader coverage for the truth-vector floor / dedup than the 6-row x/y fixture.+-- Wider fixture (3 columns, 10 rows) with many distinct truth vectors. wideDF :: D.DataFrame wideDF =- D.fromNamedColumns+ D.fromColumns [ ("a", DI.fromList ([0, 1, 2, 3, 4, 5, 6, 7, 8, 9] :: [Double])) , ("b", DI.fromList ([9, 7, 5, 3, 1, 8, 6, 4, 2, 0] :: [Double])) , ("c", DI.fromList ([1, 1, 2, 2, 3, 3, 4, 4, 5, 5] :: [Double]))@@ -127,10 +118,6 @@ , matW (F.col @Double "c" .>=. F.lit 3) ] ---------------------------------------------------------------------------- HUnit cases-------------------------------------------------------------------------- tests :: [Test] tests = [ TestLabel "structural: same distinct set as oracle" . TestCase $@@ -330,10 +317,6 @@ (map keyOf (saturateCandidates TruthVector 1 fiber)) ] ---------------------------------------------------------------------------- QuickCheck properties (over generated base pools and depths)-------------------------------------------------------------------------- genAtom :: Gen CondVec genAtom = elements@@ -342,7 +325,7 @@ genBase :: Gen [CondVec] genBase = choose (2, 5) >>= \k -> vectorOf k genAtom --- Random label vector of the fixture's length (6 rows), for selection-preservation.+-- Random label vector of the fixture's length (6 rows). genLabels :: Gen [Bool] genLabels = vectorOf 6 (elements [False, True]) @@ -361,7 +344,6 @@ truthSet (saturateCandidates TruthVector d base) === truthSet (ref d base) -- The candidate set depends only on the base as a set, not its input order.--- (Output order tracks input order — the byte-identity contract; see selection tests.) prop_orderInvariant :: Property prop_orderInvariant = forAllBlind genBase $ \base ->@@ -371,8 +353,6 @@ keySet (saturateCandidates Structural d base) === keySet (saturateCandidates Structural d base') --- The candidate the consumer's 'minimumBy penaltyCV' selects is byte-identical to the oracle's,--- for any label vector (d >= 2 so combinations exist). This is the model-preservation contract. prop_selectionPreserved :: Property prop_selectionPreserved = forAllBlind genBase $ \base ->@@ -381,10 +361,6 @@ counterexample (show (map keyOf base, lbls, d)) $ argminKey lbls (saturateCandidates Structural d base) === argminKey lbls (ref d base)---- The full output (order included) is byte-identical to the deduped oracle, at every depth.--- Subsumes selection-preservation for ANY (cvVec,eSize)-penalty, and stresses the--- frontier:=admitted optimisation past the depth where it could first diverge. prop_orderMatchesOracle :: Property prop_orderMatchesOracle = forAllBlind genBase $ \base ->@@ -393,8 +369,7 @@ map keyOf (saturateCandidates Structural d base) === map keyOf (nubBy ((==) `on` keyOf) (ref d base)) --- The structural key faithfully represents the 'eqExpr' quotient on the candidate domain--- (atoms and their AND/OR products): show.normalize merges exactly what eqExpr merges.+-- The structural key represents the 'eqExpr' quotient on atoms and products. genCand :: Gen CondVec genCand = oneof