packages feed

dataframe-learn 1.1.0.1 → 2.0.0.0

raw patch · 80 files changed

+9266/−4611 lines, 80 filesdep +HUnitdep +QuickCheckdep +aesondep ~containersdep ~dataframe-coredep ~dataframe-operations

Dependencies added: HUnit, QuickCheck, aeson, bytestring, dataframe-csv, dataframe-expr-serializer, dataframe-learn

Dependency ranges changed: containers, dataframe-core, dataframe-operations, parallel, random, vector, vector-algorithms

Files

README.md view
@@ -1,3 +1,5 @@+<!-- scripths: 0.5.3.0 -->+ <!--   This README is a runnable scripths (https://github.com/DataHaskell/scripths)   notebook. Every ```haskell block runs top-to-bottom in one shared session and@@ -10,32 +12,28 @@  # dataframe-learn -Machine learning for [`dataframe`](https://hackage.haskell.org/package/dataframe)-where **a fitted model is a dataframe expression**. You `fit` a model and-`predict` hands you back an `Expr` over your columns — pretty-print it to read-the formula, apply it with `derive` to score a frame, fold preprocessing into it-with `compileThrough`. The model *is* the prediction, not an opaque blob, and the-scikit-learn-style record (coefficients, centroids, components, support) is right-there too for inspection. Because every prediction is the same kind of `Expr`,-preprocessing, prediction, and deployment all compose the same way — the-[design notes](#design-notes-the-categorical-account) at the end explain why.+Symbolic machine learning for [`dataframe`](https://hackage.haskell.org/package/dataframe)+where a fitted model is a dataframe expression. The API borrows from the now ubiquitous+scikit-learn `fit` + `predict` convention. `fit` returns a record containing model information.+`predict` takes that record and returns an `Expr` over your columns. The expression is a+normal dataframe expression that can be: -## A linear model is a formula+* applied with `derive`+* pretty-printed+* manipulated symbolically. -`fit` returns a record (with `regCoef`/`regIntercept` for inspection) and-`predict` compiles it to an `Expr Double` you can read. The `D` import (the-public `DataFrame` umbrella, which also gives `D.col` for the expression DSL)-carries through the rest of the notebook; each later section adds the one model-module it needs:+## Linear regression +For a linear regression `fit` returns a record (with `regCoef`/`regIntercept` for inspection)+and `predict` compiles it to an `Expr Double`.+ ```haskell--- cabal: packages: .., ., ../dataframe-core, ../dataframe-operations, ../dataframe-parsing--- cabal: build-depends: dataframe, dataframe-learn, text--- cabal: default-extensions: OverloadedStrings, TypeApplications+-- cabal: packages: .., ., ../dataframe-core, ../dataframe-parsing, ../dataframe-operations, ../dataframe-csv, ../dataframe-json, ../dataframe-parquet, ../dataframe-lazy, ../dataframe-viz, ../dataframe-expr-serializer, ../dataframe-th, ../dataframe-csv-th, ../dataframe-parquet-th, ../dataframe-huggingface+-- cabal: build-depends: dataframe, dataframe-learn, text, random+-- cabal: default-extensions: OverloadedStrings, TypeApplications, DataKinds, TypeOperators, FlexibleContexts -- cabal: ghc-options: -w import qualified DataFrame as D-import DataFrame.LinearModel-import DataFrame.Model (fit, predict)+import DataFrame.Learn  sales = D.fromNamedColumns     [ ("x", D.fromList ([1, 2, 3, 4, 5, 6] :: [Double]))@@ -49,15 +47,34 @@ > <!-- scripths:mime text/plain --> > 2.0 * x + 0.9999999999999989 -## A decision tree is a readable expression+## Type-safe linear regression -The tree compiles to nested `if/then/else` over your columns — no special-viewer, it is just an expression:+`fit` and `predict` work on both typed and untyped dataframes. You can+have the compiler enforce that you don't hand the fit function a frame+with nullable fields or a non-Double:  ```haskell-import DataFrame.DecisionTree (defaultTreeConfig)-import DataFrame.DecisionTree.Model ()+import qualified DataFrame.Typed as T+import Data.Maybe (fromJust) +salesT     = T.unsafeFreeze @'[T.Column "x" Double, T.Column "y" Double] sales+typedModel = fit defaultLinearConfig (T.col @"y") salesT+scored     = T.derive @"prediction" (predict typedModel) salesT++putStr (unlines+    [ "typed model:  " ++ D.prettyPrint (T.unTExpr (predict typedModel))+    , "schema after: " ++ show (T.columnNames scored) ])+```++> <!-- scripths:mime text/plain -->+> typed model:  2.0 * x + 0.9999999999999989+> schema after: ["x","y","prediction"]++## 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]))@@ -70,10 +87,10 @@  > <!-- scripths:mime text/plain --> > if petal_length .<=. 2.95->   then 0.0->   else if petal_length .<=. 5.1->     then 1.0->     else 2.0+> then 0.0+> else if petal_length .<=. 5.1+> then 1.0+> else 2.0  ## Symbolic regression discovers a formula @@ -81,8 +98,6 @@ it as a dataframe `Expr` plus the accuracy/complexity Pareto front:  ```haskell-import DataFrame.SymbolicRegression- curve = D.fromNamedColumns     [ ("x", D.fromList xs)     , ("y", D.fromList [x * x + x | x <- xs])@@ -98,36 +113,9 @@ > <!-- scripths:mime text/plain --> > x + x * x   (mse 0.0) -## When the formula is bigger than a glance--Not every model is a one-liner. A linear model, a small tree, or a symbolic-expression you can *read*; a 40-tree gradient booster you cannot — its `predict`-is an exact sum of forty trees. Counting the characters in each printed formula-shows the gap:--```haskell-import DataFrame.Boosting--gbm = fit defaultGBConfig { gbNEstimators = 40, gbMaxDepth = 2 } (D.col @Double "y") sales-putStr (unlines-    [ "linear prediction: " ++ show (length (D.prettyPrint (predict model))) ++ " characters"-    , "GBM(40 trees):     " ++ show (length (D.prettyPrint (predict gbm)))  ++ " characters" ])-```--> <!-- scripths:mime text/plain -->-> linear prediction: 28 characters-> GBM(40 trees):     7151 characters--Even when it is too big to eyeball, the expression is still the whole story:-a self-contained, dependency-free artifact that scores a frame with `derive` —-no pickled blob, no runtime to ship. For the big ensembles the *interpretability*-comes from `gbFeatureImportances` and pretty-printing individual trees, not from-reading the summed formula.- ## Deploy: applying an expression to a frame -Because the model is an `Expr`, deploying it is just `derive` — you add the-prediction as a new column with the ordinary dataframe API:+Because the model is an `Expr` you can use `derive` to do inference.  ```haskell D.columnNames (D.derive "prediction" (predict model) sales)@@ -139,16 +127,13 @@ ## A model and its preprocessing compose by substitution  Preprocessing is an expression too, so a model trained in a transformed space and-the transform that produced it *compose* — and composition of expressions is+the transform that produced it compose. Composition of expressions is substitution of one into the other. `compileThrough` performs that composition, folding a fitted transform into a prediction so the result is a single formula-over the raw inputs. Here we standardize `x`, fit in the scaled space, then fold+over the raw inputs. Below we standardize `x`, fit in the scaled space, then fold the scaler back in to recover a raw-column model:  ```haskell-import DataFrame.Transform-import DataFrame.Metrics- scaler      = standardScaler ["x"] sales scaledSales = applyTransform (scalerTransform scaler) sales scaledModel = fit defaultLinearConfig (D.col @Double "y") scaledSales@@ -164,9 +149,7 @@ > folded to raw columns:   3.4156502553198655 * (x - 3.5) / 1.707825127659933 + 8.0  The folded expression is a function of the raw `x` alone, so it scores the-original frame with no preprocessing step at inference time — and by the-substitution lemma it computes the same result (up to floating point) as-transforming the frame and then predicting:+original frame with no preprocessing step at inference time.  ```haskell evaluate rmse deployed (D.col @Double "y") sales@@ -175,12 +158,11 @@ > <!-- scripths:mime text/plain --> > 3.6259732146947156e-16 -## A realistic run: pick features, split, evaluate held-out, tune--Real frames are noisy and carry columns you must not train on. Here is a noisy-linear signal with a spurious `id` column:+## 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)@@ -189,39 +171,20 @@   where     xs      = map fromIntegral [1 .. 40 :: Int] :: [Double]     noise i = fromIntegral ((i * 2654435761 + 12345) `mod` 1000) / 100 - 5-``` -> <!-- scripths:mime text/plain -->--**Feature selection.** Supervised `fit` uses *every* non-target column as a-feature, so a naive fit drags `id` into the model. `selectFeatures` restricts to-the columns you mean (mirroring the explicit feature list the unsupervised-fitters take), which is the difference between a leaky model and a clean one:--```haskell-import DataFrame.Model (selectFeatures)--naive   = fit defaultLinearConfig (D.col @Double "y") realistic-guarded = fit defaultLinearConfig (D.col @Double "y")-              (selectFeatures ["x"] (D.col @Double "y") realistic)-putStr (unlines-    [ "all columns:           " ++ D.prettyPrint (predict naive)-    , "selectFeatures [\"x\"]:   " ++ D.prettyPrint (predict guarded) ])+clean = D.select ["x", "y"] realistic ```  > <!-- scripths:mime text/plain -->-> all columns:           -7.746701620642152e-3 * id + 1.9914915483217268 * x + 1.6474622984919354-> selectFeatures ["x"]:   1.9918011257035637 * x + 1.2630769230769452 -**Hold-out evaluation.** `trainTestSplit` (seeded, deterministic) keeps the score-honest — evaluate on rows the model never saw, and the metrics are realistic, not-the `1e-15` of an in-sample toy:+**Hold-out evaluation.** `randomSplit` (seeded, deterministic) keeps the+score honest — evaluate on rows the model never saw, and the metrics are+realistic, not the `1e-15` of an in-sample toy:  ```haskell-import DataFrame.ModelSelection+import System.Random (mkStdGen) -clean        = selectFeatures ["x"] (D.col @Double "y") realistic-(train, test) = trainTestSplit 0.75 7 clean+(train, test) = D.randomSplit (mkStdGen 7) 0.75 clean heldModel     = fit defaultLinearConfig (D.col @Double "y") train putStr (unlines     [ "held-out R^2:  " ++ show (evaluate r2   (predict heldModel) (D.col @Double "y") test)@@ -248,7 +211,7 @@  `gridSearch` tunes hyperparameters the same way, over a list of configs. -## Reports without hand-rolling metrics+## Reporting metrics  Metrics are plain functions (`rmse`, `mse`, `r2`, `accuracy`, multiclass `precision`/`recall`/`f1`), and `classificationReport` bundles the common numbers@@ -256,8 +219,6 @@ macro/weighted averages):  ```haskell-import DataFrame.Metrics.Report- clf = fit defaultLogisticConfig (D.col @Double "species") flowers putStr (show (classificationReportExpr (predict clf) (D.col @Double "species") flowers)) ```@@ -279,8 +240,6 @@ expression over the raw columns for export:  ```haskell-import DataFrame.PCA- features = ["petal_length", "petal_width"] scalerF  = standardScaler features flowers pca      = fit (PCAConfig (NComp 2) True) (map (D.col @Double) features) flowers@@ -292,21 +251,6 @@ > <!-- scripths:mime text/plain --> > ["petal_length","petal_width","species","pc1","pc2"] -## Unsupervised models are inspectable too--k-means returns `cluster_centers_`-style centroids, and per-cluster distance /-assignment expressions:--```haskell-import DataFrame.KMeans--km = fit defaultKMeansConfig { kmK = 3, kmSeed = 1 } (map (D.col @Double) features) flowers-kmCenters km-```--> <!-- scripths:mime text/plain -->-> [[1.4,0.2],[5.65,2.05],[4.55,1.4500000000000002]]- ## Synthesize the feature you would have hand-engineered  `DataFrame.Synthesis` is automated feature engineering: a bottom-up enumerative@@ -317,8 +261,6 @@ to exact — still a formula you can read:  ```haskell-import DataFrame.Synthesis- interactions = D.fromNamedColumns     [ ("a", D.fromList as)     , ("b", D.fromList bs)@@ -333,7 +275,7 @@ withFeat = D.derive "synth" (predict feature) interactions fitModel =     fit defaultLinearConfig (D.col @Double "y")-        (selectFeatures ["synth"] (D.col @Double "y") withFeat)+        (D.select ["synth", "y"] withFeat)  putStr (unlines     [ "discovered feature: " ++ D.prettyPrint (predict feature)@@ -349,125 +291,3 @@  `predict feature` is the single best expression; `sfFeatures feature` is the whole ranked, deduplicated bank, ready to `derive` as a batch of candidate columns.--## What's in the box--| Task | Models |-|------|--------|-| Regression | OLS, ridge, lasso, elastic net, regression trees, gradient boosting, symbolic regression |-| Classification | logistic regression, linear SVC, RFF kernel SVM, decision trees, gradient boosting, AdaBoost |-| Dimensionality reduction | PCA, Nyström kernel PCA |-| Clustering | k-means, Gaussian mixtures, DBSCAN |-| Feature engineering | `DataFrame.Synthesis` (enumerative feature synthesis), symbolic regression |-| Evaluation | `DataFrame.Metrics` (metrics + `evaluate`), `DataFrame.Metrics.Report` (reports) |-| Pipelines & tuning | `DataFrame.Transform` (composable transforms), `DataFrame.ModelSelection` (`trainTestSplit`, `crossValidate`, `gridSearch`) |--Every model is a `Fit` instance, so there is one verb to train — `fit cfg input-df` — and every model with an honest out-of-sample prediction is a `Predict`-instance, so one verb to compile it — `predict model`. Auxiliary outputs-(`gbProbaExpr`, `logisticProbExprs`, `kmeansDistanceExprs`, `pcaTransform`, …)-keep descriptive names; transductive models like DBSCAN deliberately have no-`Predict` instance. Fits that use randomness take a `seed` in their config, so-results are reproducible across Linux, macOS, and Windows. Pure Haskell — the-only extra dependency beyond the dataframe packages is `random`.--## Design notes: the categorical account--The two verbs live in `DataFrame.Model`:--```-class Fit cfg input model | cfg input -> model where-    fit :: cfg -> input -> DataFrame -> model--class Predict model r | model -> r where-    predict :: model -> Expr r-```--They are small on purpose, because the structure they hang on lives in the-expression language, not in the classes. The framing borrows from-[*Seven Sketches in Compositionality*](https://arxiv.org/abs/1803.05316)-(Fong & Spivak) and the Para/Lens account of learners-([Fong, Johnson & Spivak, *Lenses and Learners*](https://arxiv.org/abs/1903.03671);-[Cruttwell et al., *Categorical Foundations of Gradient-Based Learning*](https://arxiv.org/abs/2103.01931)).-What follows is deliberately careful about what is load-bearing and what is only-analogy.--**The row-wise fragment is a category.** Restrict to the row-wise expression-constructors — `Col`, `Lit`, `Unary`, `Binary`, `If`. Take typed column contexts-as objects and, as an arrow `Γ → Δ`, a `Δ`-tuple of such expressions over `Γ`.-Composition is simultaneous substitution (`substituteColumns`, added to-`dataframe-core` for exactly this) and the identities are the column projections-(`Col`). This is the category of contexts (the Lawvere theory) of the column-signature. The restriction is load-bearing for *both* laws, not just composition:-`Agg` and `Over` are column-level/relational, not row-wise maps, and the raw-text-column reference inside `CastWith` is opaque to substitution — so identity-by-`Col`-fails on those constructors too. They are excluded by construction (transforms-reject `Agg`/`Over`), which is why composition and identity stay well defined.--**`predict` gives every model a uniform codomain.** `Predict model r` interprets a-fitted model as an arrow in that category: `predict model :: Expr r` runs from the-model's feature context to the one-column context `{r}`, and the dependency-`model -> r` fixes the codomain object. This is *not* a functor or a denotation in-the technical sense — there is no category of models to be functorial over. The-real, useful property is uniformity: every model's prediction lands in the *same*-expression type (`Expr Double`/`Expr a`/`Expr Int`), so `derive`, the `Transform`-monoid, and `compileThrough` all apply with no per-model glue. That the compiled-`Expr` actually agrees with the fitted record's own parameters is a tested property-(`tests/Learn/Denotation.hs`), not a typeclass law — the class only knows the-symbolic half.--**`fit` is the parametrized-morphism (Para) fragment.** `fit cfg input df` chooses a-parameter — the trained record — and `predict` is the forward map applied at it.-In the Para/Lens picture of learning a learner is a *parametrized lens* carrying a-forward map plus backward update/request maps; we inhabit only the forward (Para)-part and expose no backward maps, because this interface is batch training, not-online gradient exchange. That is a complete, self-contained sub-structure, not a-half-built one — but it does mean the Lens vocabulary is motivation here, not-something the code instantiates. (The functional dependency `cfg input -> model`-fixes the parameter *type*; `fit` is the value-level map that picks the point.)--**`Transform` is a monoid of derived-column lists.** `Transform`'s `<>` keeps the-earlier step's outputs and rewrites the later step's column references through them-by substitution; `mempty` is the empty list. These are context-*extending* maps-(`applyTransform` adds columns), so this is an ordinary algebraic monoid — a monoid-*is* a one-object category (Seven Sketches ch. 3) — not the endomorphism monoid of a-fixed object. Associativity and identity hold for the row-wise fragment **provided-output names do not collide**: the implementation merges output maps with-`Data.Map.fromList`, which keeps the last binding on a clash, so reusing a column-name across steps is the one way to break the law.--**Composition is the point.** `compileThrough t (predict m)` realizes the composite-`predict m ∘ t` (read right-to-left: first `t`, then `predict m`) by substituting-`t`'s definitions into `predict m`. By the substitution lemma it denotes the same-function as transforming the frame and then predicting — equal results up to-floating point, not syntactically identical expressions. That is exactly the-"compose by substitution" example above, and it is why a model trained in a-transformed space deploys as one formula over the raw columns.--**What deliberately has no `predict` — two different reasons.** DBSCAN is-transductive: every clustering *fit* depends on the whole training set, but what-distinguishes the models is whether the *fitted* model induces an out-of-sample-rule. k-means (nearest centroid) and GMM (max posterior) do, so they have honest-`predict` arrows; DBSCAN's density-reachability assignment has no per-row rule, so-we give it no `Predict` instance rather than a fake `Maybe` or a throwing stub.-PCA and kernel PCA are the *opposite* case: they *are* arrows, but multi-output-feature maps with no privileged label column, so their canonical interface is a-`Transform` (`pcaTransform`/`pcaExprs`), not a one-column `predict`.--**A note on classifiers.** A multiclass `predict` is a genuine arrow into the label-object, but it compiles arg-max to a nested-`If` cascade (`argMaxExpr`), quadratic-in the number of classes — so for a 5-class model `prettyPrint (predict m)` is an-If-tree, not a tidy formula. The "model is a readable formula" aesthetic is honest-for affine and tree models; for classifiers and clusterers the value is that the-arrow exists and composes, not that it is short.--**An aside.** A linear or affine model's prediction is a signal-flow graph — a-weighted sum of inputs. `affineExpr` builds the arrow in the prop of affine *maps*-(the single-valued sub-prop of Seven Sketches ch. 5's signal-flow calculus of-affine relations), and dropping zero-weight terms is diagram simplification —-deleting a zero-gain wire.--(The "instance is a functor `C → Set`" slogan from Spivak's functorial data model,-Seven Sketches ch. 3, is sometimes invoked for dataframes; a single flat table is-the degenerate case — a schema with no foreign-key morphisms — so it is an analogy-here, not a structure we use.)
dataframe-learn.cabal view
@@ -1,6 +1,6 @@-cabal-version:      2.4+cabal-version:      3.4 name:               dataframe-learn-version:            1.1.0.1+version:            2.0.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@@ -28,10 +28,13 @@         -Wunused-local-binds         -Wunused-packages -library+-- Numeric kernels, linear solver, tree engine, and symbolic-regression+-- internals. Private: reachable only by this package's own components+-- (the public estimators below and the learn-internal test-suite).+library internal     import:             warnings+    visibility:         private     exposed-modules:-                        DataFrame.DecisionTree                         DataFrame.DecisionTree.Types                         DataFrame.DecisionTree.CondVec                         DataFrame.DecisionTree.Cart@@ -50,18 +53,42 @@                         DataFrame.LinearAlgebra.Eigen                         DataFrame.Random                         DataFrame.Featurize.Internal+                        DataFrame.SymbolicRegression.Expr+                        DataFrame.SymbolicRegression.Simplify+                        DataFrame.SymbolicRegression.Optimize+                        DataFrame.SymbolicRegression.GP+    build-depends:      base >= 4 && < 5,+                        containers >= 0.6.7 && < 0.10,+                        parallel >= 3.3 && < 4,+                        random >= 1.2 && < 2,+                        dataframe-core:internal >= 2.0 && < 2.1,+                        dataframe-operations >= 2.0 && < 2.1,+                        text >= 2.1 && < 3,+                        vector >= 0.13 && < 0.15,+                        vector-algorithms >= 0.9 && < 0.11+    hs-source-dirs:     src-internal+    default-language:   Haskell2010++-- Curated estimator surface: fit/predict + configs + fitted records + the+-- DataFrame.Learn umbrella. Implementation sealed in the private sublib.+library+    import:             warnings+    exposed-modules:+                        DataFrame.Learn                         DataFrame.Model                         DataFrame.LinearModel                         DataFrame.LinearModel.Regression                         DataFrame.LinearModel.Logistic                         DataFrame.SVM+                        DataFrame.SVM.RFF+                        DataFrame.DecisionTree                         DataFrame.DecisionTree.Regression                         DataFrame.DecisionTree.Model                         DataFrame.PCA                         DataFrame.PCA.Kernel-                        DataFrame.SVM.RFF                         DataFrame.KMeans                         DataFrame.Transform+                        DataFrame.Transform.Serialize                         DataFrame.Boosting                         DataFrame.Boosting.GBM                         DataFrame.Boosting.AdaBoost@@ -71,19 +98,59 @@                         DataFrame.Metrics.Report                         DataFrame.ModelSelection                         DataFrame.SymbolicRegression-                        DataFrame.SymbolicRegression.Expr-                        DataFrame.SymbolicRegression.Simplify-                        DataFrame.SymbolicRegression.Optimize-                        DataFrame.SymbolicRegression.GP                         DataFrame.Synthesis+                        DataFrame.Segmented     build-depends:      base >= 4 && < 5,-                        containers >= 0.6.7 && < 0.9,-                        parallel ^>= 3.2,+                        aeson >= 0.11.0.0 && < 3,+                        containers >= 0.6.7 && < 0.10,+                        parallel >= 3.3 && < 4,                         random >= 1.2 && < 2,-                        dataframe-core ^>= 1.1,-                        dataframe-operations ^>= 1.1.1,+                        dataframe-core >= 2.0 && < 2.1,+                        dataframe-core:internal >= 2.0 && < 2.1,+                        dataframe-operations >= 2.0 && < 2.1,+                        dataframe-operations:internal >= 2.0 && < 2.1,+                        dataframe-expr-serializer >= 1.1 && < 1.2,+                        dataframe-learn:internal,                         text >= 2.1 && < 3,-                        vector ^>= 0.13,-                        vector-algorithms ^>= 0.9+                        vector >= 0.13 && < 0.15     hs-source-dirs:     src+    default-language:   Haskell2010++-- Tests that exercise the private `internal` sublib directly (solver, tree+-- engine, symbolic-regression internals). They live here rather than in the+-- meta `dataframe` test-suite because that suite cannot reach a private sublib.+test-suite learn-internal+    import:             warnings+    type:               exitcode-stdio-1.0+    main-is:            Main.hs+    other-modules:      DataFrameApi+                        Cart+                        DecisionTree+                        TreePruning+                        Worklist+                        LinearSolver+                        Properties.Simplify+                        Learn.Numerics+                        Learn.Symbolic+                        Learn.EdgeCases+                        Learn.NumericalRigor+    -- Depends on the constituent packages directly, NOT the meta `dataframe`+    -- (which depends back on dataframe-learn, a package-level cycle).+    build-depends:      base >= 4 && < 5,+                        aeson >= 0.11.0.0 && < 3,+                        bytestring >= 0.11 && < 0.14,+                        containers >= 0.6.7 && < 0.10,+                        dataframe-core >= 2.0 && < 2.1,+                        dataframe-core:internal >= 2.0 && < 2.1,+                        dataframe-csv >= 2.0 && < 2.1,+                        dataframe-learn,+                        dataframe-learn:internal,+                        dataframe-operations >= 2.0 && < 2.1,+                        dataframe-operations:internal >= 2.0 && < 2.1,+                        HUnit >= 1.6 && < 1.8,+                        QuickCheck >= 2 && < 3,+                        random >= 1 && < 2,+                        text >= 2.1 && < 3,+                        vector >= 0.13 && < 0.15+    hs-source-dirs:     tests-internal     default-language:   Haskell2010
+ src-internal/DataFrame/DecisionTree/Cart.hs view
@@ -0,0 +1,290 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedStrings #-}+{-# 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 (..),+    CartNode (..),+    sortIndicesByValue,+    buildCartTree,+    cartFeatures,+    cartTargetLabels,+) where++import DataFrame.DecisionTree.Types (Tree (..), TreeConfig (..))+import qualified DataFrame.Functions as F+import DataFrame.Internal.Column+import DataFrame.Internal.DataFrame (DataFrame, columnNames, unsafeGetColumn)+import DataFrame.Internal.Expression (Expr (..))+import DataFrame.Internal.Interpreter (interpret)+import DataFrame.Internal.Types+import DataFrame.Operations.Core (nRows)+import DataFrame.Operators++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+import Data.Type.Equality (testEquality, (:~:) (..))+import qualified Data.Vector as V+import qualified Data.Vector.Algorithms.Merge as VA+import qualified Data.Vector.Unboxed as VU+import Type.Reflection (typeRep)++{- | A one-hot feature column: per-row Double values plus the sklearn LEFT+predicate (@x <= threshold@) over the ORIGINAL DataFrame.+-}+data CartFeature = CartFeature+    { cfValues :: !(VU.Vector Double)+    , cfPred :: !(Double -> Expr Bool)+    }++-- | Pre-'Tree' CART node: a leaf class id, or a split on feature @j@.+data CartNode = CLeaf !Int | CSplit !Int !Double !CartNode !CartNode++-- | Immutable per-fit context for the CART recursion.+data CartCtx = CartCtx+    { ctxFeats :: !(V.Vector CartFeature)+    , ctxNFeats :: !Int+    , ctxCodes :: !(VU.Vector Int)+    , ctxNClasses :: !Int+    , ctxMaxDepth :: !Int+    , 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.+-}+sortIndicesByValue :: VU.Vector Double -> VU.Vector Int+sortIndicesByValue vs =+    VU.create $ do+        mv <- VU.thaw (VU.enumFromN 0 (VU.length vs))+        VA.sortBy (compare `on` (vs VU.!)) mv+        pure mv++buildCartTree ::+    forall a. (Columnable a, Ord a) => TreeConfig -> T.Text -> DataFrame -> Tree a+buildCartTree cfg target df =+    cartToTree feats classes (buildCartNode ctx 0 (VU.enumFromN 0 nAll) featSorted)+  where+    nAll = nRows df+    feats = V.fromList (cartFeatures target df)+    featSorted = V.map (sortIndicesByValue . cfValues) feats+    labels = cartLabels @a df target+    classes = cartClasses labels+    ctx =+        CartCtx+            feats+            (V.length feats)+            (classCodes classes labels)+            (V.length classes)+            (maxTreeDepth cfg)+            (max 1 (minLeafSize cfg))++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 err (toVector @a column)+    _ -> err+  where+    err = error "buildCartTree: cannot interpret target column"++cartClasses :: (Ord a) => V.Vector a -> V.Vector a+cartClasses = V.fromList . Set.toList . Set.fromList . V.toList++classCodes :: (Ord a) => V.Vector a -> V.Vector a -> VU.Vector Int+classCodes classes labels = VU.generate (V.length labels) (\i -> M.findWithDefault 0 (labels V.! i) ix)+  where+    ix = M.fromList (zip (V.toList classes) [0 ..])++cartToTree :: V.Vector CartFeature -> V.Vector a -> CartNode -> Tree a+cartToTree feats classes = go+  where+    go (CLeaf cid) = Leaf (classes V.! cid)+    go (CSplit fj thr l r) = Branch (cfPred (feats V.! fj) thr) (go l) (go r)++classCounts :: CartCtx -> VU.Vector Int -> VU.Vector Int+classCounts ctx idxs =+    VU.accumulate+        (+)+        (VU.replicate (ctxNClasses ctx) 0)+        (VU.map (\i -> (ctxCodes ctx VU.! i, 1)) idxs)++isPure :: VU.Vector Int -> Bool+isPure counts = VU.length (VU.filter (> 0) counts) <= 1++buildCartNode ::+    CartCtx -> Int -> VU.Vector Int -> V.Vector (VU.Vector Int) -> CartNode+buildCartNode ctx depth idxs sortedByFeat+    | VU.length idxs < 2 || depth >= ctxMaxDepth ctx || isPure counts = leaf+    | otherwise =+        maybe+            leaf+            (splitNode ctx depth idxs sortedByFeat)+            (bestSplit ctx sortedByFeat counts n)+  where+    n = VU.length idxs+    counts = classCounts ctx idxs+    leaf = CLeaf (VU.maxIndex counts)++splitNode ::+    CartCtx ->+    Int ->+    VU.Vector Int ->+    V.Vector (VU.Vector Int) ->+    (Int, Double) ->+    CartNode+splitNode ctx depth idxs sortedByFeat (fj, thr) =+    CSplit fj thr (rec leftIdx leftSorted) (rec rightIdx rightSorted)+  where+    vals = cfValues (ctxFeats ctx V.! fj)+    leftIdx = VU.filter (\i -> vals VU.! i <= thr) idxs+    rightIdx = VU.filter (\i -> vals VU.! i > thr) idxs+    leftSorted = V.map (VU.filter (\i -> vals VU.! i <= thr)) sortedByFeat+    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.+-}+bestSplit ::+    CartCtx ->+    V.Vector (VU.Vector Int) ->+    VU.Vector Int ->+    Int ->+    Maybe (Int, Double)+bestSplit ctx sortedByFeat counts n =+    fmap (\(_, j, t) -> (j, t)) (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.+-}+data Sweep = Sweep+    { swBest :: !(Maybe (Double, Double))+    , swLeft :: ![Int]+    , swMoved :: !Int+    , swPrev :: !Double+    }++sweepFeature ::+    CartCtx ->+    [Int] ->+    VU.Vector Int ->+    CartFeature ->+    Int ->+    Maybe (Double, Double)+sweepFeature ctx total si feat n =+    swBest+        ( foldl'+            step+            (Sweep Nothing (replicate (ctxNClasses ctx) 0) 0 (0 / 0))+            [0 .. VU.length si - 1]+        )+  where+    vals = cfValues feat+    step s k = advance ctx total n (vals VU.! i) (ctxCodes ctx VU.! i) s+      where+        i = si VU.! k++advance :: CartCtx -> [Int] -> Int -> Double -> Int -> Sweep -> Sweep+advance ctx total n v c s =+    Sweep+        (considerThreshold ctx total n v s)+        (bumpClass c (swLeft s))+        (swMoved s + 1)+        v++considerThreshold ::+    CartCtx -> [Int] -> Int -> Double -> Sweep -> Maybe (Double, Double)+considerThreshold ctx total n v s+    | swMoved s >= ctxMinLeaf ctx+    , n - swMoved s >= ctxMinLeaf ctx+    , v > swPrev s + 1e-7 =+        keepBetter+            (swBest s)+            (weightedGini total (swLeft s) (swMoved s) n)+            ((swPrev s + v) / 2)+    | otherwise = swBest s++keepBetter ::+    Maybe (Double, Double) -> Double -> Double -> Maybe (Double, Double)+keepBetter best g thr = case best of+    Just (wb, _) | wb <= g -> best+    _ -> Just (g, thr)++weightedGini :: [Int] -> [Int] -> Int -> Int -> Double+weightedGini total leftAcc nl n =+    ( fromIntegral nl * giniImpurity leftAcc nl+        + fromIntegral nr * giniImpurity rightAcc nr+    )+        / fromIntegral n+  where+    nr = n - nl+    rightAcc = zipWith (-) total leftAcc++-- | Gini impurity @1 - Σ (c/m)²@ of a class-count list of total @m@.+giniImpurity :: [Int] -> Int -> Double+giniImpurity _ 0 = 0+giniImpurity cs m = 1 - sum [let p = fromIntegral c / fromIntegral m in p * p | c <- cs]++bumpClass :: Int -> [Int] -> [Int]+bumpClass c = zipWith (\j x -> if j == c then x + 1 else x) [0 ..]++-- | 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))++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+    pt@(PackedText _ _) -> case materializePacked pt of+        BoxedColumn _ (v :: V.Vector b) -> oneHotFeatures @b (nRows df) c v+        _ -> []++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)]+    Nothing -> case sIntegral @b of+        STrue ->+            [ CartFeature (VU.map fromIntegral v) (\t -> F.toDouble (F.col @b c) .<=. F.lit t)+            ]+        SFalse -> []++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))]+    Nothing -> []++oneHot :: Int -> T.Text -> V.Vector T.Text -> T.Text -> CartFeature+oneHot nAll c 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))++-- | Target column as string labels (matches pandas @y.astype(str)@).+cartTargetLabels :: T.Text -> DataFrame -> V.Vector T.Text+cartTargetLabels target df = case unsafeGetColumn target df of+    BoxedColumn _ (v :: V.Vector b) -> case testEquality (typeRep @b) (typeRep @T.Text) of+        Just Refl -> v+        Nothing -> V.map (T.pack . show) v+    UnboxedColumn _ (v :: VU.Vector b) -> V.map (T.pack . show) (V.convert v)+    pt@(PackedText _ _) -> case materializePacked pt of+        BoxedColumn _ (v :: V.Vector b) -> case testEquality (typeRep @b) (typeRep @T.Text) of+            Just Refl -> v+            Nothing -> V.map (T.pack . show) v+        _ -> V.empty
+ src-internal/DataFrame/DecisionTree/Categorical.hs view
@@ -0,0 +1,380 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE GADTs #-}+{-# 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.+-}+module DataFrame.DecisionTree.Categorical (+    TargetInfo (..),+    mkTargetInfo,+    distinctValuesUpTo,+    validBoxedValues,+    orEqs,+    subsetSplits,+    subsetLists,+    singletonSplits,+    singletonLists,+    breimanPrefixSplits,+    breimanPrefixLists,+    catValueLists,+    membershipVec,+    crossColumnConds,+    discreteConditions,+    discreteCondVecs,+) where++import DataFrame.DecisionTree.CondVec (CondVec (..), materializeCondVec)+import DataFrame.DecisionTree.Types (+    ColumnOrdering,+    SynthConfig (..),+    TreeConfig (..),+    withOrdFrom,+ )+import DataFrame.Internal.Column+import DataFrame.Internal.DataFrame (DataFrame, columnNames, unsafeGetColumn)+import DataFrame.Internal.Expression (Expr (..))+import DataFrame.Internal.Interpreter (interpret)+import DataFrame.Internal.Types+import DataFrame.Operators++import Data.Either (fromRight)+import Data.Function (on)+import Data.List (inits, sort, sortBy, subsequences)+import qualified Data.Map.Strict as M+import Data.Maybe (fromMaybe, mapMaybe)+import qualified Data.Set as Set+import qualified Data.Text as T+import Data.Type.Equality (testEquality, (:~:) (..))+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import Type.Reflection (typeRep)++-- | Valid-slot view of a nullable boxed column (null slots hold crash-thunks).+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.+-}+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.+-}+mkTargetInfo ::+    forall target.+    (Columnable target, Ord target) =>+    T.Text -> DataFrame -> Maybe (TargetInfo target)+mkTargetInfo target df = case interpret @target df (Col target) of+    Right (TColumn column) ->+        either (const Nothing) (Just . targetInfoFromValues) (toVector @target column)+    _ -> Nothing++targetInfoFromValues :: (Ord target) => V.Vector target -> TargetInfo target+targetInfoFromValues vals = TargetInfo isBinary posClass vals+  where+    distinct = Set.toAscList (Set.fromList (V.toList vals))+    isBinary = length distinct == 2+    posClass = case distinct of+        (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.+-}+distinctValuesUpTo :: (Ord a) => Int -> V.Vector a -> Either Int [a]+distinctValuesUpTo cap values = go Set.empty 0+  where+    n = V.length values+    go !s !i+        | i >= n = Right (Set.toAscList s)+        | 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.+-}+orEqs :: (a -> Expr Bool) -> [a] -> Expr Bool+orEqs eqLit = foldr1 (.||.) . map eqLit++subsetSplits :: (a -> Expr Bool) -> [a] -> [Expr Bool]+subsetSplits eqLit = map (orEqs eqLit) . subsetLists++-- | Proper non-empty, non-full subsets of the values.+subsetLists :: [a] -> [[a]]+subsetLists vs = drop 1 (init (subsequences vs))++singletonSplits :: (a -> Expr Bool) -> [a] -> [Expr Bool]+singletonSplits = map++singletonLists :: [a] -> [[a]]+singletonLists = map (: [])++breimanPrefixSplits ::+    (Ord a, Ord target) =>+    target ->+    V.Vector a ->+    V.Vector target ->+    [a] ->+    (a -> Expr Bool) ->+    [Expr Bool]+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.+-}+breimanPrefixLists ::+    (Ord a, Ord target) => target -> V.Vector a -> V.Vector target -> [a] -> [[a]]+breimanPrefixLists pc values targetVals distinctVals =+    nonTrivialPrefixes (sortByRate (levelCounts pc values targetVals) distinctVals)++levelCounts ::+    (Ord a, Eq target) =>+    target -> V.Vector a -> V.Vector target -> M.Map a (Int, Int)+levelCounts pc values targetVals = V.ifoldl' add M.empty values+  where+    add acc i v = M.insertWith plus v (indicator (V.unsafeIndex targetVals i == pc), 1) acc+    plus (p1, n1) (p2, n2) = (p1 + p2, n1 + n2)+    indicator b = if b then 1 else 0++laplaceRate :: (Ord a) => M.Map a (Int, Int) -> a -> Double+laplaceRate counts v = case M.lookup v counts of+    Nothing -> 0.5+    Just (pos, n) -> (fromIntegral pos + 1) / (fromIntegral n + 2)++sortByRate :: (Ord a) => M.Map a (Int, Int) -> [a] -> [a]+sortByRate counts = sortBy (compare `on` (\v -> (laplaceRate counts v, v)))++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.+-}+catValueLists ::+    (Ord a, Ord target) =>+    Bool -> Maybe target -> V.Vector target -> Int -> V.Vector a -> [[a]]+catValueLists isBinary posClass targetVals subsetCap values+    | V.null values = []+    | isBinary, Just pc <- posClass = binaryLists pc targetVals values+    | otherwise = multiclassLists subsetCap values++binaryLists ::+    (Ord a, Ord target) => target -> V.Vector target -> V.Vector a -> [[a]]+binaryLists pc targetVals values+    | length distinct < 2 = []+    | otherwise = breimanPrefixLists pc values targetVals distinct+  where+    distinct = fromRight (ascDistinct values) (distinctValuesUpTo 64 values)++multiclassLists :: (Ord a) => Int -> V.Vector a -> [[a]]+multiclassLists subsetCap values = case distinctValuesUpTo subsetCap values of+    Right vs | length vs >= 2 -> subsetLists vs+    Right _ -> []+    Left _ -> singletonLists (ascDistinct values)++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.+-}+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.+-}+data CatCtx target = CatCtx+    { ccBinary :: !Bool+    , ccPos :: !(Maybe target)+    , ccTargets :: !(V.Vector target)+    , ccSubsetCap :: !Int+    , ccOrds :: !ColumnOrdering+    }++catCtx :: TargetInfo target -> TreeConfig -> CatCtx target+catCtx ti cfg =+    CatCtx+        (tiIsBinary ti)+        (tiPositiveClass ti)+        (tiValues ti)+        (maxCategoricalSubsetCardinality (synthConfig cfg))+        (columnOrdering cfg)++catValueListsFor :: (Ord a, Ord target) => CatCtx target -> V.Vector a -> [[a]]+catValueListsFor ctx = catValueLists (ccBinary ctx) (ccPos ctx) (ccTargets ctx) (ccSubsetCap ctx)++-- | True for numeric columns (handled by the numeric pool, not here).+isNumericKind :: forall a. (Columnable a) => Bool+isNumericKind = case sFloating @a of+    STrue -> True+    SFalse -> case sIntegral @a of+        STrue -> True+        SFalse -> False++{- | All equality-based candidate splits from non-numeric columns: per-column+categorical conditions plus cross-column equality/order conditions.+-}+discreteConditions ::+    forall target.+    (Columnable target, Ord target) =>+    TargetInfo target -> TreeConfig -> DataFrame -> [Expr Bool]+discreteConditions targetInfo cfg df =+    concatMap (columnConds (catCtx targetInfo cfg) df) (columnNames df)+        ++ crossColumnConds cfg df++columnConds ::+    (Columnable target, Ord target) =>+    CatCtx target -> DataFrame -> T.Text -> [Expr Bool]+columnConds ctx df colName = case unsafeGetColumn colName df of+    BoxedColumn Nothing (column :: V.Vector a) -> nonNullColConds ctx colName column+    BoxedColumn (Just bm) (column :: V.Vector a) -> nullableColConds ctx colName bm column+    UnboxedColumn _ (_ :: VU.Vector a) -> []+    pt@(PackedText _ _) -> case materializePacked pt of+        BoxedColumn Nothing (column :: V.Vector a) -> nonNullColConds ctx colName column+        BoxedColumn (Just bm) (column :: V.Vector a) -> nullableColConds ctx colName bm column+        _ -> []++nonNullColConds ::+    forall a target.+    (Columnable a, Ord target) =>+    CatCtx target -> T.Text -> V.Vector a -> [Expr Bool]+nonNullColConds ctx colName column =+    fromMaybe+        []+        ( withOrdFrom @a+            (ccOrds ctx)+            (map (orEqs (eqExprFor @a colName)) (catValueListsFor ctx column))+        )++nullableColConds ::+    forall a target.+    (Columnable a, Ord target) =>+    CatCtx target -> T.Text -> Bitmap -> V.Vector a -> [Expr Bool]+nullableColConds ctx colName bm column+    | isNumericKind @a || V.null valid = []+    | otherwise =+        fromMaybe+            []+            ( withOrdFrom @a+                (ccOrds ctx)+                (map (orEqs (eqJustFor @a colName)) (catValueListsFor ctx valid))+            )+  where+    valid = validBoxedValues bm column++eqExprFor :: forall a. (Columnable a) => T.Text -> a -> Expr Bool+eqExprFor colName v = Col @a colName .==. Lit v++eqJustFor :: forall a. (Columnable a) => T.Text -> a -> Expr Bool+eqJustFor colName v = Col @(Maybe a) colName .==. Lit (Just v)++-- | Cross-column equality/order conditions over pairs of same-typed columns.+crossColumnConds :: TreeConfig -> DataFrame -> [Expr Bool]+crossColumnConds cfg df = concatMap (pairConds (columnOrdering cfg) df) (allowedPairs cfg df)++allowedPairs :: TreeConfig -> DataFrame -> [(T.Text, T.Text)]+allowedPairs cfg df =+    [ (l, r)+    | l <- columnNames df+    , r <- columnNames df+    , l /= r+    , not (isDisallowedPair cfg l r)+    ]++isDisallowedPair :: TreeConfig -> T.Text -> T.Text -> Bool+isDisallowedPair cfg l r =+    any+        (\(l', r') -> sort [l', r'] == sort [l, r])+        (disallowedCombinations (synthConfig cfg))++pairConds :: ColumnOrdering -> DataFrame -> (T.Text, T.Text) -> [Expr Bool]+pairConds ords df (l, r) = case ( materializePacked (unsafeGetColumn l df)+                                , materializePacked (unsafeGetColumn r df)+                                ) of+    (BoxedColumn Nothing (_ :: V.Vector a), BoxedColumn Nothing (_ :: V.Vector b)) -> strictPairConds @a @b l r+    (BoxedColumn (Just _) (_ :: V.Vector a), BoxedColumn (Just _) (_ :: V.Vector b)) -> nullablePairConds @a @b ords l r+    _ -> []++strictPairConds ::+    forall a b. (Columnable a, Columnable b) => T.Text -> T.Text -> [Expr Bool]+strictPairConds l r = case testEquality (typeRep @a) (typeRep @b) of+    Just Refl -> [Col @a l .==. Col @a r]+    Nothing -> []++nullablePairConds ::+    forall a b.+    (Columnable a, Columnable b) =>+    ColumnOrdering -> T.Text -> T.Text -> [Expr Bool]+nullablePairConds ords l r = case testEquality (typeRep @a) (typeRep @b) of+    Nothing -> []+    Just Refl -> nullableEqOrLe @a ords l r++nullableEqOrLe ::+    forall a. (Columnable a) => ColumnOrdering -> T.Text -> T.Text -> [Expr Bool]+nullableEqOrLe ords l r+    | isTextType @a = eqOnly+    | otherwise =+        maybe+            eqOnly+            (++ eqOnly)+            (withOrdFrom @a ords [Col @(Maybe a) l .<=. Col @(Maybe a) r])+  where+    eqOnly = [Col @(Maybe a) l .==. Col @(Maybe a) r]++isTextType :: forall a. (Columnable a) => Bool+isTextType = case testEquality (typeRep @a) (typeRep @T.Text) of+    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.+-}+discreteCondVecs ::+    forall target.+    (Columnable target, Ord target) =>+    TargetInfo target -> TreeConfig -> DataFrame -> [CondVec]+discreteCondVecs targetInfo cfg df =+    concatMap (columnCondVecs (catCtx targetInfo cfg) df) (columnNames df)+        ++ mapMaybe (materializeCondVec df) (crossColumnConds cfg df)++columnCondVecs ::+    (Columnable target, Ord target) =>+    CatCtx target -> DataFrame -> T.Text -> [CondVec]+columnCondVecs ctx df colName = case unsafeGetColumn colName df of+    BoxedColumn Nothing (column :: V.Vector a) -> nonNullColCondVecs ctx colName column+    BoxedColumn (Just bm) (column :: V.Vector a) -> mapMaybe (materializeCondVec df) (nullableColConds ctx colName bm column)+    UnboxedColumn _ (_ :: VU.Vector a) -> []+    pt@(PackedText _ _) -> case materializePacked pt of+        BoxedColumn Nothing (column :: V.Vector a) -> nonNullColCondVecs ctx colName column+        BoxedColumn (Just bm) (column :: V.Vector a) -> mapMaybe (materializeCondVec df) (nullableColConds ctx colName bm column)+        _ -> []++nonNullColCondVecs ::+    forall a target.+    (Columnable a, Ord target) => CatCtx target -> T.Text -> V.Vector a -> [CondVec]+nonNullColCondVecs ctx colName column =+    fromMaybe+        []+        ( withOrdFrom @a+            (ccOrds ctx)+            (map (membershipCondVec colName column) (catValueListsFor ctx column))+        )++membershipCondVec ::+    forall a. (Columnable a, Ord a) => T.Text -> V.Vector a -> [a] -> CondVec+membershipCondVec colName column vs = CondVec (orEqs (eqExprFor @a colName) vs) (membershipVec column vs)
+ src-internal/DataFrame/DecisionTree/CondVec.hs view
@@ -0,0 +1,180 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedStrings #-}+{-# 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.+-}+module DataFrame.DecisionTree.CondVec (+    CondVec (..),+    materializeCondVec,+    CondCache,+    condCacheKey,+    condCacheFromVecs,+    addTreeCondsToCache,+    lookupCondVec,+    partitionByVec,+    countErrorsByVec,+    consolidateThreshold,+    combineAndVec,+    combineOrVec,+) where++import DataFrame.DecisionTree.Types (CarePoint (..), Direction (..), Tree (..))+import qualified DataFrame.Functions as F+import DataFrame.Internal.Column (TypedColumn (..), toVector)+import DataFrame.Internal.DataFrame (DataFrame)+import DataFrame.Internal.Expression (+    BinaryOp (binaryName),+    Expr (..),+    eqExpr,+    normalize,+ )+import DataFrame.Internal.Interpreter (interpret)++import qualified Data.Map.Strict as M+import Data.Maybe (fromMaybe)+import qualified Data.Text as T+import Data.Type.Equality (testEquality, (:~:) (..))+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import Type.Reflection (typeRep)++-- | A boolean condition paired with its truth vector over the full DataFrame.+data CondVec = CondVec+    { cvExpr :: !(Expr Bool)+    , cvVec :: !(VU.Vector Bool)+    }++{- | Interpret a condition once over the DataFrame; 'Nothing' on a+type/interpret failure so the candidate is silently dropped.+-}+materializeCondVec :: DataFrame -> Expr Bool -> Maybe CondVec+materializeCondVec df cond = case interpret @Bool df cond of+    Left _ -> Nothing+    Right (TColumn column) -> CondVec cond <$> eitherToMaybe (toVector @Bool @VU.Vector column)++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.+-}+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).+-}+condCacheKey :: Expr Bool -> T.Text+condCacheKey = T.pack . show . normalize++-- | Seed a cache from already-materialized candidate 'CondVec's (no interpret).+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).+-}+addTreeCondsToCache :: DataFrame -> Tree a -> CondCache -> CondCache+addTreeCondsToCache df = go+  where+    go (Leaf _) c = c+    go (Branch cond l r) c = go r (go l (insertCond df cond c))++insertCond :: DataFrame -> Expr Bool -> CondCache -> CondCache+insertCond df cond c+    | M.member k c = c+    | otherwise =+        maybe c (\cv -> M.insert k (cvVec cv) c) (materializeCondVec df cond)+  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).+-}+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.+partitionByVec :: VU.Vector Bool -> V.Vector Int -> (V.Vector Int, V.Vector Int)+partitionByVec boolVals = V.partition (boolVals VU.!)++-- | Count care points the truth vector routes to the wrong child.+countErrorsByVec :: VU.Vector Bool -> [CarePoint] -> Int+countErrorsByVec boolVals = length . filter misrouted+  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.+-}+data ThreshCmp = ThreshCmp+    { tcCol :: !T.Text+    , tcName :: !T.Text+    , tcThr :: !Double+    , tcRebuild :: Double -> Expr Bool+    }++asDoubleThreshold :: Expr Bool -> Maybe ThreshCmp+asDoubleThreshold (Binary op (Col c :: Expr cc) (Lit (t :: tt))) =+    case ( testEquality (typeRep @cc) (typeRep @Double)+         , testEquality (typeRep @tt) (typeRep @Double)+         ) of+        (Just Refl, Just Refl) -> Just (ThreshCmp c (binaryName op) t (Binary op (Col c) . Lit))+        _ -> Nothing+asDoubleThreshold _ = Nothing++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).+-}+chooseThreshold :: Bool -> T.Text -> Double -> Double -> Double+chooseThreshold isAnd name t1 t2+    | leftDir = if isAnd then min t1 t2 else max t1 t2+    | otherwise = if isAnd then max t1 t2 else min t1 t2+  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.+-}+consolidateThreshold :: Bool -> Expr Bool -> Expr Bool -> Maybe (Expr Bool)+consolidateThreshold isAnd ea eb = do+    a <- asDoubleThreshold ea+    b <- asDoubleThreshold eb+    if tcCol a == tcCol b && tcName a == tcName b && tcName a `elem` directionalNames+        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.+-}+combineAndVec :: CondVec -> CondVec -> CondVec+combineAndVec a b+    | eqExpr (cvExpr a) (cvExpr b) = a+    | otherwise = CondVec expr (VU.zipWith (&&) (cvVec a) (cvVec b))+  where+    expr =+        fromMaybe+            (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').+-}+combineOrVec :: CondVec -> CondVec -> CondVec+combineOrVec a b+    | eqExpr (cvExpr a) (cvExpr b) = a+    | otherwise = CondVec expr (VU.zipWith (||) (cvVec a) (cvVec b))+  where+    expr =+        fromMaybe+            (F.or (cvExpr a) (cvExpr b))+            (consolidateThreshold False (cvExpr a) (cvExpr b))
+ src-internal/DataFrame/DecisionTree/Fit.hs view
@@ -0,0 +1,213 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE FlexibleContexts #-}+{-# 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.+-}+module DataFrame.DecisionTree.Fit (+    treeToExpr,+    fitDecisionTree,+    buildTree,+    pruneTree,+    partitionDataFrame,+    calculateGini,+    majorityValue,+    getCounts,+    percentile,+    ProbTree,+    probsFromIndices,+    buildProbTree,+    fitProbTree,+    probExprs,+) where++import DataFrame.DecisionTree.Cart (buildCartTree)+import DataFrame.DecisionTree.Categorical (+    TargetInfo (..),+    discreteCondVecs,+    discreteConditions,+    mkTargetInfo,+ )+import DataFrame.DecisionTree.CondVec (CondVec)+import DataFrame.DecisionTree.Numeric (numericCondVecs, numericConditions)+import DataFrame.DecisionTree.Pool (dedupCVByExpr, nubByExpr)+import DataFrame.DecisionTree.Predict (partitionIndices)+import DataFrame.DecisionTree.Prune (pruneDead, pruneExpr)+import DataFrame.DecisionTree.Tao (taoOptimize, taoOptimizeCV)+import DataFrame.DecisionTree.Types (Tree (..), TreeConfig (..))+import qualified DataFrame.Functions as F+import DataFrame.Internal.Column (Columnable, TypedColumn (..), toVector)+import DataFrame.Internal.DataFrame (DataFrame)+import DataFrame.Internal.Expression (Expr (..))+import DataFrame.Internal.Interpreter (interpret)+import DataFrame.Operations.Core (nRows)+import DataFrame.Operations.Subset (exclude, filterWhere)++import Control.Exception (throw)+import Data.Function (on)+import Data.List (foldl', maximumBy, nub, sort)+import qualified Data.Map.Strict as M+import Data.Maybe (fromMaybe)+import qualified Data.Text as T+import qualified Data.Vector as V++-- | Convert a fitted tree to a nested-conditional expression.+treeToExpr :: (Columnable a) => Tree a -> Expr a+treeToExpr (Leaf v) = Lit v+treeToExpr (Branch cond left right) = F.ifThenElse cond (treeToExpr left) (treeToExpr right)++-- | Fit a TAO decision tree (CART-seeded) and return it as an expression.+fitDecisionTree ::+    forall a. (Columnable a, Ord a) => TreeConfig -> Expr a -> DataFrame -> Expr a+fitDecisionTree cfg (Col target) df =+    pruneExpr+        (treeToExpr (taoOptimizeCV @a cfg target condVecs df indices initialTree))+  where+    condVecs = candidatePool @a cfg target df+    initialTree = buildCartTree @a cfg target df+    indices = V.enumFromN 0 (nRows df)+fitDecisionTree _ expr _ = error ("Cannot create tree for compound expression: " ++ show expr)++-- | The deduplicated numeric + discrete candidate pool for a target column.+candidatePool ::+    forall a.+    (Columnable a, Ord a) => TreeConfig -> T.Text -> DataFrame -> [CondVec]+candidatePool cfg target df = dedupCVByExpr (numericCVs ++ discreteCVs)+  where+    dfNoTarget = exclude [target] df+    numericCVs = numericCondVecs cfg dfNoTarget df+    discreteCVs = discreteCondVecs (targetInfoOrEmpty @a target df) cfg dfNoTarget++targetInfoOrEmpty ::+    forall a. (Columnable a, Ord a) => T.Text -> DataFrame -> TargetInfo a+targetInfoOrEmpty target df = fromMaybe (TargetInfo False Nothing V.empty) (mkTargetInfo @a target df)++-- | Fit a tree at a given depth from a raw condition list (CART + TAO + prune).+buildTree ::+    forall a.+    (Columnable a, Ord a) =>+    TreeConfig -> Int -> T.Text -> [Expr Bool] -> DataFrame -> Expr a+buildTree cfg depth target conds df =+    pruneExpr (treeToExpr (taoOptimize @a cfg target conds df indices tree))+  where+    tree = buildCartTree @a cfg{maxTreeDepth = depth} target df+    indices = V.enumFromN 0 (nRows df)++pruneTree :: forall a. (Columnable a) => Expr a -> Expr a+pruneTree = pruneExpr++partitionDataFrame :: Expr Bool -> DataFrame -> (DataFrame, DataFrame)+partitionDataFrame cond df = (filterWhere cond df, filterWhere (F.not cond) df)++-- | Laplace-smoothed Gini impurity of the target distribution.+calculateGini ::+    forall a. (Columnable a, Ord a) => T.Text -> DataFrame -> Double+calculateGini target df+    | n == 0 = 0+    | otherwise = 1 - sum (map (^ (2 :: Int)) probs)+  where+    n = fromIntegral (nRows df)+    counts = getCounts @a target df+    numClasses = fromIntegral (M.size counts)+    probs = map (\c -> (fromIntegral c + 1) / (n + numClasses)) (M.elems counts)++majorityValue :: forall a. (Columnable a, Ord a) => T.Text -> DataFrame -> a+majorityValue target df+    | M.null counts = error "Empty DataFrame in leaf"+    | otherwise = fst (maximumBy (compare `on` snd) (M.toList counts))+  where+    counts = getCounts @a target df++getCounts ::+    forall a. (Columnable a, Ord a) => T.Text -> DataFrame -> M.Map a Int+getCounts target df = case interpret @a df (Col target) of+    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)++-- | The @p@-th percentile of an expression's values (@0@ on failure/empty).+percentile :: Int -> Expr Double -> DataFrame -> Double+percentile p expr df = case interpret @Double df expr of+    Right (TColumn column) -> either (const 0) (percentileOfVec p) (toVector @Double column)+    _ -> 0++percentileOfVec :: Int -> V.Vector Double -> Double+percentileOfVec p vals+    | n == 0 = 0+    | otherwise = sorted V.! min (n - 1) (max 0 ((p * n) `div` 100))+  where+    sorted = V.fromList (sort (V.toList vals))+    n = V.length sorted++-- | A tree whose leaves hold class-probability distributions.+type ProbTree a = Tree (M.Map a Double)++-- | Normalised class probabilities over a subset of training rows.+probsFromIndices ::+    forall a.+    (Columnable a, Ord a) => T.Text -> DataFrame -> V.Vector Int -> M.Map a Double+probsFromIndices target df indices = case interpret @a df (Col target) of+    Right (TColumn column) -> either (const M.empty) (normaliseCounts indices) (toVector @a column)+    _ -> M.empty++normaliseCounts :: (Ord a) => V.Vector Int -> V.Vector a -> M.Map a Double+normaliseCounts indices vals = M.map (\c -> fromIntegral c / total) counts+  where+    counts =+        V.foldl'+            (\acc i -> M.insertWith (+) (vals V.! i) (1 :: Int) acc)+            M.empty+            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.+-}+buildProbTree ::+    forall a.+    (Columnable a, Ord a) =>+    Tree a -> T.Text -> DataFrame -> V.Vector Int -> ProbTree a+buildProbTree (Leaf _) target df indices = Leaf (probsFromIndices @a target df indices)+buildProbTree (Branch cond left right) target df indices =+    Branch+        cond+        (buildProbTree @a left target df l)+        (buildProbTree @a right target df r)+  where+    (l, r) = partitionIndices cond df indices++-- | Fit a TAO tree and return one probability expression per class.+fitProbTree ::+    forall a.+    (Columnable a, Ord a) =>+    TreeConfig -> Expr a -> DataFrame -> M.Map a (Expr Double)+fitProbTree cfg (Col target) df = probExprs (buildProbTree @a pruned target df indices)+  where+    conds =+        nubByExpr+            ( numericConditions cfg dfNoTarget+                ++ discreteConditions (targetInfoOrEmpty @a target df) cfg dfNoTarget+            )+    dfNoTarget = exclude [target] df+    indices = V.enumFromN 0 (nRows df)+    pruned =+        pruneDead+            (taoOptimize @a cfg target conds df indices (buildCartTree @a cfg target df))+fitProbTree _ expr _ = error ("Cannot create prob tree for compound expression: " ++ show expr)++-- | Convert a 'ProbTree' into one @Expr Double@ per class.+probExprs ::+    forall a. (Columnable a, Ord a) => ProbTree a -> M.Map a (Expr Double)+probExprs tree = M.fromList [(c, classExpr c tree) | c <- nub (allClasses tree)]++allClasses :: ProbTree a -> [a]+allClasses (Leaf m) = M.keys m+allClasses (Branch _ l r) = allClasses l ++ allClasses r++classExpr :: (Ord a) => a -> ProbTree a -> Expr Double+classExpr c (Leaf m) = Lit (M.findWithDefault 0.0 c m)+classExpr c (Branch cond l r) = F.ifThenElse cond (classExpr c l) (classExpr c r)
+ src-internal/DataFrame/DecisionTree/Linear.hs view
@@ -0,0 +1,161 @@+{-# 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.+-}+module DataFrame.DecisionTree.Linear (+    bestLinearCandidate,+    fitLinearCandidate,+    careRowsFromFeatures,+    careLabels,+    featName,+    imputeMean,+    materializeFeatureForCare,+) where++import DataFrame.DecisionTree.Numeric (NumExpr (..), numericCols)+import DataFrame.DecisionTree.Types (+    CarePoint (..),+    Direction (..),+    TreeConfig (..),+ )+import DataFrame.Internal.Column (TypedColumn (..), toVector)+import DataFrame.Internal.DataFrame (DataFrame)+import DataFrame.Internal.Expression (Expr, getColumns)+import DataFrame.Internal.Interpreter (interpret)+import qualified DataFrame.LinearSolver as LS++import Data.Maybe (catMaybes, fromMaybe, mapMaybe)+import qualified Data.Text as T+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.+-}+bestLinearCandidate ::+    TreeConfig -> DataFrame -> [CarePoint] -> Maybe (Expr Bool)+bestLinearCandidate cfg df carePoints+    | not (useLinearSolver cfg) = Nothing+    | length carePoints < minCarePointsForLinear cfg = Nothing+    | otherwise = fitLinearCandidate cfg 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.+-}+fitLinearCandidate ::+    TreeConfig -> DataFrame -> [CarePoint] -> Maybe (Expr Bool)+fitLinearCandidate cfg df carePoints = case materializedFeatures df carePoints of+    [] -> Nothing+    mats -> linearFromFeatures cfg carePoints mats++materializedFeatures :: DataFrame -> [CarePoint] -> [(T.Text, VU.Vector Double)]+materializedFeatures df carePoints = mapMaybe (materializeFeatureForCare df carePoints) (numericCols df)++linearFromFeatures ::+    TreeConfig -> [CarePoint] -> [(T.Text, VU.Vector Double)] -> Maybe (Expr Bool)+linearFromFeatures cfg carePoints mats+    | VU.all (== 0) weights = Nothing+    | degenerateHyperplane rows weights (LS.lmIntercept model) = Nothing+    | otherwise = Just (LS.modelToExpr model)+  where+    rows = careRowsFromFeatures (length carePoints) mats+    labels = careLabels carePoints+    model =+        LS.fitL1Logistic+            (solverConfigFor cfg labels)+            rows+            labels+            (V.fromList (map fst mats))+    weights = LS.lmWeights model++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).+-}+classBalancedWeights :: VU.Vector Double -> Maybe (VU.Vector Double)+classBalancedWeights labels+    | nPos > 0 && nNeg > 0 = Just (VU.generate nCare weightAt)+    | otherwise = Nothing+  where+    nCare = VU.length labels+    nPos = VU.length (VU.filter (> 0) labels)+    nNeg = nCare - nPos+    weightAt i+        | 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).+-}+degenerateHyperplane ::+    V.Vector (VU.Vector Double) -> VU.Vector Double -> Double -> Bool+degenerateHyperplane rows weights bias =+    nCare > 0 && (VU.minimum scores > 0 || VU.maximum scores < 0)+  where+    nCare = V.length rows+    scores =+        VU.generate+            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).+-}+careRowsFromFeatures ::+    Int -> [(T.Text, VU.Vector Double)] -> V.Vector (VU.Vector Double)+careRowsFromFeatures nCare mats =+    V.generate nCare (\i -> VU.generate nFeat (\j -> snd (matsVec V.! j) VU.! i))+  where+    matsVec = V.fromList mats+    nFeat = V.length matsVec++-- | Solver labels: @+1@ when 'GoLeft' is correct, @-1@ otherwise.+careLabels :: [CarePoint] -> VU.Vector Double+careLabels carePoints =+    VU.fromList [if cpCorrectDir cp == GoLeft then 1.0 else -1.0 | cp <- carePoints]++-- | First column referenced by an expression, or a placeholder when none.+featName :: Expr b -> T.Text+featName expr = case getColumns expr of+    (c : _) -> c+    [] -> "<feat>"++{- | Replace missing values with the mean of present ones; 'Nothing' when+nothing is present so the caller can drop the feature.+-}+imputeMean :: [Maybe Double] -> Maybe (VU.Vector Double)+imputeMean careRaw = case catMaybes careRaw of+    [] -> Nothing+    present -> Just (VU.fromList [fromMaybe (mean present) mv | mv <- careRaw])+  where+    mean xs = sum xs / fromIntegral (length xs)++interpretDoubleVals :: DataFrame -> Expr Double -> Maybe (V.Vector Double)+interpretDoubleVals df expr = case interpret @Double df expr of+    Right (TColumn column) -> either (const Nothing) Just (toVector @Double column)+    _ -> Nothing++interpretMaybeDoubleVals ::+    DataFrame -> Expr (Maybe Double) -> Maybe (V.Vector (Maybe Double))+interpretMaybeDoubleVals df expr = case interpret @(Maybe Double) df expr of+    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.+-}+materializeFeatureForCare ::+    DataFrame -> [CarePoint] -> NumExpr -> Maybe (T.Text, VU.Vector Double)+materializeFeatureForCare df carePoints (NDouble expr) = do+    vals <- interpretDoubleVals df expr+    Just (featName expr, VU.fromList [vals V.! cpIndex cp | cp <- carePoints])+materializeFeatureForCare df carePoints (NMaybeDouble expr) = do+    vals <- interpretMaybeDoubleVals df expr+    imputed <- imputeMean [vals V.! cpIndex cp | cp <- carePoints]+    Just (featName expr, imputed)
+ src-internal/DataFrame/DecisionTree/Numeric.hs view
@@ -0,0 +1,254 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}++{- | Numeric split candidates: per-column Double expressions, arithmetic+expansion, and threshold conditions. 'numericCondVecs' materializes the+pool with one interpret per distinct expression.+-}+module DataFrame.DecisionTree.Numeric (+    NumExpr (..),+    numExprCols,+    numExprEq,+    combineNumExprs,+    numericConditions,+    generateNumericConds,+    percentilesOf,+    numericCondVecs,+    numericExprsWithTerms,+    numericCols,+) where++import DataFrame.DecisionTree.CondVec (CondVec (..))+import DataFrame.DecisionTree.Types (SynthConfig (..), TreeConfig (..))+import qualified DataFrame.Functions as F+import DataFrame.Internal.Column+import DataFrame.Internal.DataFrame (DataFrame, columnNames, unsafeGetColumn)+import DataFrame.Internal.Expression (Expr (..), eqExpr, getColumns, normalize)+import DataFrame.Internal.Interpreter (interpret)+import DataFrame.Internal.Types+import DataFrame.Operators++import Data.List (sort)+import Data.Maybe (fromMaybe)+import qualified Data.Set as Set+import qualified Data.Text as T+import Data.Type.Equality (testEquality, (:~:) (..))+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import Type.Reflection (typeRep)++-- | A numeric feature expression, non-nullable or nullable.+data NumExpr+    = NDouble !(Expr Double)+    | NMaybeDouble !(Expr (Maybe Double))++numExprCols :: NumExpr -> [T.Text]+numExprCols (NDouble e) = getColumns e+numExprCols (NMaybeDouble e) = getColumns e++numExprEq :: NumExpr -> NumExpr -> Bool+numExprEq (NDouble e1) (NDouble e2) = eqExpr e1 e2+numExprEq (NMaybeDouble e1) (NMaybeDouble e2) = eqExpr e1 e2+numExprEq _ _ = False++-- | Safe division: @0@ (or @Nothing@) where the divisor is zero.+safeDivD :: Expr Double -> Expr Double -> Expr Double+safeDivD a b = F.ifThenElse (b ./= F.lit (0 :: Double)) (a ./ b) (F.lit (0 :: Double))++safeDivMaybe :: Expr Bool -> Expr (Maybe Double) -> Expr (Maybe Double)+safeDivMaybe nonZero q = F.ifThenElse nonZero q (F.lit (Nothing :: Maybe Double))++-- | Arithmetic combinations (@+@, @-@, @*@, safe @/@) of two numeric exprs.+combineNumExprs :: NumExpr -> NumExpr -> [NumExpr]+combineNumExprs (NDouble e1) (NDouble e2) =+    map NDouble [e1 .+ e2, e1 .- e2, e1 .* e2, safeDivD e1 e2]+combineNumExprs (NDouble e1) (NMaybeDouble e2) =+    map+        NMaybeDouble+        [ e1 .+ e2+        , e1 .- e2+        , e1 .* e2+        , safeDivMaybe (F.fromMaybe False (e2 ./= F.lit (0 :: Double))) (e1 ./ e2)+        ]+combineNumExprs (NMaybeDouble e1) (NDouble e2) =+    map+        NMaybeDouble+        [ e1 .+ e2+        , e1 .- e2+        , e1 .* e2+        , safeDivMaybe (e2 ./= F.lit (0 :: Double)) (e1 ./ e2)+        ]+combineNumExprs (NMaybeDouble e1) (NMaybeDouble e2) =+    map+        NMaybeDouble+        [ e1 .+ e2+        , e1 .- e2+        , e1 .* e2+        , safeDivMaybe (F.fromMaybe False (e2 ./= F.lit (0 :: Double))) (e1 ./ e2)+        ]++numericConditions :: TreeConfig -> DataFrame -> [Expr Bool]+numericConditions = generateNumericConds++generateNumericConds :: TreeConfig -> DataFrame -> [Expr Bool]+generateNumericConds cfg df = do+    expr <- numericExprsWithTerms (synthConfig cfg) df+    threshold <- numericThresholds cfg df expr+    condsFromExpr expr threshold++numericThresholds :: TreeConfig -> DataFrame -> NumExpr -> [Double]+numericThresholds cfg df (NDouble e) = thresholdsForExpr cfg df e+numericThresholds cfg df (NMaybeDouble e) = thresholdsForExpr cfg df (F.fromMaybe 0 e)++thresholdsForExpr :: TreeConfig -> DataFrame -> Expr Double -> [Double]+thresholdsForExpr cfg df e =+    maybe [] (percentilesOf (percentiles cfg) . V.toList) (interpretDoubleCol df e)++condsFromExpr :: NumExpr -> Double -> [Expr Bool]+condsFromExpr (NDouble e) t = [e .<= F.lit t, e .>= F.lit t, e .< F.lit t, e .> F.lit t]+condsFromExpr (NMaybeDouble e) t =+    map+        (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.+-}+percentilesOf :: [Int] -> [Double] -> [Double]+percentilesOf ps valsList+    | n == 0 = []+    | otherwise = map (\p -> sortedV V.! min (n - 1) (max 0 (p * n `div` 100))) ps+  where+    !sortedV = V.fromList (sort valsList)+    !n = V.length sortedV++interpretDoubleCol :: DataFrame -> Expr Double -> Maybe (V.Vector Double)+interpretDoubleCol df e = case interpret @Double df e of+    Right (TColumn column) -> either (const Nothing) Just (toVector @Double column)+    _ -> Nothing++interpretMaybeDoubleCol ::+    DataFrame -> Expr (Maybe Double) -> Maybe (V.Vector (Maybe Double))+interpretMaybeDoubleCol df e = case interpret @(Maybe Double) df e of+    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.+-}+numericCondVecs :: TreeConfig -> DataFrame -> DataFrame -> [CondVec]+numericCondVecs cfg dfGen df = concatMap forExpr (numericExprsWithTerms (synthConfig cfg) dfGen)+  where+    forExpr (NDouble e) = maybe [] (condsForDouble cfg e) (interpretDoubleCol df e)+    forExpr (NMaybeDouble e) = maybe [] (condsForMaybe cfg e) (interpretMaybeDoubleCol df e)++condsForDouble :: TreeConfig -> Expr Double -> V.Vector Double -> [CondVec]+condsForDouble cfg e vals = concatMap (doubleCondsAt e vals (V.length vals)) ts+  where+    ts = percentilesOf (percentiles cfg) (V.toList vals)++doubleCondsAt :: Expr Double -> V.Vector Double -> Int -> Double -> [CondVec]+doubleCondsAt e vals n t =+    [ CondVec (e .<= F.lit t) (gen (<= t))+    , CondVec (e .>= F.lit t) (gen (>= t))+    , CondVec (e .< F.lit t) (gen (< t))+    , CondVec (e .> F.lit t) (gen (> t))+    ]+  where+    gen p = VU.generate n (\i -> p (vals V.! i))++condsForMaybe ::+    TreeConfig -> Expr (Maybe Double) -> V.Vector (Maybe Double) -> [CondVec]+condsForMaybe cfg e mvals = concatMap (maybeCondsAt e mvals (V.length mvals)) ts+  where+    ts = percentilesOf (percentiles cfg) (map (fromMaybe 0) (V.toList mvals))++maybeCondsAt ::+    Expr (Maybe Double) -> V.Vector (Maybe Double) -> Int -> Double -> [CondVec]+maybeCondsAt e mvals n t =+    [ CondVec (F.fromMaybe False (e .<= F.lit t)) (gen (<= t))+    , CondVec (F.fromMaybe False (e .>= F.lit t)) (gen (>= t))+    , CondVec (F.fromMaybe False (e .< F.lit t)) (gen (< t))+    , CondVec (F.fromMaybe False (e .> F.lit t)) (gen (> t))+    ]+  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.+-}+numericExprsWithTerms :: SynthConfig -> DataFrame -> [NumExpr]+numericExprsWithTerms cfg df+    | not (enableArithOps cfg) = base+    | otherwise =+        base ++ expandRounds cfg base (max 0 (maxExprDepth cfg - 1)) base seen0+  where+    base = numericCols df+    seen0 = Set.fromList (map keyNum base)++keyNum :: NumExpr -> String+keyNum (NDouble e) = show (normalize e)+keyNum (NMaybeDouble e) = show (normalize e)++isDisallowed :: SynthConfig -> NumExpr -> NumExpr -> Bool+isDisallowed cfg e1 e2 =+    any (\(l, r) -> l `elem` cols && r `elem` cols) (disallowedCombinations cfg)+  where+    cols = numExprCols e1 <> numExprCols e2++roundProducts :: SynthConfig -> [NumExpr] -> [NumExpr] -> [NumExpr]+roundProducts cfg frontier base =+    [ c+    | e1 <- frontier+    , e2 <- base+    , not (numExprEq e1 e2)+    , not (isDisallowed cfg e1 e2)+    , c <- combineNumExprs e1 e2+    ]++expandRounds ::+    SynthConfig -> [NumExpr] -> Int -> [NumExpr] -> Set.Set String -> [NumExpr]+expandRounds _ _ 0 _ _ = []+expandRounds cfg base d frontier seen+    | null admitted = []+    | otherwise = admitted ++ expandRounds cfg base (d - 1) admitted seen'+  where+    (admitted, seen') = admitNovel seen (roundProducts cfg frontier base)++admitNovel :: Set.Set String -> [NumExpr] -> ([NumExpr], Set.Set String)+admitNovel seen0 = go seen0 []+  where+    go seen acc [] = (reverse acc, seen)+    go seen acc (c : cs)+        | keyNum c `Set.member` seen = go seen acc cs+        | otherwise = go (Set.insert (keyNum c) seen) (c : acc) cs++numericCols :: DataFrame -> [NumExpr]+numericCols df = concatMap (numExprsOfColumn df) (columnNames df)++numExprsOfColumn :: DataFrame -> T.Text -> [NumExpr]+numExprsOfColumn df colName = case unsafeGetColumn colName df of+    UnboxedColumn Nothing (_ :: VU.Vector b) -> strictNumeric @b colName+    BoxedColumn (Just _) (_ :: V.Vector b) -> nullableNumeric @b colName+    UnboxedColumn (Just _) (_ :: VU.Vector b) -> nullableNumeric @b colName+    _ -> []++strictNumeric :: forall b. (Columnable b) => T.Text -> [NumExpr]+strictNumeric c = case testEquality (typeRep @b) (typeRep @Double) of+    Just Refl -> [NDouble (Col c)]+    Nothing -> case sIntegral @b of+        STrue -> [NDouble (F.toDouble (Col @b c))]+        SFalse -> []++nullableNumeric :: forall b. (Columnable b) => T.Text -> [NumExpr]+nullableNumeric c = case testEquality (typeRep @b) (typeRep @Double) of+    Just Refl -> [NMaybeDouble (Col @(Maybe b) c)]+    Nothing -> case sIntegral @b of+        STrue -> [NMaybeDouble (F.whenPresent (realToFrac @b @Double) (Col @(Maybe b) c))]+        SFalse -> []
+ src-internal/DataFrame/DecisionTree/Pool.hs view
@@ -0,0 +1,224 @@+{-# 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.+-}+module DataFrame.DecisionTree.Pool (+    evalWithPenaltyVec,+    primaryColExpr,+    primaryColCV,+    takeDiverse,+    candidateParChunk,+    bestDiscreteCandidate,+    boolExprsVec,+    DedupMode (..),+    saturateCandidates,+    roundProducts,+    admitKeys,+    admitVecs,+    dedupCVByExpr,+    nubByExpr,+) where++import DataFrame.DecisionTree.CondVec (+    CondVec (..),+    combineAndVec,+    combineOrVec,+    countErrorsByVec,+ )+import DataFrame.DecisionTree.Types (+    CarePoint,+    SynthConfig (..),+    TreeConfig (..),+ )+import DataFrame.Internal.Expression (+    Expr,+    compareExpr,+    eSize,+    eqExpr,+    getColumns,+    normalize,+ )++import Control.Parallel.Strategies (parListChunk, rdeepseq, using)+import Data.Function (on)+import Data.List (minimumBy, sortBy)+import qualified Data.Map.Strict as M+import qualified Data.Set as Set+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.+-}+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.+-}+primaryColExpr :: Expr Bool -> T.Text+primaryColExpr e = case getColumns e of+    [] -> "<noncol>"+    (c : _) -> c++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).+-}+takeDiverse :: Int -> Maybe Int -> (a -> T.Text) -> [a] -> [a]+takeDiverse k Nothing _ = take k+takeDiverse k (Just quota) primary = go M.empty 0+  where+    go !_ !_ [] = []+    go !seen !n (x : xs)+        | n >= k = []+        | M.findWithDefault 0 col seen >= quota = go seen n xs+        | otherwise = x : go (M.insertWith (+) col 1 seen) (n + 1) xs+      where+        !col = primary x++{- | Chunk size for the parallel per-node candidate scans; tuned by an -N+sweep, not correctness-affecting.+-}+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 :: (CondVec -> (Int, Int)) -> [CondVec] -> [((Int, Int), CondVec)]+decorate penaltyCV xs = zip (map penaltyCV xs `using` parListChunk candidateParChunk rdeepseq) xs++-- | The diverse top-@expressionPairs@ valid candidates by penalty.+sortedTopK :: TreeConfig -> (CondVec -> (Int, Int)) -> [CondVec] -> [CondVec]+sortedTopK cfg penaltyCV validCondVecs =+    map+        snd+        ( takeDiverse+            (expressionPairs cfg)+            (perColumnQuota (synthConfig cfg))+            (primaryColCV . snd)+            sorted+        )+  where+    sorted = sortBy (compare `on` fst) (decorate penaltyCV validCondVecs)++-- | Lowest-penalty candidate after boolean saturation of the diverse top-K.+bestDiscreteCandidate ::+    TreeConfig -> (CondVec -> (Int, Int)) -> [CondVec] -> Maybe CondVec+bestDiscreteCandidate _ _ [] = Nothing+bestDiscreteCandidate cfg penaltyCV validCondVecs =+    case saturateCandidates+        Structural+        (boolExpansion (synthConfig cfg))+        (sortedTopK cfg penaltyCV validCondVecs) of+        [] -> 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).+-}+boolExprsVec :: [CondVec] -> [CondVec] -> Int -> Int -> [CondVec]+boolExprsVec baseExprs prevExprs depth maxDepth+    | depth == 0 =+        baseExprs ++ boolExprsVec baseExprs prevExprs (depth + 1) maxDepth+    | depth >= maxDepth = []+    | otherwise = combined ++ boolExprsVec baseExprs combined (depth + 1) maxDepth+  where+    combined = roundProducts prevExprs baseExprs++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).+-}+saturateCandidates :: DedupMode -> Int -> [CondVec] -> [CondVec]+saturateCandidates Structural maxDepth base = base' ++ go 1 base' seen0+  where+    (base', seen0) = admitKeys Set.empty base+    go !depth frontier seen+        | depth >= maxDepth || null frontier = []+        | otherwise =+            let (admitted, seen') = admitKeys seen (roundProducts frontier base)+             in admitted ++ go (depth + 1) admitted seen'+saturateCandidates TruthVector maxDepth base = M.elems (go 1 frontier0 reps0)+  where+    (reps0, frontier0) = admitVecs M.empty base+    go !depth frontier reps+        | depth >= maxDepth || null frontier = reps+        | otherwise =+            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).+-}+roundProducts :: [CondVec] -> [CondVec] -> [CondVec]+roundProducts frontier base =+    [ c+    | e1 <- frontier+    , e2 <- base+    , not (eqExpr (cvExpr e1) (cvExpr e2))+    , c <- [combineAndVec e1 e2, combineOrVec e1 e2]+    ]++-- | Admit candidates with a not-yet-seen normalized form, preserving order.+admitKeys :: Set.Set String -> [CondVec] -> ([CondVec], Set.Set String)+admitKeys = go []+  where+    go acc seen [] = (reverse acc, seen)+    go acc !seen (c : cs)+        | structuralKey c `Set.member` seen = go acc seen cs+        | otherwise = go (c : acc) (Set.insert (structuralKey c) seen) cs++structuralKey :: CondVec -> String+structuralKey = show . normalize . cvExpr++{- | Admit candidates by distinct truth vector, keeping the smallest-expression+representative per vector.+-}+admitVecs ::+    M.Map (VU.Vector Bool) CondVec ->+    [CondVec] ->+    (M.Map (VU.Vector Bool) CondVec, [CondVec])+admitVecs = go []+  where+    go acc reps [] = (reps, reverse acc)+    go acc !reps (c : cs) = case M.lookup (cvVec c) reps of+        Nothing -> go (c : acc) (M.insert (cvVec c) c reps) cs+        Just r -> go acc (M.insert (cvVec c) (smaller r c) reps) cs++smaller :: CondVec -> CondVec -> CondVec+smaller a b = case compare (eSize (cvExpr a)) (eSize (cvExpr b)) of+    LT -> a+    GT -> b+    EQ -> if compareExpr (cvExpr a) (cvExpr b) /= GT then a else b++-- | Deduplicate 'CondVec's by normalized 'cvExpr', keeping the first.+dedupCVByExpr :: [CondVec] -> [CondVec]+dedupCVByExpr = go Set.empty+  where+    go _ [] = []+    go seen (cv : cvs)+        | structuralKey cv `Set.member` seen = go seen cvs+        | otherwise = cv : go (Set.insert (structuralKey cv) seen) cvs++-- | Deduplicate expressions by normalized form, keeping the first.+nubByExpr :: [Expr Bool] -> [Expr Bool]+nubByExpr = go Set.empty+  where+    go _ [] = []+    go seen (e : es)+        | k `Set.member` seen = go seen es+        | otherwise = e : go (Set.insert k seen) es+      where+        k = show (normalize e)
+ src-internal/DataFrame/DecisionTree/Predict.hs view
@@ -0,0 +1,216 @@+{-# LANGUAGE FlexibleContexts #-}+{-# 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.+-}+module DataFrame.DecisionTree.Predict (+    predictWithTree,+    predictManyWithTree,+    predictManyWithTreeCached,+    identifyCarePoints,+    identifyCarePointsCached,+    countCarePointErrors,+    partitionIndices,+    partitionIndicesCached,+    majorityValueFromIndices,+    computeTreeLoss,+    computeTreeLossCached,+    isValidAtNode,+) where++import DataFrame.DecisionTree.CondVec (+    CondCache,+    countErrorsByVec,+    lookupCondVec,+ )+import DataFrame.DecisionTree.Types (+    CarePoint (..),+    Direction (..),+    Tree (..),+    TreeConfig (..),+ )+import DataFrame.Internal.Column (Columnable, TypedColumn (..), toVector)+import DataFrame.Internal.DataFrame (DataFrame)+import DataFrame.Internal.Expression (Expr (..))+import DataFrame.Internal.Interpreter (interpret)++import Control.Exception (throw)+import Control.Monad.ST (ST)+import Data.Function (on)+import Data.List (maximumBy)+import qualified Data.Map.Strict as M+import qualified Data.Text as T+import qualified Data.Vector as V+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).+-}+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)+    _ -> Nothing++-- | The target column as a label vector, or 'Nothing' on failure.+interpretLabelCol ::+    forall a. (Columnable a) => DataFrame -> T.Text -> Maybe (V.Vector a)+interpretLabelCol df target = case interpret @a df (Col target) of+    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).+predictWithTree ::+    forall a. (Columnable a) => T.Text -> DataFrame -> Int -> Tree a -> a+predictWithTree _ _ _ (Leaf v) = v+predictWithTree target df idx (Branch cond left right) =+    predictWithTree @a target df idx (childFor cond left right idx df)++childFor :: Expr Bool -> Tree a -> Tree a -> Int -> DataFrame -> Tree a+childFor cond left right idx df = case branchBool df cond of+    Nothing -> left+    Just boolVals -> if boolVals VU.! idx then left else right++predictManyWithTree ::+    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.+-}+predictManyWithTreeCached ::+    forall a.+    (Columnable a) => CondCache -> Tree a -> DataFrame -> V.Vector Int -> V.Vector a+predictManyWithTreeCached cache tree df indices = V.create $ do+    mv <- VM.new (V.length indices)+    fill mv (V.zip (V.enumFromN 0 (V.length indices)) indices) tree+    pure mv+  where+    fill :: VM.MVector s a -> V.Vector (Int, Int) -> Tree a -> ST s ()+    fill mv prs (Leaf v) = V.mapM_ (\(p, _) -> VM.write mv p v) prs+    fill mv prs (Branch cond left right) = case lookupCondVec cache df cond of+        Nothing -> fill mv prs left+        Just boolVals -> fillSplit mv (V.partition (\(_, i) -> boolVals VU.! i) prs) left right++    fillSplit ::+        VM.MVector s a ->+        (V.Vector (Int, Int), V.Vector (Int, Int)) ->+        Tree a ->+        Tree a ->+        ST s ()+    fillSplit mv (leftPrs, rightPrs) left right = fill mv leftPrs left >> fill mv rightPrs right++identifyCarePoints ::+    forall a.+    (Columnable a) =>+    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.+-}+identifyCarePointsCached ::+    forall a.+    (Columnable a) =>+    CondCache ->+    T.Text ->+    DataFrame ->+    V.Vector Int ->+    Tree a ->+    Tree a ->+    [CarePoint]+identifyCarePointsCached cache target df indices leftTree rightTree =+    maybe [] carePoints (interpretLabelCol @a df target)+  where+    leftPreds = predictManyWithTreeCached cache leftTree df indices+    rightPreds = predictManyWithTreeCached cache rightTree df indices+    carePoints targetVals = V.toList (V.imapMaybe (checkPoint targetVals leftPreds rightPreds) indices)++checkPoint ::+    (Eq a) =>+    V.Vector a -> V.Vector a -> V.Vector a -> Int -> Int -> Maybe CarePoint+checkPoint targetVals leftPreds rightPreds k idx =+    case (leftPreds V.! k == trueLabel, rightPreds V.! k == trueLabel) of+        (True, False) -> Just (CarePoint idx GoLeft)+        (False, True) -> Just (CarePoint idx GoRight)+        _ -> Nothing+  where+    trueLabel = targetVals V.! idx++-- | Care points a free condition misroutes (uncached; for the linear path).+countCarePointErrors :: Expr Bool -> DataFrame -> [CarePoint] -> Int+countCarePointErrors cond df carePoints =+    maybe (length carePoints) (`countErrorsByVec` carePoints) (branchBool df cond)++partitionIndices ::+    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).+-}+partitionIndicesCached ::+    CondCache ->+    Expr Bool ->+    DataFrame ->+    V.Vector Int ->+    (V.Vector Int, V.Vector Int)+partitionIndicesCached cache cond df indices = case lookupCondVec cache df cond of+    Nothing -> (indices, V.empty)+    Just boolVals -> V.partition (boolVals VU.!) indices++-- | A split is valid at a node when both children keep at least 'minLeafSize'.+isValidAtNode :: TreeConfig -> DataFrame -> V.Vector Int -> Expr Bool -> Bool+isValidAtNode cfg df indices c =+    V.length t >= minLeafSize cfg && V.length f >= minLeafSize cfg+  where+    (t, f) = partitionIndices c df indices++majorityValueFromIndices ::+    forall a. (Columnable a, Ord a) => T.Text -> DataFrame -> V.Vector Int -> a+majorityValueFromIndices target df indices = majorityOf (countLabels (labelColOrThrow @a df target) indices)++labelColOrThrow :: forall a. (Columnable a) => DataFrame -> T.Text -> V.Vector a+labelColOrThrow df target = case interpret @a df (Col target) of+    Left e -> throw e+    Right (TColumn column) -> either throw id (toVector @a column)++countLabels :: (Ord a) => V.Vector a -> V.Vector Int -> M.Map a Int+countLabels vals = V.foldl' (\acc i -> M.insertWith (+) (vals V.! i) (1 :: Int) acc) M.empty++majorityOf :: M.Map a Int -> a+majorityOf counts+    | M.null counts = error "Empty indices in majorityValueFromIndices"+    | otherwise = fst (maximumBy (compare `on` snd) (M.toList counts))++computeTreeLoss ::+    forall a.+    (Columnable a) => T.Text -> DataFrame -> V.Vector Int -> Tree a -> Double+computeTreeLoss = computeTreeLossCached @a M.empty++-- | 0/1 loss of a tree over @indices@, with a 'CondCache' for the predictions.+computeTreeLossCached ::+    forall a.+    (Columnable a) =>+    CondCache -> T.Text -> DataFrame -> V.Vector Int -> Tree a -> Double+computeTreeLossCached cache target df indices tree+    | V.null indices = 0+    | otherwise =+        maybe 1.0 (treeLoss cache tree df indices) (interpretLabelCol @a df target)++treeLoss ::+    (Columnable a) =>+    CondCache -> Tree a -> DataFrame -> V.Vector Int -> V.Vector a -> Double+treeLoss cache tree df indices targetVals =+    fromIntegral (countMismatches targetVals indices preds)+        / fromIntegral (V.length indices)+  where+    preds = predictManyWithTreeCached cache tree df indices++countMismatches :: (Eq a) => V.Vector a -> V.Vector Int -> V.Vector a -> Int+countMismatches targetVals indices preds =+    V.length+        (V.ifilter (\k _ -> targetVals V.! (indices V.! k) /= preds V.! k) preds)
+ src-internal/DataFrame/DecisionTree/Prune.hs view
@@ -0,0 +1,66 @@+{-# LANGUAGE FlexibleContexts #-}+{-# 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.+-}+module DataFrame.DecisionTree.Prune (+    pruneDead,+    treeEq,+    pruneExpr,+) where++import DataFrame.DecisionTree.Types (Tree (..))+import DataFrame.Internal.Column (Columnable)+import DataFrame.Internal.Expression (Expr (..), eqExpr)+import DataFrame.Internal.Simplify (PredFact, entails, factFalse, 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.+-}+pruneDead :: forall a. (Columnable a) => Tree a -> Tree a+pruneDead = go []+  where+    go :: [PredFact] -> Tree a -> Tree a+    go _ (Leaf v) = Leaf v+    go facts (Branch cond left right) = case entails facts cond of+        Just True -> go facts left+        Just False -> go facts right+        Nothing ->+            reconcile+                cond+                (go (addFact (factTrue cond) facts) left)+                (go (addFact (factFalse cond) facts) right)++reconcile :: (Columnable a) => Expr Bool -> Tree a -> Tree a -> Tree a+reconcile cond left right+    | treeEq left right = left+    | otherwise = Branch cond left right++addFact :: Maybe PredFact -> [PredFact] -> [PredFact]+addFact (Just f) fs = f : fs+addFact Nothing fs = fs++treeEq :: (Columnable a) => Tree a -> Tree a -> Bool+treeEq (Leaf x) (Leaf y) = x == y+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.+-}+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)+pruneExpr (Binary op l r) = Binary op (pruneExpr l) (pruneExpr r)+pruneExpr e = e++collapseIf :: (Columnable a) => Expr Bool -> Expr a -> Expr a -> Expr a+collapseIf cond t f+    | eqExpr t f = t+    | If ci ti _ <- t, eqExpr cond ci = If cond ti f+    | If ci _ fi <- f, eqExpr cond ci = If cond t fi+    | otherwise = If cond t f
+ src-internal/DataFrame/DecisionTree/Tao.hs view
@@ -0,0 +1,266 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE FlexibleContexts #-}+{-# 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.+-}+module DataFrame.DecisionTree.Tao (+    taoOptimize,+    taoOptimizeCV,+    taoIteration,+    taoIterationCV,+    optimizeNode,+    findBestSplitTAO,+) where++import DataFrame.DecisionTree.CondVec+import DataFrame.DecisionTree.Linear (bestLinearCandidate)+import DataFrame.DecisionTree.Pool (+    bestDiscreteCandidate,+    candidateParChunk,+    evalWithPenaltyVec,+ )+import DataFrame.DecisionTree.Predict+import DataFrame.DecisionTree.Prune (pruneDead)+import DataFrame.DecisionTree.Types+import DataFrame.Internal.Column (Columnable)+import DataFrame.Internal.DataFrame (DataFrame)+import DataFrame.Internal.Expression (Expr)++import Control.Parallel (par, pseq)+import Control.Parallel.Strategies (parListChunk, rdeepseq, using)+import Data.Function (on)+import Data.List (foldl', 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).+-}+data TaoEnv = TaoEnv+    { teCache :: !CondCache+    , teCfg :: !TreeConfig+    , teTarget :: !T.Text+    , teConds :: ![CondVec]+    , teDf :: !DataFrame+    }++-- | Public TAO entry point over raw conditions; materializes each once.+taoOptimize ::+    forall a.+    (Columnable a, Ord a) =>+    TreeConfig ->+    T.Text ->+    [Expr Bool] ->+    DataFrame ->+    V.Vector Int ->+    Tree a ->+    Tree a+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.+-}+taoOptimizeCV ::+    forall a.+    (Columnable a, Ord a) =>+    TreeConfig ->+    T.Text ->+    [CondVec] ->+    DataFrame ->+    V.Vector Int ->+    Tree a ->+    Tree a+taoOptimizeCV cfg target condVecs df rootIndices initialTree =+    go 0 initialTree (lossWith baseCache initialTree)+  where+    baseCache = condCacheFromVecs condVecs+    lossWith cache = computeTreeLossCached @a cache target df rootIndices+    go iter tree prevLoss+        | iter >= taoIterations cfg = pruneDead tree+        | prevLoss - newLoss < taoConvergenceTol cfg = pruneDead tree'+        | otherwise = go (iter + 1) tree' newLoss+      where+        cache = addTreeCondsToCache df tree baseCache+        tree' = taoIterationCV @a cache cfg target condVecs df rootIndices tree+        newLoss = lossWith cache tree'++-- | Public single-iteration entry point.+taoIteration ::+    forall a.+    (Columnable a, Ord a) =>+    TreeConfig ->+    T.Text ->+    [Expr Bool] ->+    DataFrame ->+    V.Vector Int ->+    Tree a ->+    Tree a+taoIteration cfg target conds df rootIndices tree =+    let condVecs = mapMaybe (materializeCondVec df) conds+        cache = addTreeCondsToCache df tree (condCacheFromVecs condVecs)+     in taoIterationCV @a cache cfg target condVecs df rootIndices tree++-- | One bottom-to-top sweep: re-optimize every node level by level.+taoIterationCV ::+    forall a.+    (Columnable a, Ord a) =>+    CondCache ->+    TreeConfig ->+    T.Text ->+    [CondVec] ->+    DataFrame ->+    V.Vector Int ->+    Tree a ->+    Tree a+taoIterationCV cache cfg target condVecs df rootIndices tree =+    foldl'+        (optimizeDepthLevel env rootIndices)+        tree+        [treeDepth tree, treeDepth tree - 1 .. 0]+  where+    env = TaoEnv cache cfg target condVecs df++optimizeDepthLevel ::+    forall a.+    (Columnable a, Ord a) => TaoEnv -> V.Vector Int -> Tree a -> Int -> Tree a+optimizeDepthLevel env rootIndices tree = optimizeAtDepth @a env rootIndices tree 0++optimizeAtDepth ::+    forall a.+    (Columnable a, Ord a) =>+    TaoEnv -> V.Vector Int -> Tree a -> Int -> Int -> Tree a+optimizeAtDepth env indices tree currentDepth targetDepth+    | currentDepth == targetDepth = optimizeNode @a env indices tree+    | otherwise = case tree of+        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).+-}+optimizeChildren ::+    forall a.+    (Columnable a, Ord a) =>+    TaoEnv -> V.Vector Int -> Expr Bool -> Tree a -> Tree a -> Int -> Int -> Tree a+optimizeChildren env indices cond left right currentDepth targetDepth =+    forceTreeWork left' `par` (forceTreeWork right' `pseq` Branch cond left' right')+  where+    (indicesL, indicesR) = partitionIndicesCached (teCache env) cond (teDf env) indices+    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.+-}+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'.+-}+optimizeNode ::+    forall a. (Columnable a, Ord a) => TaoEnv -> V.Vector Int -> Tree a -> Tree a+optimizeNode env indices tree+    | V.null indices = tree+    | otherwise = case tree of+        Leaf _ -> leaf+        Branch oldCond left right -> rebuiltBranch env indices oldCond left right leaf+  where+    leaf = Leaf (majorityValueFromIndices @a (teTarget env) (teDf env) indices)++rebuiltBranch ::+    forall a.+    (Columnable a, Ord a) =>+    TaoEnv -> V.Vector Int -> Expr Bool -> Tree a -> Tree a -> Tree a -> Tree a+rebuiltBranch env indices oldCond left right leaf+    | underflows = leaf+    | otherwise = Branch newCond left right+  where+    newCond = findBestSplitTAO @a env indices left right oldCond+    (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.+-}+findBestSplitTAO ::+    forall a.+    (Columnable a) =>+    TaoEnv -> V.Vector Int -> Tree a -> Tree a -> Expr Bool -> Expr Bool+findBestSplitTAO env indices leftTree rightTree currentCond+    | V.null indices || null carePoints = currentCond+    | pureReplacementLinear cfg+    , Just c <- linearCandidate+    , isValidAtNode cfg (teDf env) indices c =+        c+    | otherwise = bestOfPool penaltyCV currentCond pool+  where+    cfg = teCfg env+    carePoints =+        identifyCarePointsCached @a+            (teCache env)+            (teTarget env)+            (teDf env)+            indices+            leftTree+            rightTree+    penaltyCV = evalWithPenaltyVec cfg carePoints+    linearCandidate = bestLinearCandidate cfg (teDf env) carePoints+    valid = filterValidCandidates cfg indices (teConds env)+    pool =+        candidatePool+            env+            indices+            currentCond+            (bestDiscreteCandidate cfg penaltyCV valid)+            linearCandidate++bestOfPool :: (CondVec -> (Int, Int)) -> Expr Bool -> [CondVec] -> Expr Bool+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.+-}+filterValidCandidates :: TreeConfig -> V.Vector Int -> [CondVec] -> [CondVec]+filterValidCandidates cfg indices condVecs = map snd (filter fst (zip validity condVecs))+  where+    validity =+        map (validAtNode cfg indices) condVecs+            `using` parListChunk candidateParChunk rdeepseq++validAtNode :: TreeConfig -> V.Vector Int -> CondVec -> Bool+validAtNode cfg indices cv = nTrue >= minLeaf && (V.length indices - nTrue) >= minLeaf+  where+    minLeaf = minLeafSize cfg+    nTrue =+        V.foldl'+            (\ !acc i -> if cvVec cv VU.! i then acc + 1 else acc)+            (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.+-}+candidatePool ::+    TaoEnv ->+    V.Vector Int ->+    Expr Bool ->+    Maybe CondVec ->+    Maybe (Expr Bool) ->+    [CondVec]+candidatePool env indices currentCond discreteCV linearCandidate =+    filter+        (isValidAtNode (teCfg env) (teDf env) indices . cvExpr)+        (catMaybes [currentCV, discreteCV, linearCV])+  where+    currentCV = CondVec currentCond <$> lookupCondVec (teCache env) (teDf env) currentCond+    linearCV = linearCandidate >>= materializeCondVec (teDf env)
+ src-internal/DataFrame/DecisionTree/Types.hs view
@@ -0,0 +1,190 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE GADTs #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}++{- | Shared types, configuration and ordering machinery for the decision-tree+learner. Imported by every other @DataFrame.DecisionTree.*@ module.+-}+module DataFrame.DecisionTree.Types (+    Tree (..),+    treeDepth,+    TreeConfig (..),+    SynthConfig (..),+    defaultTreeConfig,+    defaultSynthConfig,+    ColumnOrdering (..),+    orderable,+    defaultColumnOrdering,+    withOrdFrom,+    CarePoint (..),+    Direction (..),+    ttrace,+) where++import DataFrame.Internal.Column (Columnable)+import DataFrame.Internal.Expression (Expr (..))+import qualified DataFrame.LinearSolver as LS++import Data.Int (Int16, Int32, Int64, Int8)+import qualified Data.Map.Strict as M+import Data.Proxy (Proxy (..))+import qualified Data.Text as T+import Data.Type.Equality (testEquality, (:~:) (..))+import Data.Word (Word16, Word32, Word64, Word8)+import qualified Debug.Trace as Trace+import System.Environment (lookupEnv)+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.+-}+data Tree a+    = Leaf !a+    | Branch !(Expr Bool) !(Tree a) !(Tree a)+    deriving (Show)++treeDepth :: Tree a -> Int+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).+-}+data CarePoint = CarePoint+    { cpIndex :: !Int+    , cpCorrectDir :: !Direction+    }+    deriving (Eq, Show)++data Direction = GoLeft | GoRight+    deriving (Eq, Show)++data TreeConfig = TreeConfig+    { maxTreeDepth :: Int+    , minSamplesSplit :: Int+    , minLeafSize :: Int+    , percentiles :: [Int]+    , expressionPairs :: Int+    , synthConfig :: SynthConfig+    , taoIterations :: Int+    , taoConvergenceTol :: Double+    , columnOrdering :: ColumnOrdering+    , useLinearSolver :: Bool+    , linearSolverConfig :: LS.SolverConfig+    , minCarePointsForLinear :: Int+    , pureReplacementLinear :: Bool+    }++data SynthConfig = SynthConfig+    { maxExprDepth :: Int+    , boolExpansion :: Int+    , disallowedCombinations :: [(T.Text, T.Text)]+    , complexityPenalty :: Double+    , enableStringOps :: Bool+    , enableCrossCols :: Bool+    , enableArithOps :: Bool+    , maxCategoricalSubsetCardinality :: Int+    , perColumnQuota :: Maybe Int+    }+    deriving (Eq, Show)++defaultSynthConfig :: SynthConfig+defaultSynthConfig =+    SynthConfig+        { maxExprDepth = 2+        , boolExpansion = 2+        , disallowedCombinations = []+        , complexityPenalty = 0.05+        , enableStringOps = True+        , enableCrossCols = True+        , enableArithOps = True+        , maxCategoricalSubsetCardinality = 4+        , perColumnQuota = Just 3+        }++defaultTreeConfig :: TreeConfig+defaultTreeConfig =+    TreeConfig+        { maxTreeDepth = 4+        , minSamplesSplit = 5+        , minLeafSize = 1+        , percentiles = [0, 10 .. 100]+        , expressionPairs = 10+        , synthConfig = defaultSynthConfig+        , taoIterations = 10+        , taoConvergenceTol = 1e-6+        , columnOrdering = defaultColumnOrdering+        , useLinearSolver = True+        , linearSolverConfig = LS.defaultSolverConfig+        , minCarePointsForLinear = 10+        , pureReplacementLinear = False+        }++{- | Which column types support ordering for splits. Register a type with+'orderable' and combine with @<>@.+-}+newtype ColumnOrdering = ColumnOrdering (M.Map SomeTypeRep OrdDict)++instance Semigroup ColumnOrdering where+    ColumnOrdering a <> ColumnOrdering b = ColumnOrdering (a <> b)++instance Monoid ColumnOrdering where+    mempty = ColumnOrdering M.empty++-- | Register a type as orderable for decision-tree splits.+orderable :: forall a. (Columnable a, Ord a) => ColumnOrdering+orderable = ColumnOrdering (M.singleton (SomeTypeRep (typeRep @a)) (OrdDict (Proxy @a)))++-- | All standard numeric, text, and primitive types.+defaultColumnOrdering :: ColumnOrdering+defaultColumnOrdering = mconcat (numericOrderings ++ otherOrderings)++numericOrderings :: [ColumnOrdering]+numericOrderings =+    [ orderable @Int+    , orderable @Int8+    , orderable @Int16+    , orderable @Int32+    , orderable @Int64+    , orderable @Word+    , orderable @Word8+    , orderable @Word16+    , orderable @Word32+    , orderable @Word64+    , orderable @Integer+    , orderable @Double+    , orderable @Float+    ]++otherOrderings :: [ColumnOrdering]+otherOrderings =+    [orderable @Bool, orderable @Char, orderable @T.Text, orderable @String]++-- | Existential @Ord@ dictionary keyed by type representation.+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.+-}+withOrdFrom ::+    forall a r. (Columnable a) => ColumnOrdering -> ((Ord a) => r) -> Maybe r+withOrdFrom (ColumnOrdering m) k = case M.lookup (SomeTypeRep (typeRep @a)) m of+    Just (OrdDict (_ :: Proxy b)) -> case testEquality (typeRep @a) (typeRep @b) of+        Just Refl -> Just k+        Nothing -> Nothing+    Nothing -> Nothing++{-# NOINLINE taoTraceEnabled #-}+taoTraceEnabled :: Bool+taoTraceEnabled = unsafePerformIO (fmap (== Just "1") (lookupEnv "TAO_TRACE"))++-- | Emit a trace line when @TAO_TRACE=1@; a no-op otherwise.+ttrace :: String -> a -> a+ttrace msg x+    | taoTraceEnabled = Trace.trace ("[TAO] " ++ msg) x+    | otherwise = x
+ src-internal/DataFrame/Featurize/Internal.hs view
@@ -0,0 +1,206 @@+{-# LANGUAGE FlexibleContexts #-}+{-# 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).+-}+module DataFrame.Featurize.Internal (+    -- * Supervised extraction+    featureNames,+    numericMatrix,+    targetDoubles,+    targetValues,++    -- * Unsupervised extraction+    Features (..),+    extractFeatures,+    columnExprName,+    materializeColumn,++    -- * Expression builders+    affineExpr,+    argMaxExpr,+    argMinExpr,+) where++import Control.Exception (throw)+import Data.Maybe (isJust)+import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import Type.Reflection (TypeRep, typeRep)++import Data.Either (fromRight)+import DataFrame.Errors (DataFrameException (..), TypeErrorContext (..))+import qualified DataFrame.Functions as F+import DataFrame.Internal.Column (Columnable, columnBitmap, columnTypeString)+import qualified DataFrame.Internal.Column as D+import DataFrame.Internal.DataFrame (DataFrame, columnNames, getColumn)+import DataFrame.Internal.Expression (Expr (..))+import DataFrame.LinearAlgebra (Matrix, transposeM)+import DataFrame.Operations.Core (+    columnAsDoubleVector,+    columnAsUnboxedVector,+    columnAsVector,+ )+import DataFrame.Operators ((.&&.), (.*.), (.+.), (.<=.), (.>=.))++-- | Every column name except the supervised target's.+featureNames :: Expr a -> DataFrame -> [T.Text]+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.+-}+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.+-}+requireDoubleColumn :: T.Text -> DataFrame -> VU.Vector Double+requireDoubleColumn name df = case getColumn name df of+    Nothing ->+        throw+            ( TypeMismatchException+                ( MkTypeErrorContext+                    (Right (typeRep @Double))+                    (Left "missing" :: Either String (TypeRep Double))+                    (Just (T.unpack name))+                    Nothing+                )+            )+    Just c ->+        if isJust (columnBitmap c)+            then throw (nullableInputError name (columnTypeString c))+            else+                if D.hasElemType @Double c+                    then fromRight undefined (columnAsUnboxedVector (F.col @Double name) df)+                    else+                        throw+                            ( asDoubleInputError+                                name+                                ( TypeMismatchException+                                    ( MkTypeErrorContext+                                        (Right (typeRep @Double))+                                        (Left (columnTypeString c) :: Either String (TypeRep Double))+                                        (Just (T.unpack name))+                                        Nothing+                                    )+                                )+                            )++-- | Actionable error for a non-'Double' input column (wraps the type mismatch).+asDoubleInputError :: T.Text -> DataFrameException -> DataFrameException+asDoubleInputError name (TypeMismatchException ctx) =+    TypeMismatchException+        ctx+            { errorColumnName = Just (T.unpack name)+            , callingFunctionName =+                Just+                    "model fit (input columns must be Double; convert with toDouble or build the column as Double)"+            }+asDoubleInputError _ e = e++-- | Actionable error for a nullable ('Maybe Double') input column.+nullableInputError :: T.Text -> String -> DataFrameException+nullableInputError name found =+    TypeMismatchException+        ( MkTypeErrorContext+            { userType = Right (typeRep @Double)+            , expectedType = Left found+            , errorColumnName = Just (T.unpack name)+            , callingFunctionName =+                Just+                    "model fit (input columns must be non-null Double; drop or impute missing values before fitting)"+            } ::+            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.+-}+targetDoubles :: Expr Double -> DataFrame -> VU.Vector Double+targetDoubles (Col name) df = requireDoubleColumn name df+targetDoubles expr df = case columnAsUnboxedVector expr df of+    Right v -> v+    Left e -> throw (asDoubleInputError (T.pack "<target>") e)++-- | The target column as a vector of its own type (for classifiers).+targetValues :: (Columnable a) => Expr a -> DataFrame -> V.Vector a+targetValues expr df = case columnAsVector expr df of+    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.+-}+data Features = Features+    { ftNames :: ![T.Text]+    , ftCols :: ![VU.Vector Double]+    , ftRows :: !Matrix+    , ftN :: !Int+    , ftD :: !Int+    }++-- | Extract the given feature columns once, in every shape the fitters use.+extractFeatures :: [Expr Double] -> DataFrame -> Features+extractFeatures features df = Features names cols rows n d+  where+    names = map columnExprName features+    cols = map (materializeColumn df) features+    n = case cols of+        (x : _) -> VU.length x+        _ -> 0+    d = length cols+    rows = V.generate n (\i -> VU.generate d (\j -> (cols !! j) VU.! i))++-- | The column name behind a @Col@ feature expression.+columnExprName :: Expr Double -> T.Text+columnExprName (Col n) = n+columnExprName e = error ("expected a column expression, got " ++ show e)++-- | Interpret a @Col@ (or numeric) expression to a @Double@ vector.+materializeColumn :: DataFrame -> Expr Double -> VU.Vector Double+materializeColumn df e = case columnAsDoubleVector e df of+    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).+-}+affineExpr :: Double -> [(Double, T.Text)] -> Expr Double+affineExpr b terms =+    foldr+        (.+.)+        (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.+-}+argMaxExpr :: (Columnable a) => [(a, Expr Double)] -> Expr a+argMaxExpr = argExtreme (.>=.)++-- | The class whose score is smallest (e.g. nearest cluster by distance).+argMinExpr :: (Columnable a) => [(a, Expr Double)] -> Expr a+argMinExpr = argExtreme (.<=.)++argExtreme ::+    (Columnable a) =>+    (Expr Double -> Expr Double -> Expr Bool) -> [(a, Expr Double)] -> Expr a+argExtreme _ [] = error "argExtreme: no classes"+argExtreme _ [(c, _)] = Lit c+argExtreme cmp ((c, sc) : rest) =+    If+        (foldr ((\o acc -> cmp sc o .&&. acc) . snd) (F.lit True) rest)+        (Lit c)+        (argExtreme cmp rest)
+ src-internal/DataFrame/LinearAlgebra.hs view
@@ -0,0 +1,121 @@+{-# 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".+-}+module DataFrame.LinearAlgebra (+    Matrix,+    dot,+    axpy,+    scaleV,+    matVec,+    tMatVec,+    gram,+    transposeM,+    identityM,+    logSumExp,+    sqDist,+    nearestCenter,+    epsNeighbors,+) where++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@.+-}+type Matrix = V.Vector (VU.Vector Double)++-- | Inner product of two equal-length vectors.+dot :: VU.Vector Double -> VU.Vector Double -> Double+dot a b = VU.sum (VU.zipWith (*) a b)+{-# INLINE dot #-}++-- | @axpy a x y = a*x + y@.+axpy :: Double -> VU.Vector Double -> VU.Vector Double -> VU.Vector Double+axpy a = VU.zipWith (\xi yi -> a * xi + yi)+{-# INLINE axpy #-}++-- | Scalar-vector product.+scaleV :: Double -> VU.Vector Double -> VU.Vector Double+scaleV a = VU.map (* a)+{-# INLINE scaleV #-}++-- | @matVec A v@ for @A@ of shape @n×d@ and @v@ of length @d@; result length @n@.+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@.+tMatVec :: Matrix -> VU.Vector Double -> VU.Vector Double+tMatVec a v+    | V.null a = VU.empty+    | otherwise = V.foldl' step (VU.replicate d 0) (V.zipWith (,) vBoxed a)+  where+    d = VU.length (V.head a)+    vBoxed = V.generate (V.length a) (v VU.!)+    step !acc (vi, row) = axpy vi row acc++-- | @gram A = Aᵀ A@, the @d×d@ symmetric matrix of column inner products.+gram :: Matrix -> Matrix+gram a+    | V.null a = V.empty+    | otherwise =+        V.generate d $ \i ->+            VU.generate d $ \j ->+                V.foldl' (\ !acc row -> acc + (row VU.! i) * (row VU.! j)) 0 a+  where+    d = VU.length (V.head a)++-- | Transpose an @n×d@ matrix to @d×n@.+transposeM :: Matrix -> Matrix+transposeM a+    | V.null a = V.empty+    | otherwise = V.generate d $ \j -> VU.generate n $ \i -> (a V.! i) VU.! j+  where+    n = V.length a+    d = VU.length (V.head a)++-- | @d×d@ identity matrix.+identityM :: Int -> Matrix+identityM d = V.generate d $ \i -> VU.generate d $ \j -> if i == j then 1 else 0++-- | Numerically stable @log Σ exp xᵢ@.+logSumExp :: VU.Vector Double -> Double+logSumExp xs+    | VU.null xs = negate (1 / 0)+    | otherwise = m + log (VU.sum (VU.map (\x -> exp (x - m)) xs))+  where+    m = VU.maximum xs++-- | Squared Euclidean distance.+sqDist :: VU.Vector Double -> VU.Vector Double -> Double+sqDist a b = VU.sum (VU.zipWith (\x y -> let z = x - y in z * z) a b)+{-# INLINE sqDist #-}++-- | Index of and squared distance to the nearest centre.+nearestCenter ::+    V.Vector (VU.Vector Double) -> VU.Vector Double -> (Int, Double)+nearestCenter centers p =+    V.ifoldl'+        ( \(!bi, !bd) i c ->+            let dd = sqDist c p in if dd < bd then (i, dd) else (bi, bd)+        )+        (-1, 1 / 0)+        centers++{- | Indices @j@ (excluding @i@) within squared radius @eps²@ of row @i@, by+brute force; @O(n d)@ per query.+-}+epsNeighbors :: Double -> Matrix -> Int -> VU.Vector Int+epsNeighbors eps rows i =+    VU.fromList+        [ j+        | j <- [0 .. n - 1]+        , j /= i+        , sqDist (rows V.! i) (rows V.! j) <= eps2+        ]+  where+    n = V.length rows+    eps2 = eps * eps
+ src-internal/DataFrame/LinearAlgebra/Eigen.hs view
@@ -0,0 +1,120 @@+{-# 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.+-}+module DataFrame.LinearAlgebra.Eigen (+    jacobiEigenSym,+    powerIterTop,+) where++import Control.Monad (forM_, when)+import Control.Monad.ST (runST)+import Data.List (sortBy)+import Data.Ord (Down (..), comparing)+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+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.+-}+jacobiEigenSym :: Matrix -> (VU.Vector Double, Matrix)+jacobiEigenSym a0+    | V.null a0 = (VU.empty, V.empty)+    | otherwise = runST $ do+        a <- VUM.new (d * d)+        forM_ [0 .. d - 1] $ \i ->+            forM_ [0 .. d - 1] $ \j ->+                VUM.write a (i * d + j) ((a0 V.! i) VU.! j)+        v <- VUM.replicate (d * d) 0+        forM_ [0 .. d - 1] $ \i -> VUM.write v (i * d + i) 1+        sweep a v 0+        afrozen <- VU.freeze a+        vmat <- VU.freeze v+        let diag = VU.generate d (\i -> afrozen VU.! (i * d + i))+            vecs =+                V.generate d $ \col ->+                    VU.generate d $ \row -> vmat VU.! (row * d + col)+            paired =+                sortBy+                    (comparing (Down . fst))+                    (zip (VU.toList diag) (V.toList vecs))+        pure+            ( VU.fromList (map fst paired)+            , V.fromList (map (canonicalSign . snd) paired)+            )+  where+    d = V.length a0+    maxSweeps = 100+    tol = 1e-12+    sweep a v s+        | s >= maxSweeps = pure ()+        | otherwise = do+            off <- offNorm a+            when (off >= tol) $ do+                forM_ [0 .. d - 2] $ \p ->+                    forM_ [p + 1 .. d - 1] $ \q -> rotate a v p q+                sweep a v (s + 1)+    offNorm a = go 0 0+      where+        go i !acc+            | i >= d = pure acc+            | otherwise = do+                r <- goRow i (i + 1) acc+                go (i + 1) r+        goRow i j !acc+            | j >= d = pure acc+            | otherwise = do+                x <- VUM.read a (i * d + j)+                goRow i (j + 1) (acc + x * x)+    rotate a v p q = do+        apq <- VUM.read a (p * d + q)+        when (abs apq > 1e-300) $ do+            app <- VUM.read a (p * d + p)+            aqq <- VUM.read a (q * d + q)+            let theta = (aqq - app) / (2 * apq)+                s' = if theta == 0 then 1 else signum theta+                t = s' / (abs theta + sqrt (theta * theta + 1))+                c = 1 / sqrt (t * t + 1)+                sn = t * c+            forM_ [0 .. d - 1] $ \i -> do+                aip <- VUM.read a (i * d + p)+                aiq <- VUM.read a (i * d + q)+                VUM.write a (i * d + p) (c * aip - sn * aiq)+                VUM.write a (i * d + q) (sn * aip + c * aiq)+            forM_ [0 .. d - 1] $ \j -> do+                apj <- VUM.read a (p * d + j)+                aqj <- VUM.read a (q * d + j)+                VUM.write a (p * d + j) (c * apj - sn * aqj)+                VUM.write a (q * d + j) (sn * apj + c * aqj)+            forM_ [0 .. d - 1] $ \i -> do+                vip <- VUM.read v (i * d + p)+                viq <- VUM.read v (i * d + q)+                VUM.write v (i * d + p) (c * vip - sn * viq)+                VUM.write v (i * d + q) (sn * vip + c * viq)++canonicalSign :: VU.Vector Double -> VU.Vector Double+canonicalSign vec =+    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.+-}+powerIterTop :: Int -> Matrix -> (Double, VU.Vector Double)+powerIterTop iters a+    | V.null a = (0, VU.empty)+    | otherwise = go iters (normalize (VU.replicate d 1))+  where+    d = V.length a+    normalize v =+        let nrm = sqrt (dot v v) in if nrm == 0 then v else scaleV (1 / nrm) v+    go 0 v = (dot v (matVec a v), v)+    go k v =+        let av = matVec a v+            nrm = sqrt (dot av av)+         in if nrm < 1e-300 then (0, v) else go (k - 1) (scaleV (1 / nrm) av)
+ src-internal/DataFrame/LinearAlgebra/Solve.hs view
@@ -0,0 +1,213 @@+{-# 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.+-}+module DataFrame.LinearAlgebra.Solve (+    qrLeastSquares,+    cholesky,+    choleskySolve,+    logDetFromChol,+    forwardSubst,+    backSubst,+) where++import Control.Monad (forM_)+import Control.Monad.ST (ST, runST)+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+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.+-}+qrLeastSquares :: Matrix -> VU.Vector Double -> Either [Int] (VU.Vector Double)+qrLeastSquares a b+    | V.null a = Right VU.empty+    | n < d = Left [0 .. d - 1]+    | otherwise = runST $ do+        mat <- VUM.new (n * d)+        forM_ [0 .. n - 1] $ \i ->+            forM_ [0 .. d - 1] $ \j ->+                VUM.write mat (j * n + i) ((a V.! i) VU.! j)+        rhs <- VUM.new n+        forM_ [0 .. n - 1] $ \i -> VUM.write rhs i (b VU.! i)+        deficient <- householder mat rhs n d+        if not (null deficient)+            then pure (Left deficient)+            else do+                x <- VUM.new d+                backSubstQR mat rhs n d x+                Right <$> VU.freeze x+  where+    n = V.length a+    d = VU.length (V.head a)++householder ::+    VUM.STVector s Double -> VUM.STVector s Double -> Int -> Int -> ST s [Int]+householder mat rhs n d = go 0 []+  where+    tol = 1e-10+    go k acc+        | k >= d = pure (reverse acc)+        | otherwise = do+            normSq <- sumSq k+            let alphaMag = sqrt normSq+            akk <- VUM.read mat (k * n + k)+            let alpha = if akk > 0 then negate alphaMag else alphaMag+            if alphaMag < tol+                then go (k + 1) (k : acc)+                else do+                    VUM.write mat (k * n + k) (akk - alpha)+                    vNormSq <- sumSq k+                    if vNormSq < tol * tol+                        then go (k + 1) acc+                        else do+                            forM_ [k + 1 .. d - 1] $ \j -> reflectColumn k j vNormSq+                            reflectRhs k vNormSq+                            VUM.write mat (k * n + k) alpha+                            go (k + 1) acc+    sumSq k = foldRows k+      where+        foldRows i+            | i >= n = pure 0+            | otherwise = do+                x <- VUM.read mat (k * n + i)+                rest <- foldRows (i + 1)+                pure (x * x + rest)+    reflectColumn k j vNormSq = do+        dotv <- dotV k j k+        let beta = 2 * dotv / vNormSq+        forM_ [k .. n - 1] $ \i -> do+            vi <- VUM.read mat (k * n + i)+            aij <- VUM.read mat (j * n + i)+            VUM.write mat (j * n + i) (aij - beta * vi)+    reflectRhs k vNormSq = do+        dotv <- dotRhs k k+        let beta = 2 * dotv / vNormSq+        forM_ [k .. n - 1] $ \i -> do+            vi <- VUM.read mat (k * n + i)+            bi <- VUM.read rhs i+            VUM.write rhs i (bi - beta * vi)+    dotV k j i+        | i >= n = pure 0+        | otherwise = do+            vi <- VUM.read mat (k * n + i)+            aij <- VUM.read mat (j * n + i)+            rest <- dotV k j (i + 1)+            pure (vi * aij + rest)+    dotRhs k i+        | i >= n = pure 0+        | otherwise = do+            vi <- VUM.read mat (k * n + i)+            bi <- VUM.read rhs i+            rest <- dotRhs k (i + 1)+            pure (vi * bi + rest)++backSubstQR ::+    VUM.STVector s Double ->+    VUM.STVector s Double ->+    Int ->+    Int ->+    VUM.STVector s Double ->+    ST s ()+backSubstQR mat rhs n d x = forM_ [d - 1, d - 2 .. 0] $ \i -> do+    bi <- VUM.read rhs i+    s <- sumAbove i (i + 1) 0+    rii <- VUM.read mat (i * n + i)+    VUM.write x i ((bi - s) / rii)+  where+    sumAbove i j !acc+        | j >= d = pure acc+        | otherwise = do+            rij <- VUM.read mat (j * n + i)+            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 :: Matrix -> Maybe Matrix+cholesky a+    | V.null a = Just V.empty+    | otherwise = runST $ do+        l <- VUM.replicate (d * d) 0+        ok <- buildL l+        if ok then Just <$> freezeLower l else pure Nothing+  where+    d = V.length a+    buildL l = go 0+      where+        go j+            | j >= d = pure True+            | otherwise = do+                s <- sumLk l j j (j - 1) 0+                let ajj = (a V.! j) VU.! j+                    diag = ajj - s+                if diag <= 0+                    then pure False+                    else do+                        let ljj = sqrt diag+                        VUM.write l (j * d + j) ljj+                        forM_ [j + 1 .. d - 1] $ \i -> do+                            sij <- sumLk l i j (j - 1) 0+                            let aij = (a V.! i) VU.! j+                            VUM.write l (i * d + j) ((aij - sij) / ljj)+                        go (j + 1)+    sumLk l i j k !acc+        | k < 0 = pure acc+        | otherwise = do+            lik <- VUM.read l (i * d + k)+            ljk <- VUM.read l (j * d + k)+            sumLk l i j (k - 1) (acc + lik * ljk)+    freezeLower l = do+        frozen <- VU.freeze l+        pure $ V.generate d $ \i -> VU.slice (i * d) d frozen++-- | Solve @L y = b@ for lower-triangular @L@.+forwardSubst :: Matrix -> VU.Vector Double -> VU.Vector Double+forwardSubst l b = runST $ do+    y <- VUM.new d+    forM_ [0 .. d - 1] $ \i -> do+        let row = l V.! i+        s <- sumKnown y row i 0 0+        VUM.write y i ((b VU.! i - s) / (row VU.! i))+    VU.freeze y+  where+    d = V.length l+    sumKnown y row i j !acc+        | j >= i = pure acc+        | otherwise = do+            yj <- VUM.read y j+            sumKnown y row i (j + 1) (acc + (row VU.! j) * yj)++-- | Solve @Lᵀ x = y@ for lower-triangular @L@.+backSubst :: Matrix -> VU.Vector Double -> VU.Vector Double+backSubst l y = runST $ do+    x <- VUM.new d+    forM_ [d - 1, d - 2 .. 0] $ \i -> do+        s <- sumKnown x i (i + 1) 0+        VUM.write x i ((y VU.! i - s) / ((l V.! i) VU.! i))+    VU.freeze x+  where+    d = V.length l+    sumKnown x i j !acc+        | j >= d = pure acc+        | otherwise = do+            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.+-}+choleskySolve :: Matrix -> VU.Vector Double -> Maybe (VU.Vector Double)+choleskySolve a b = do+    l <- cholesky a+    pure (backSubst l (forwardSubst l b))++-- | @log det A = 2 Σ log Lᵢᵢ@ from a Cholesky factor @L@.+logDetFromChol :: Matrix -> Double+logDetFromChol l = 2 * V.sum (V.imap (\i row -> log (row VU.! i)) l)
+ src-internal/DataFrame/LinearSolver.hs view
@@ -0,0 +1,450 @@+{-# 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.+-}+module DataFrame.LinearSolver (+    -- * Model+    LinearModel (..),++    -- * Configuration+    SolverConfig (..),+    defaultSolverConfig,++    -- * Solvers+    fitL1Logistic,+    fitProx,++    -- * Expr conversion+    modelToExpr,++    -- * Internals (exposed for testing)+    standardize,+    columnStats,+    softThreshold,+    sigmoid,+    dotProduct,+) where++import qualified DataFrame.Functions as F+import DataFrame.Internal.Expression (Expr (..))+import DataFrame.LinearAlgebra (Matrix, gram, scaleV)+import DataFrame.LinearAlgebra.Eigen (powerIterTop)+import DataFrame.LinearSolver.Loss (+    SmoothLoss (..),+    logisticLoss,+    sigmoid,+ )+import DataFrame.Operators ((.*.), (.+.), (.>.))++import Control.Monad.ST (ST, runST)+import qualified Data.Text as T+import qualified Data.Vector as V+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').+-}+data LinearModel = LinearModel+    { lmWeights :: !(VU.Vector Double)+    , lmIntercept :: !Double+    , lmFeatureNames :: !(V.Vector T.Text)+    }+    deriving (Eq, Show)++-- | Hyper-parameters for the FISTA solver.+data SolverConfig = SolverConfig+    { 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.+    -}+    , 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.+    -}+    }+    deriving (Eq, Show)++defaultSolverConfig :: SolverConfig+defaultSolverConfig =+    SolverConfig+        { scL1Lambda = 0.005+        , scL2Lambda = 0.005+        , scMaxIter = 200+        , scTol = 1.0e-4+        , 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.+-}+fitL1Logistic ::+    SolverConfig ->+    V.Vector (VU.Vector Double) ->+    VU.Vector Double ->+    V.Vector T.Text ->+    LinearModel+{-# 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.+-}+fitProx ::+    SmoothLoss ->+    SolverConfig ->+    V.Vector (VU.Vector Double) ->+    VU.Vector Double ->+    V.Vector T.Text ->+    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.+-}+specNormLipschitz :: SmoothLoss -> Matrix -> Int -> Double+specNormLipschitz loss xKept _ =+    let n = V.length xKept+        gramN = V.map (scaleV (1 / fromIntegral n)) (gram xKept)+        (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.+-}+runFista ::+    SmoothLoss ->+    (Matrix -> Int -> Double) ->+    SolverConfig ->+    V.Vector (VU.Vector Double) ->+    VU.Vector Double ->+    V.Vector T.Text ->+    LinearModel+runFista loss lipschitzOf cfg rows labels featureNames+    | n == 0 || d == 0 = zeroModel+    | otherwise =+        let (!means, !stds, !variances) = columnStats rows+            !keep = keptIndices variances+         in if VU.null keep+                then zeroModel+                else+                    let !meansKept = gatherBy keep means+                        !stdsKept = gatherBy keep stds+                        !xKept = V.map (standardizeRowKept keep means stds) rows+                        !lipschitz =+                            lipschitzOf xKept (VU.length keep) + scL2Lambda cfg+                        (!wStdKept, !bStd) =+                            fistaLoop+                                loss+                                (scL1Lambda cfg)+                                (scL2Lambda cfg)+                                lipschitz+                                (scMaxIter cfg)+                                (scTol cfg)+                                (scSampleWeights cfg)+                                xKept+                                labels+                                (VU.replicate (VU.length keep) 0)+                                0+                        !wRawKept = VU.zipWith (/) wStdKept stdsKept+                        !bRaw = bStd - VU.sum (VU.zipWith (*) wRawKept meansKept)+                     in LinearModel (expandWeights d keep wRawKept) bRaw featureNames+  where+    !n = V.length rows+    !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@.+-}+keptIndices :: VU.Vector Double -> VU.Vector Int+keptIndices variances =+    VU.fromList+        [ j+        | j <- [0 .. VU.length variances - 1]+        , 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.+-}+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.+-}+standardizeRowKept ::+    VU.Vector Int ->+    VU.Vector Double ->+    VU.Vector Double ->+    VU.Vector Double ->+    VU.Vector Double+standardizeRowKept keep means stds row = VU.map standardizeAt keep+  where+    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.+-}+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)@.+-}+modelToExpr :: LinearModel -> Expr Bool+modelToExpr m =+    case nonZero of+        [] -> F.lit (b > 0)+        (w0, n0) : rest -> score rest (term w0 n0) .>. F.lit (0 :: Double)+  where+    b = lmIntercept m+    nonZero =+        [ (w, n)+        | (w, n) <- zip (VU.toList (lmWeights m)) (V.toList (lmFeatureNames m))+        , w /= 0+        ]+    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@.+-}+columnStats ::+    V.Vector (VU.Vector Double) ->+    (VU.Vector Double, VU.Vector Double, VU.Vector Double)+columnStats x+    | V.null x = (VU.empty, VU.empty, VU.empty)+    | otherwise =+        let !d = VU.length (V.unsafeHead x)+            !invN = 1 / fromIntegral (V.length x)+            !means = columnMeans d invN x+            !variances = columnVariances d invN means x+            !stds = VU.map (\v -> if v < 1e-12 then 1 else sqrt v) variances+         in (means, stds, variances)++-- | Mean of each of the @d@ columns; @invN@ is @1 / nRows@.+columnMeans :: Int -> Double -> V.Vector (VU.Vector Double) -> VU.Vector Double+columnMeans d invN x = runST $ do+    acc <- VUM.replicate d 0+    V.forM_ x $ \row ->+        VU.iforM_ row $ \j v -> VUM.unsafeModify acc (+ v) j+    scaleInPlace invN acc+    VU.unsafeFreeze acc++-- | Variance of each of the @d@ columns about the supplied @means@.+columnVariances ::+    Int ->+    Double ->+    VU.Vector Double ->+    V.Vector (VU.Vector Double) ->+    VU.Vector Double+columnVariances d invN means x = runST $ do+    acc <- VUM.replicate d 0+    V.forM_ x $ \row ->+        VU.iforM_ row $ \j v ->+            let !c = v - VU.unsafeIndex means j in VUM.unsafeModify acc (+ c * c) j+    scaleInPlace invN acc+    VU.unsafeFreeze acc++-- | Multiply every element of a mutable vector by @factor@ in place.+scaleInPlace :: Double -> VUM.MVector s Double -> ST s ()+scaleInPlace factor mv = go 0+  where+    go !j+        | 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 ::+    V.Vector (VU.Vector Double) ->+    ( V.Vector (VU.Vector Double)+    , VU.Vector Double+    , VU.Vector Double+    , VU.Vector Double+    )+standardize x+    | V.null x = (x, VU.empty, VU.empty, VU.empty)+    | otherwise =+        let (!means, !stds, !variances) = columnStats x+            !d = VU.length (V.unsafeHead x)+            standardizeRow row =+                VU.generate d $ \j ->+                    (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.+-}+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.+-}+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.+-}+lossGradient ::+    SmoothLoss ->+    Maybe (VU.Vector Double) ->+    V.Vector (VU.Vector Double) ->+    VU.Vector Double ->+    VU.Vector Double ->+    Double ->+    (VU.Vector Double, Double)+lossGradient loss sampleWeights features labels w b = (gradW, gradB)+  where+    !invN = 1 / fromIntegral (V.length features)+    !coeffs = rowCoeffs loss sampleWeights features labels w b invN+    !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]@.+-}+rowCoeffs ::+    SmoothLoss ->+    Maybe (VU.Vector Double) ->+    V.Vector (VU.Vector Double) ->+    VU.Vector Double ->+    VU.Vector Double ->+    Double ->+    Double ->+    VU.Vector Double+rowCoeffs loss sampleWeights features labels w b invN =+    VU.generate (V.length features) $ \i ->+        let !yi = VU.unsafeIndex labels i+            !row = V.unsafeIndex features i+            !z = dotProduct w row + b+            !base = slGradZ loss yi z * invN+         in case sampleWeights of+                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.+-}+accumulateGradW ::+    Int -> V.Vector (VU.Vector Double) -> VU.Vector Double -> VU.Vector Double+accumulateGradW d features coeffs = runST $ do+    mv <- VUM.replicate d 0+    V.iforM_ features $ \i row ->+        let !c = VU.unsafeIndex coeffs i+         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.+-}+fistaLoop ::+    SmoothLoss ->+    Double ->+    Double ->+    Double ->+    Int ->+    Double ->+    Maybe (VU.Vector Double) ->+    V.Vector (VU.Vector Double) ->+    VU.Vector Double ->+    VU.Vector Double ->+    Double ->+    (VU.Vector Double, Double)+fistaLoop loss lambda1 lambda2 lp maxIter tol sampleWeights features labels w0 b0 =+    go 0 w0 b0 w0 b0 1.0+  where+    !shrink = lambda1 / lp+    !ridgeDenom = 1 + lambda2 / lp+    !stepInv = 1 / lp+    proxStep = fistaProxStep loss sampleWeights features labels shrink ridgeDenom stepInv+    go !iter !xWPrev !xBPrev !yW !yB !t+        | iter >= maxIter = (xWPrev, xBPrev)+        | iter > 0 && delta < tol = (xW, xB)+        | otherwise = go (iter + 1) xW xB yWNew yBNew tNew+      where+        (!xW, !xB) = proxStep yW yB+        !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.+-}+fistaProxStep ::+    SmoothLoss ->+    Maybe (VU.Vector Double) ->+    V.Vector (VU.Vector Double) ->+    VU.Vector Double ->+    Double ->+    Double ->+    Double ->+    VU.Vector Double ->+    Double ->+    (VU.Vector Double, Double)+fistaProxStep loss sampleWeights features labels shrink ridgeDenom stepInv yW yB =+    let (gW, gB) = lossGradient loss sampleWeights features labels yW yB+        !wNew =+            VU.zipWith+                (\yi gi -> softThreshold shrink (yi - gi * stepInv) / ridgeDenom)+                yW+                gW+        !bNew = yB - gB * stepInv+     in (wNew, bNew)++{- | Nesterov momentum extrapolation: new look-ahead point @(yW, yB)@ and the+updated step size @t@.+-}+fistaMomentum ::+    Double ->+    VU.Vector Double ->+    Double ->+    VU.Vector Double ->+    Double ->+    (VU.Vector Double, Double, Double)+fistaMomentum t xWPrev xBPrev xW xB =+    let !tNew = (1 + sqrt (1 + 4 * t * t)) / 2+        !mom = (t - 1) / tNew+        !yW = VU.zipWith (\new old -> new + mom * (new - old)) xW xWPrev+        !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.+-}+{-# 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
@@ -0,0 +1,47 @@+{-# 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).+-}+module DataFrame.LinearSolver.Loss (+    SmoothLoss (..),+    sigmoid,+    logisticLoss,+    squaredLoss,+    sqHingeLoss,+) where++import qualified Data.Text as T++{- | A convex, @C¹@ per-sample loss @ℓ(y, z)@. 'slGradZ' is @∂ℓ/∂z@;+'slCurvBound' bounds @∂²ℓ/∂z²@ over all @(y, z)@.+-}+data SmoothLoss = SmoothLoss+    { slName :: !T.Text+    , slGradZ :: Double -> Double -> Double+    , slCurvBound :: !Double+    }++-- | Numerically stable logistic sigmoid.+sigmoid :: Double -> Double+sigmoid z+    | z >= 0 = 1 / (1 + exp (-z))+    | otherwise = let ez = exp z in ez / (1 + ez)++-- | Binary logistic loss for labels in @{\-1,+1}@: @ℓ = log(1 + exp(-y z))@.+logisticLoss :: SmoothLoss+logisticLoss =+    SmoothLoss "logistic" (\y z -> negate (y * sigmoid (negate (y * z)))) 0.25++-- | Squared error for regression: @ℓ = ½ (z - y)²@.+squaredLoss :: SmoothLoss+squaredLoss = SmoothLoss "squared" (flip (-)) 1.0++-- | Squared hinge for classification (LinearSVC default), labels @{\-1,+1}@.+sqHingeLoss :: SmoothLoss+sqHingeLoss =+    SmoothLoss+        "squared_hinge"+        (\y z -> let m = 1 - y * z in if m > 0 then negate (2 * y * m) else 0)+        2.0
+ src-internal/DataFrame/Random.hs view
@@ -0,0 +1,119 @@+{-# 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.+-}+module DataFrame.Random (+    Gen,+    mkGen,+    splitGen,+    nextWord64,+    nextDouble,+    nextIntR,+    gaussianPair,+    gaussianVector,+    shuffleInts,+    sampleIndices,+) where++import Control.Monad (forM_)+import Control.Monad.ST (runST)+import Data.Bits (shiftR)+import qualified Data.Vector.Unboxed as VU+import qualified Data.Vector.Unboxed.Mutable as VUM+import Data.Word (Word64)+import System.Random (StdGen, genWord64, mkStdGen)+import qualified System.Random as R++-- | The pure splittable generator. A fit is a function of @(seed, data)@.+type Gen = StdGen++-- | Seed a generator from an 'Int'.+mkGen :: Int -> Gen+mkGen = mkStdGen++-- | Split into two independent generators.+splitGen :: Gen -> (Gen, Gen)+#if MIN_VERSION_random(1,3,0)+splitGen = R.splitGen+#else+splitGen = R.split+#endif++-- | Raw 64-bit draw.+nextWord64 :: Gen -> (Word64, Gen)+nextWord64 = genWord64++-- | Uniform 'Double' in @[0, 1)@ from the top 53 bits (exact mantissa).+nextDouble :: Gen -> (Double, Gen)+nextDouble g =+    let (w, g') = genWord64 g+        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@.+-}+nextIntR :: (Int, Int) -> Gen -> (Int, Gen)+nextIntR (lo, hi) g+    | hi <= lo = (lo, g)+    | otherwise = loop g+  where+    range = fromIntegral (hi - lo + 1) :: Word64+    threshold = negate range `mod` range+    loop gg =+        let (w, gg') = genWord64 gg+         in if w >= threshold+                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.+-}+gaussianPair :: Gen -> ((Double, Double), Gen)+gaussianPair g =+    let (u1, g1) = nextDouble g+        (u2, g2) = nextDouble g1+        u1' = if u1 <= 0 then 2.220446049250313e-16 else u1+        r = sqrt (-(2 * log u1'))+        a = 2 * pi * u2+     in ((r * cos a, r * sin a), g2)++-- | A length-@n@ vector of standard normals.+gaussianVector :: Int -> Gen -> (VU.Vector Double, Gen)+gaussianVector n g0 = go n g0 []+  where+    go k g acc+        | k <= 0 = (VU.fromList (take n (reverse acc)), g)+        | otherwise =+            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.+-}+shuffleInts :: Int -> Gen -> (VU.Vector Int, Gen)+shuffleInts n g0+    | n <= 1 = (VU.enumFromN 0 (max 0 n), g0)+    | otherwise =+        let (swaps, g1) = genSwaps (n - 1) g0 []+            v = runST $ do+                m <- VU.thaw (VU.enumFromN 0 n)+                forM_ swaps $ uncurry (VUM.swap m)+                VU.freeze m+         in (v, g1)+  where+    genSwaps i g acc+        | i < 1 = (reverse acc, g)+        | otherwise =+            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@.+-}+sampleIndices :: Int -> Int -> Gen -> (VU.Vector Int, Gen)+sampleIndices k n g =+    let (perm, g') = shuffleInts n g+     in (VU.take (min k n) perm, g')
+ src-internal/DataFrame/SymbolicRegression/Expr.hs view
@@ -0,0 +1,121 @@+{-# 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@.+-}+module DataFrame.SymbolicRegression.Expr (+    SRExpr (..),+    BinOp (..),+    UnOp (..),+    evalSR,+    toDataFrameExpr,+    srSize,+    constants,+    setConstants,+    allBinOps,+    allUnOps,+) where++import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU++import qualified DataFrame.Functions as F+import DataFrame.Internal.Expression (Expr (..))+import DataFrame.Operators ((.*.), (.+.), (.-.), (./.))++data BinOp = SAdd | SSub | SMul | SDiv+    deriving (Eq, Ord, Show, Enum, Bounded)++data UnOp = SNeg | SSin | SCos | SExp | SLog | SSqrt+    deriving (Eq, Ord, Show, Enum, Bounded)++-- | A symbolic-regression expression over feature variables and constants.+data SRExpr+    = SVar !Int+    | SConst !Double+    | SUn !UnOp SRExpr+    | SBin !BinOp SRExpr SRExpr+    deriving (Eq, Ord, Show)++allBinOps :: [BinOp]+allBinOps = [minBound .. maxBound]++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.+-}+evalSR :: V.Vector (VU.Vector Double) -> Int -> SRExpr -> VU.Vector Double+evalSR feats n = go+  where+    go (SVar j)+        | j < V.length feats = feats V.! j+        | otherwise = VU.replicate n 0+    go (SConst c) = VU.replicate n c+    go (SUn op e) = VU.map (unFn op) (go e)+    go (SBin op a b) = VU.zipWith (binFn op) (go a) (go b)++binFn :: BinOp -> Double -> Double -> Double+binFn SAdd a b = a + b+binFn SSub a b = a - b+binFn SMul a b = a * b+binFn SDiv a b = if abs b < 1e-9 then 1 else a / b++unFn :: UnOp -> Double -> Double+unFn SNeg = negate+unFn SSin = sin+unFn SCos = cos+unFn SExp = exp . min 50+unFn SLog = \x -> log (abs x + 1e-9)+unFn SSqrt = sqrt . abs++-- | Translate to a dataframe expression over the named feature columns.+toDataFrameExpr :: V.Vector T.Text -> SRExpr -> Expr Double+toDataFrameExpr names = go+  where+    go (SVar j)+        | j < V.length names = Col (names V.! j)+        | otherwise = F.lit 0+    go (SConst c) = F.lit c+    go (SUn op e) = unExpr op (go e)+    go (SBin op a b) = binExpr op (go a) (go b)+    unExpr SNeg = negate+    unExpr SSin = sin+    unExpr SCos = cos+    unExpr SExp = exp+    unExpr SLog = log+    unExpr SSqrt = sqrt+    binExpr SAdd = (.+.)+    binExpr SSub = (.-.)+    binExpr SMul = (.*.)+    binExpr SDiv = (./.)++srSize :: SRExpr -> Int+srSize (SVar _) = 1+srSize (SConst _) = 1+srSize (SUn _ e) = 1 + srSize e+srSize (SBin _ a b) = 1 + srSize a + srSize b++-- | The constant values in left-to-right traversal order.+constants :: SRExpr -> [Double]+constants (SConst c) = [c]+constants (SVar _) = []+constants (SUn _ e) = constants e+constants (SBin _ a b) = constants a ++ constants b++-- | Replace the constants in traversal order; extra values are ignored.+setConstants :: [Double] -> SRExpr -> SRExpr+setConstants vals e = fst (go vals e)+  where+    go vs (SConst _) = case vs of+        (v : rest) -> (SConst v, rest)+        [] -> (SConst 0, [])+    go vs (SVar j) = (SVar j, vs)+    go vs (SUn op a) = let (a', vs') = go vs a in (SUn op a', vs')+    go vs (SBin op a b) =+        let (a', vs') = go vs a+            (b', vs'') = go vs' b+         in (SBin op a' b', vs'')
+ src-internal/DataFrame/SymbolicRegression/GP.hs view
@@ -0,0 +1,206 @@+{- | 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.+-}+module DataFrame.SymbolicRegression.GP (+    GPParams (..),+    runGP,+) where++import Data.List (foldl', minimumBy, sortBy)+import qualified Data.Map.Strict as M+import Data.Ord (comparing)+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU++import DataFrame.Random (Gen, nextDouble, nextIntR)+import DataFrame.SymbolicRegression.Expr+import DataFrame.SymbolicRegression.Optimize (+    meanSquaredError,+    optimizeConstants,+ )+import DataFrame.SymbolicRegression.Simplify (simplify)++-- | GP hyper-parameters resolved from the public config.+data GPParams = GPParams+    { gpFeats :: !(V.Vector (VU.Vector Double))+    , gpN :: !Int+    , gpTarget :: !(VU.Vector Double)+    , gpNVars :: !Int+    , gpUnOps :: ![UnOp]+    , gpPopSize :: !Int+    , gpGenerations :: !Int+    , gpMaxSize :: !Int+    , gpTournament :: !Int+    , gpCrossoverP :: !Double+    , gpMutationP :: !Double+    , gpOptimizeP :: !Double+    , gpParsimony :: !Double+    }++type Scored = (SRExpr, Double)++-- | Run the search; returns @(best, pareto front, generations run)@.+runGP :: GPParams -> Gen -> (SRExpr, [(Int, Double, SRExpr)], Int)+runGP p g0 =+    let (pop0, g1) = initPop p g0+        scored0 = map (scoreOf p) pop0+        arch0 = foldl' (archiveInsert p) M.empty scored0+        (_, finalArch, gN, _) =+            iterate' 0 scored0 arch0 g1+        best = bestOfArchive finalArch+        front =+            [ (sz, mse, e)+            | (sz, (mse, e)) <- M.toList finalArch+            ]+     in (snd3 best, sortBy (comparing fst3) front, gN)+  where+    iterate' gen pop arch g+        | gen >= gpGenerations p = (pop, arch, gen, g)+        | otherwise =+            let (pop', g') = nextGen p pop g+                arch' = foldl' (archiveInsert p) arch pop'+             in iterate' (gen + 1) pop' arch' g'+    fst3 (a, _, _) = a+    snd3 (_, b, _) = b+    bestOfArchive arch =+        case M.toList arch of+            [] -> (0 :: Int, SConst 0, 1 / 0)+            xs ->+                let (sz, (mse, e)) = minimumBy (comparing (fst . snd)) xs+                 in (sz, e, mse)++scoreOf :: GPParams -> SRExpr -> Scored+scoreOf p e = (e, meanSquaredError (gpFeats p) (gpN p) (gpTarget p) e)++fitness :: GPParams -> Scored -> Double+fitness p (e, mse) = mse + gpParsimony p * fromIntegral (srSize e)++archiveInsert ::+    GPParams -> M.Map Int (Double, SRExpr) -> Scored -> M.Map Int (Double, SRExpr)+archiveInsert _ arch (e, mse)+    | isNaN mse || isInfinite mse = arch+    | otherwise =+        let key = srSize (simplify e)+         in M.insertWith better key (mse, e) arch+  where+    better newv@(m1, _) oldv@(m2, _) = if m1 < m2 then newv else oldv++initPop :: GPParams -> Gen -> ([SRExpr], Gen)+initPop p = go (gpPopSize p) []+  where+    go 0 acc g = (acc, g)+    go k acc g =+        let (depth, g1) = nextIntR (1, 4) g+            (e, g2) = randomExpr p depth g1+         in go (k - 1) (e : acc) g2++randomExpr :: GPParams -> Int -> Gen -> (SRExpr, Gen)+randomExpr p depth g+    | depth <= 1 = randomLeaf p g+    | otherwise =+        let (r, g1) = nextDouble g+         in if r < 0.3+                then randomLeaf p g1+                else+                    let (isUn, g2) = nextDouble g1+                     in if isUn < 0.3 && not (null (gpUnOps p))+                            then+                                let (oi, g3) = nextIntR (0, length (gpUnOps p) - 1) g2+                                    (e, g4) = randomExpr p (depth - 1) g3+                                 in (SUn (gpUnOps p !! oi) e, g4)+                            else+                                let (oi, g3) = nextIntR (0, length allBinOps - 1) g2+                                    (a, g4) = randomExpr p (depth - 1) g3+                                    (b, g5) = randomExpr p (depth - 1) g4+                                 in (SBin (allBinOps !! oi) a b, g5)++randomLeaf :: GPParams -> Gen -> (SRExpr, Gen)+randomLeaf p g =+    let (r, g1) = nextDouble g+     in if r < 0.6 && gpNVars p > 0+            then let (j, g2) = nextIntR (0, gpNVars p - 1) g1 in (SVar j, g2)+            else let (c, g2) = nextDouble g1 in (SConst (c * 4 - 2), g2)++nextGen :: GPParams -> [Scored] -> Gen -> ([Scored], Gen)+nextGen p pop g0 =+    let elite = minimumBy (comparing (fitness p)) pop+        (rest, g1) = go (gpPopSize p - 1) [] g0+     in (elite : rest, g1)+  where+    go 0 acc g = (acc, g)+    go k acc g =+        let (child, g') = breed p pop g+            scored = optimizeMaybe p child g'+         in go (k - 1) (fst scored : acc) (snd scored)++optimizeMaybe :: GPParams -> SRExpr -> Gen -> (Scored, Gen)+optimizeMaybe p e g =+    let (r, g1) = nextDouble g+        e' =+            if r < gpOptimizeP p+                then optimizeConstants (gpFeats p) (gpN p) (gpTarget p) 15 e+                else e+     in (scoreOf p e', g1)++breed :: GPParams -> [Scored] -> Gen -> (SRExpr, Gen)+breed p pop g0 =+    let (pa, g1) = tournament p pop g0+        (doX, g2) = nextDouble g1+        (child, g3) =+            if doX < gpCrossoverP p+                then+                    let (pb, g2') = tournament p pop g2+                        (c, g3') = crossover pa pb g2'+                     in (c, g3')+                else (pa, g2)+        (doM, g4) = nextDouble g3+        (child', g5) =+            if doM < gpMutationP p then mutate p child g4 else (child, g4)+        capped = if srSize child' > gpMaxSize p then pa else child'+     in (simplify capped, g5)++tournament :: GPParams -> [Scored] -> Gen -> (SRExpr, Gen)+tournament p pop g0 =+    let (picks, g1) = pickN (gpTournament p) g0+        chosen = map (pop !!) picks+     in (fst (minimumBy (comparing (fitness p)) chosen), g1)+  where+    n = length pop+    pickN 0 g = ([], g)+    pickN k g =+        let (i, g') = nextIntR (0, n - 1) g+            (is, g'') = pickN (k - 1) g'+         in (i : is, g'')++crossover :: SRExpr -> SRExpr -> Gen -> (SRExpr, Gen)+crossover a b g0 =+    let (ia, g1) = nextIntR (0, srSize a - 1) g0+        (ib, g2) = nextIntR (0, srSize b - 1) g1+        sub = subtreeAt ib b+     in (replaceAt ia a sub, g2)++mutate :: GPParams -> SRExpr -> Gen -> (SRExpr, Gen)+mutate p e g0 =+    let (i, g1) = nextIntR (0, srSize e - 1) g0+        (depth, g2) = nextIntR (1, 3) g1+        (newSub, g3) = randomExpr p depth g2+     in (replaceAt i e newSub, g3)++subtreeAt :: Int -> SRExpr -> SRExpr+subtreeAt 0 e = e+subtreeAt i (SUn _ e) = subtreeAt (i - 1) e+subtreeAt i (SBin _ a b) =+    let sa = srSize a+     in if i <= sa then subtreeAt (i - 1) a else subtreeAt (i - 1 - sa) b+subtreeAt _ e = e++replaceAt :: Int -> SRExpr -> SRExpr -> SRExpr+replaceAt 0 _ new = new+replaceAt i (SUn op e) new = SUn op (replaceAt (i - 1) e new)+replaceAt i (SBin op a b) new =+    let sa = srSize a+     in if i <= sa+            then SBin op (replaceAt (i - 1) a new) b+            else SBin op a (replaceAt (i - 1 - sa) b new)+replaceAt _ e _ = e
+ src-internal/DataFrame/SymbolicRegression/Optimize.hs view
@@ -0,0 +1,67 @@+{-# 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.+-}+module DataFrame.SymbolicRegression.Optimize (+    optimizeConstants,+    meanSquaredError,+) where++import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU++import DataFrame.SymbolicRegression.Expr (+    SRExpr,+    constants,+    evalSR,+    setConstants,+ )++-- | Mean squared error of an expression's predictions against the target.+meanSquaredError ::+    V.Vector (VU.Vector Double) -> Int -> VU.Vector Double -> SRExpr -> Double+meanSquaredError feats n target e =+    let pred = evalSR feats n e+        diff = VU.zipWith (-) pred target+     in VU.sum (VU.map (\x -> x * x) diff) / fromIntegral (max 1 n)++-- | Refine an expression's constants to reduce MSE (no-op when constant-free).+optimizeConstants ::+    V.Vector (VU.Vector Double) ->+    Int ->+    VU.Vector Double ->+    Int ->+    SRExpr ->+    SRExpr+optimizeConstants feats n target iters expr+    | null theta0 = expr+    | otherwise = setConstants (descend iters theta0) expr+  where+    theta0 = constants expr+    eps = 1e-6+    mseAt theta = meanSquaredError feats n target (setConstants theta expr)+    descend 0 theta = theta+    descend k theta =+        let f0 = mseAt theta+            g = numGrad theta+            gn = sqrt (sum (map (\x -> x * x) g))+         in if gn < 1e-10+                then theta+                else+                    let theta' = lineSearch theta g f0+                     in if theta' == theta then theta else descend (k - 1) theta'+    numGrad theta =+        [ (mseAt (bump i eps theta) - mseAt (bump i (negate eps) theta)) / (2 * eps)+        | i <- [0 .. length theta - 1]+        ]+    bump i delta theta =+        [if j == i then t + delta else t | (j, t) <- zip [0 ..] theta]+    lineSearch theta g f0 = go (1.0 :: Double)+      where+        go !step+            | step < 1e-8 = theta+            | otherwise =+                let theta' = zipWith (\t gi -> t - step * gi) theta g+                 in if mseAt theta' < f0 then theta' else go (step / 2)
+ src-internal/DataFrame/SymbolicRegression/Simplify.hs view
@@ -0,0 +1,63 @@+{- | 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.+-}+module DataFrame.SymbolicRegression.Simplify (+    simplify,+) where++import DataFrame.SymbolicRegression.Expr (BinOp (..), SRExpr (..), UnOp (..))++-- | Simplify to a fixed point (bounded by a fuel counter).+simplify :: SRExpr -> SRExpr+simplify = go (10 :: Int)+  where+    go 0 e = e+    go fuel e =+        let e' = step e+         in if e' == e then e else go (fuel - 1) e'++step :: SRExpr -> SRExpr+step (SUn op e) = simplifyUn op (step e)+step (SBin op a b) = simplifyBin op (step a) (step b)+step e = e++simplifyUn :: UnOp -> SRExpr -> SRExpr+simplifyUn SNeg (SUn SNeg e) = e+simplifyUn op (SConst c) = SConst (foldUn op c)+simplifyUn op e = SUn op e++simplifyBin :: BinOp -> SRExpr -> SRExpr -> SRExpr+simplifyBin op (SConst a) (SConst b) = SConst (foldBin op a b)+simplifyBin SAdd a (SConst 0) = a+simplifyBin SAdd (SConst 0) b = b+simplifyBin SSub a (SConst 0) = a+simplifyBin SSub a b | a == b = SConst 0+simplifyBin SMul _ (SConst 0) = SConst 0+simplifyBin SMul (SConst 0) _ = SConst 0+simplifyBin SMul a (SConst 1) = a+simplifyBin SMul (SConst 1) b = b+simplifyBin SDiv a (SConst 1) = a+simplifyBin SDiv a b | a == b = SConst 1+simplifyBin op a b+    | commutative op && a > b = SBin op b a+    | otherwise = SBin op a b++commutative :: BinOp -> Bool+commutative SAdd = True+commutative SMul = True+commutative _ = False++foldBin :: BinOp -> Double -> Double -> Double+foldBin SAdd a b = a + b+foldBin SSub a b = a - b+foldBin SMul a b = a * b+foldBin SDiv a b = if abs b < 1e-9 then 1 else a / b++foldUn :: UnOp -> Double -> Double+foldUn SNeg = negate+foldUn SSin = sin+foldUn SCos = cos+foldUn SExp = exp . min 50+foldUn SLog = \x -> log (abs x + 1e-9)+foldUn SSqrt = sqrt . abs
src/DataFrame/Boosting/AdaBoost.hs view
@@ -4,6 +4,7 @@ {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-}  {- | AdaBoost (SAMME) over short, sample-weighted classification trees. The@@ -12,6 +13,7 @@ path is untouched. 'predict' is the arg-max of weighted votes. -} module DataFrame.Boosting.AdaBoost (+    module DataFrame.Model,     AdaBoostConfig (..),     defaultAdaBoostConfig,     AdaBoostModel (..),@@ -35,7 +37,7 @@ import DataFrame.Internal.DataFrame (DataFrame) import DataFrame.Internal.Expression (Expr (..)) import DataFrame.Internal.Interpreter (interpret)-import DataFrame.Model (Fit (..), Predict (..))+import DataFrame.Model import DataFrame.Operators ((.*.), (.+.), (.==.))  data AdaBoostConfig = AdaBoostConfig@@ -55,10 +57,12 @@     }     deriving (Show) -instance (Columnable a, Ord a) => Fit AdaBoostConfig (Expr a) (AdaBoostModel a) where+instance (Columnable a, Ord a) => Fit AdaBoostConfig (Expr a) where+    type ModelOf AdaBoostConfig (Expr a) = (AdaBoostModel a)     fit = fitAdaBoost -instance (Columnable a, Ord a) => Predict (AdaBoostModel a) a where+instance (Columnable a, Ord a) => Predict (AdaBoostModel a) where+    type Prediction (AdaBoostModel a) = Expr a     predict = adaBoostExpr  -- | Fit an AdaBoost-SAMME classifier.
src/DataFrame/Boosting/GBM.hs view
@@ -4,6 +4,7 @@ {-# 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@@ -12,6 +13,7 @@ 'gbDecisionExpr' give the classification probability / decision. -} module DataFrame.Boosting.GBM (+    module DataFrame.Model,     GBLoss (..),     GBConfig (..),     defaultGBConfig,@@ -37,7 +39,7 @@ import DataFrame.Internal.DataFrame (DataFrame) import DataFrame.Internal.Expression (Expr (..), getColumns) import DataFrame.Internal.Interpreter (interpret)-import DataFrame.Model (Fit (..), Predict (..))+import DataFrame.Model import DataFrame.Operators ((.*.), (.+.), (.>.))  -- | The boosting loss.@@ -77,10 +79,12 @@     }     deriving (Show) -instance Fit GBConfig (Expr Double) GBModel where+instance Fit GBConfig (Expr Double) where+    type ModelOf GBConfig (Expr Double) = GBModel     fit = fitGBM -instance Predict GBModel Double where+instance Predict GBModel where+    type Prediction GBModel = Expr Double     predict = gbExpr  -- | Fit a gradient-boosting ensemble predicting @target@ from the other columns.
src/DataFrame/DBSCAN.hs view
@@ -3,6 +3,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# 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@@ -11,6 +12,7 @@ interpretable decision-tree surrogate on the cluster labels instead. -} module DataFrame.DBSCAN (+    module DataFrame.Model,     DBSCANConfig (..),     defaultDBSCANConfig,     DBSCANModel (..),@@ -35,7 +37,7 @@ import DataFrame.Internal.DataFrame (DataFrame, fromNamedColumns) import DataFrame.Internal.Expression (Expr) import DataFrame.LinearAlgebra (epsNeighbors)-import DataFrame.Model (Fit (..))+import DataFrame.Model  data DBSCANConfig = DBSCANConfig     { dbEps :: !Double@@ -56,7 +58,8 @@     }     deriving (Eq, Show) -instance Fit DBSCANConfig [Expr Double] DBSCANModel where+instance Fit DBSCANConfig [Expr Double] where+    type ModelOf DBSCANConfig [Expr Double] = DBSCANModel     fit = fitDBSCAN  -- | Cluster the feature columns with DBSCAN.
src/DataFrame/DecisionTree.hs view
@@ -1,29 +1,53 @@-{- | Decision-tree training on DataFrames: a faithful CART tree refined by Tree-Alternating Optimization (TAO). This module re-exports the implementation,-which is split across the @DataFrame.DecisionTree.*@ modules.+{- | Interpretable decision trees on DataFrames (CART refined by TAO). The+curated public surface: classifier/regressor configs, fitted-model records,+and their 'Fit'\/'Predict' instances. -} module DataFrame.DecisionTree (-    module DataFrame.DecisionTree.Types,-    module DataFrame.DecisionTree.CondVec,-    module DataFrame.DecisionTree.Cart,-    module DataFrame.DecisionTree.Numeric,-    module DataFrame.DecisionTree.Categorical,-    module DataFrame.DecisionTree.Pool,-    module DataFrame.DecisionTree.Predict,-    module DataFrame.DecisionTree.Linear,-    module DataFrame.DecisionTree.Tao,-    module DataFrame.DecisionTree.Prune,-    module DataFrame.DecisionTree.Fit,+    -- * Estimators++    {- | Fitted-model records with their @Fit@\/@Predict@ instances and the+    estimator classes (via "DataFrame.Model").+    -}+    module DataFrame.DecisionTree.Model,++    -- * Classifier configuration+    TreeConfig (..),+    defaultTreeConfig,+    SynthConfig (..),+    defaultSynthConfig,+    ColumnOrdering (..),+    orderable,+    defaultColumnOrdering,+    withOrdFrom,++    -- * Regressor configuration+    RegTreeConfig (..),+    defaultRegTreeConfig,++    -- * Fitted tree structure+    Tree (..),+    treeDepth,++    -- * Solver configuration (fills @TreeConfig.linearSolverConfig@)+    SolverConfig (..),+    defaultSolverConfig, ) where -import DataFrame.DecisionTree.Cart-import DataFrame.DecisionTree.Categorical-import DataFrame.DecisionTree.CondVec-import DataFrame.DecisionTree.Fit-import DataFrame.DecisionTree.Linear-import DataFrame.DecisionTree.Numeric-import DataFrame.DecisionTree.Pool-import DataFrame.DecisionTree.Predict-import DataFrame.DecisionTree.Prune-import DataFrame.DecisionTree.Tao-import DataFrame.DecisionTree.Types+import DataFrame.DecisionTree.Model+import DataFrame.DecisionTree.Regression (+    RegTreeConfig (..),+    defaultRegTreeConfig,+ )+import DataFrame.DecisionTree.Types (+    ColumnOrdering (..),+    SynthConfig (..),+    Tree (..),+    TreeConfig (..),+    defaultColumnOrdering,+    defaultSynthConfig,+    defaultTreeConfig,+    orderable,+    treeDepth,+    withOrdFrom,+ )+import DataFrame.LinearSolver (SolverConfig (..), defaultSolverConfig)
− src/DataFrame/DecisionTree/Cart.hs
@@ -1,291 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE GADTs #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TypeApplications #-}--{- | sklearn-faithful CART initializer used to seed TAO. One-hot encodes-categoricals, splits on exact (unsmoothed) Gini over midpoint thresholds-(@<=@ routes left), and emits a @Tree@ predicting identically to-@DecisionTreeClassifier(criterion='gini')@ on continuous features.--}-module DataFrame.DecisionTree.Cart (-    CartFeature (..),-    CartNode (..),-    sortIndicesByValue,-    buildCartTree,-    cartFeatures,-    cartTargetLabels,-) where--import DataFrame.DecisionTree.Types (Tree (..), TreeConfig (..))-import qualified DataFrame.Functions as F-import DataFrame.Internal.Column-import DataFrame.Internal.DataFrame (DataFrame, columnNames, unsafeGetColumn)-import DataFrame.Internal.Expression (Expr (..))-import DataFrame.Internal.Interpreter (interpret)-import DataFrame.Internal.Types-import DataFrame.Operations.Core (nRows)-import DataFrame.Operators--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-import Data.Type.Equality (testEquality, (:~:) (..))-import qualified Data.Vector as V-import qualified Data.Vector.Algorithms.Merge as VA-import qualified Data.Vector.Unboxed as VU-import Type.Reflection (typeRep)--{- | A one-hot feature column: per-row Double values plus the sklearn LEFT-predicate (@x <= threshold@) over the ORIGINAL DataFrame.--}-data CartFeature = CartFeature-    { cfValues :: !(VU.Vector Double)-    , cfPred :: !(Double -> Expr Bool)-    }---- | Pre-'Tree' CART node: a leaf class id, or a split on feature @j@.-data CartNode = CLeaf !Int | CSplit !Int !Double !CartNode !CartNode---- | Immutable per-fit context for the CART recursion.-data CartCtx = CartCtx-    { ctxFeats :: !(V.Vector CartFeature)-    , ctxNFeats :: !Int-    , ctxCodes :: !(VU.Vector Int)-    , ctxNClasses :: !Int-    , ctxMaxDepth :: !Int-    , 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.--}-sortIndicesByValue :: VU.Vector Double -> VU.Vector Int-sortIndicesByValue vs =-    VU.create $ do-        mv <- VU.thaw (VU.enumFromN 0 (VU.length vs))-        VA.sortBy (compare `on` (vs VU.!)) mv-        pure mv--buildCartTree ::-    forall a. (Columnable a, Ord a) => TreeConfig -> T.Text -> DataFrame -> Tree a-buildCartTree cfg target df =-    cartToTree feats classes (buildCartNode ctx 0 (VU.enumFromN 0 nAll) featSorted)-  where-    nAll = nRows df-    feats = V.fromList (cartFeatures target df)-    featSorted = V.map (sortIndicesByValue . cfValues) feats-    labels = cartLabels @a df target-    classes = cartClasses labels-    ctx =-        CartCtx-            feats-            (V.length feats)-            (classCodes classes labels)-            (V.length classes)-            (maxTreeDepth cfg)-            (max 1 (minLeafSize cfg))--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 err (toVector @a column)-    _ -> err-  where-    err = error "buildCartTree: cannot interpret target column"--cartClasses :: (Ord a) => V.Vector a -> V.Vector a-cartClasses = V.fromList . Set.toList . Set.fromList . V.toList--classCodes :: (Ord a) => V.Vector a -> V.Vector a -> VU.Vector Int-classCodes classes labels = VU.generate (V.length labels) (\i -> M.findWithDefault 0 (labels V.! i) ix)-  where-    ix = M.fromList (zip (V.toList classes) [0 ..])--cartToTree :: V.Vector CartFeature -> V.Vector a -> CartNode -> Tree a-cartToTree feats classes = go-  where-    go (CLeaf cid) = Leaf (classes V.! cid)-    go (CSplit fj thr l r) = Branch (cfPred (feats V.! fj) thr) (go l) (go r)--classCounts :: CartCtx -> VU.Vector Int -> VU.Vector Int-classCounts ctx idxs =-    VU.accumulate-        (+)-        (VU.replicate (ctxNClasses ctx) 0)-        (VU.map (\i -> (ctxCodes ctx VU.! i, 1)) idxs)--isPure :: VU.Vector Int -> Bool-isPure counts = VU.length (VU.filter (> 0) counts) <= 1--buildCartNode ::-    CartCtx -> Int -> VU.Vector Int -> V.Vector (VU.Vector Int) -> CartNode-buildCartNode ctx depth idxs sortedByFeat-    | VU.length idxs < 2 || depth >= ctxMaxDepth ctx || isPure counts = leaf-    | otherwise =-        maybe-            leaf-            (splitNode ctx depth idxs sortedByFeat)-            (bestSplit ctx sortedByFeat counts n)-  where-    n = VU.length idxs-    counts = classCounts ctx idxs-    leaf = CLeaf (VU.maxIndex counts)--splitNode ::-    CartCtx ->-    Int ->-    VU.Vector Int ->-    V.Vector (VU.Vector Int) ->-    (Int, Double) ->-    CartNode-splitNode ctx depth idxs sortedByFeat (fj, thr) =-    CSplit fj thr (rec leftIdx leftSorted) (rec rightIdx rightSorted)-  where-    vals = cfValues (ctxFeats ctx V.! fj)-    leftIdx = VU.filter (\i -> vals VU.! i <= thr) idxs-    rightIdx = VU.filter (\i -> vals VU.! i > thr) idxs-    leftSorted = V.map (VU.filter (\i -> vals VU.! i <= thr)) sortedByFeat-    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.--}-bestSplit ::-    CartCtx ->-    V.Vector (VU.Vector Int) ->-    VU.Vector Int ->-    Int ->-    Maybe (Int, Double)-bestSplit ctx sortedByFeat counts n =-    fmap (\(_, j, t) -> (j, t)) (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.--}-data Sweep = Sweep-    { swBest :: !(Maybe (Double, Double))-    , swLeft :: ![Int]-    , swMoved :: !Int-    , swPrev :: !Double-    }--sweepFeature ::-    CartCtx ->-    [Int] ->-    VU.Vector Int ->-    CartFeature ->-    Int ->-    Maybe (Double, Double)-sweepFeature ctx total si feat n =-    swBest-        ( foldl'-            step-            (Sweep Nothing (replicate (ctxNClasses ctx) 0) 0 (0 / 0))-            [0 .. VU.length si - 1]-        )-  where-    vals = cfValues feat-    step s k = advance ctx total n (vals VU.! i) (ctxCodes ctx VU.! i) s-      where-        i = si VU.! k--advance :: CartCtx -> [Int] -> Int -> Double -> Int -> Sweep -> Sweep-advance ctx total n v c s =-    Sweep-        (considerThreshold ctx total n v s)-        (bumpClass c (swLeft s))-        (swMoved s + 1)-        v--considerThreshold ::-    CartCtx -> [Int] -> Int -> Double -> Sweep -> Maybe (Double, Double)-considerThreshold ctx total n v s-    | swMoved s >= ctxMinLeaf ctx-    , n - swMoved s >= ctxMinLeaf ctx-    , v > swPrev s + 1e-7 =-        keepBetter-            (swBest s)-            (weightedGini total (swLeft s) (swMoved s) n)-            ((swPrev s + v) / 2)-    | otherwise = swBest s--keepBetter ::-    Maybe (Double, Double) -> Double -> Double -> Maybe (Double, Double)-keepBetter best g thr = case best of-    Just (wb, _) | wb <= g -> best-    _ -> Just (g, thr)--weightedGini :: [Int] -> [Int] -> Int -> Int -> Double-weightedGini total leftAcc nl n =-    ( fromIntegral nl * giniImpurity leftAcc nl-        + fromIntegral nr * giniImpurity rightAcc nr-    )-        / fromIntegral n-  where-    nr = n - nl-    rightAcc = zipWith (-) total leftAcc---- | Gini impurity @1 - Σ (c/m)²@ of a class-count list of total @m@.-giniImpurity :: [Int] -> Int -> Double-giniImpurity _ 0 = 0-giniImpurity cs m = 1 - sum [let p = fromIntegral c / fromIntegral m in p * p | c <- cs]--bumpClass :: Int -> [Int] -> [Int]-bumpClass c = zipWith (\j x -> if j == c then x + 1 else x) [0 ..]---- | 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))--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-    pt@(PackedText _ _) -> case materializePacked pt of-        BoxedColumn _ (v :: V.Vector b) -> oneHotFeatures @b (nRows df) c v-        _ -> []--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)]-    Nothing -> case sIntegral @b of-        STrue ->-            [ CartFeature (VU.map fromIntegral v) (\t -> F.toDouble (F.col @b c) .<=. F.lit t)-            ]-        SFalse -> []--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))]-    Nothing -> []--oneHot :: Int -> T.Text -> V.Vector T.Text -> T.Text -> CartFeature-oneHot nAll c 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))---- | Target column as string labels (matches pandas @y.astype(str)@).-cartTargetLabels :: T.Text -> DataFrame -> V.Vector T.Text-cartTargetLabels target df = case unsafeGetColumn target df of-    BoxedColumn _ (v :: V.Vector b) -> case testEquality (typeRep @b) (typeRep @T.Text) of-        Just Refl -> v-        Nothing -> V.map (T.pack . show) v-    UnboxedColumn _ (v :: VU.Vector b) -> V.map (T.pack . show) (V.convert v)-    pt@(PackedText _ _) -> case materializePacked pt of-        BoxedColumn _ (v :: V.Vector b) -> case testEquality (typeRep @b) (typeRep @T.Text) of-            Just Refl -> v-            Nothing -> V.map (T.pack . show) v-        _ -> V.empty
− src/DataFrame/DecisionTree/Categorical.hs
@@ -1,381 +0,0 @@-{-# LANGUAGE AllowAmbiguousTypes #-}-{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE GADTs #-}-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TypeApplications #-}--{- | Categorical split candidates: Breiman prefixes for binary targets,-subset/singleton enumeration otherwise, and cross-column equality. Each-value-list yields an OR-of-equalities condition (as an expression or a-directly-read membership truth vector).--}-module DataFrame.DecisionTree.Categorical (-    TargetInfo (..),-    mkTargetInfo,-    distinctValuesUpTo,-    validBoxedValues,-    orEqs,-    subsetSplits,-    subsetLists,-    singletonSplits,-    singletonLists,-    breimanPrefixSplits,-    breimanPrefixLists,-    catValueLists,-    membershipVec,-    crossColumnConds,-    discreteConditions,-    discreteCondVecs,-) where--import DataFrame.DecisionTree.CondVec (CondVec (..), materializeCondVec)-import DataFrame.DecisionTree.Types (-    ColumnOrdering,-    SynthConfig (..),-    TreeConfig (..),-    withOrdFrom,- )-import DataFrame.Internal.Column-import DataFrame.Internal.DataFrame (DataFrame, columnNames, unsafeGetColumn)-import DataFrame.Internal.Expression (Expr (..))-import DataFrame.Internal.Interpreter (interpret)-import DataFrame.Internal.Types-import DataFrame.Operators--import Data.Either (fromRight)-import Data.Function (on)-import Data.List (inits, sort, sortBy, subsequences)-import qualified Data.Map.Strict as M-import Data.Maybe (fromMaybe, mapMaybe)-import qualified Data.Set as Set-import qualified Data.Text as T-import Data.Type.Equality (testEquality, (:~:) (..))-import qualified Data.Vector as V-import qualified Data.Vector.Unboxed as VU-import Type.Reflection (typeRep)---- | Valid-slot view of a nullable boxed column (null slots hold crash-thunks).-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.--}-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.--}-mkTargetInfo ::-    forall target.-    (Columnable target, Ord target) =>-    T.Text -> DataFrame -> Maybe (TargetInfo target)-mkTargetInfo target df = case interpret @target df (Col target) of-    Right (TColumn column) ->-        either (const Nothing) (Just . targetInfoFromValues) (toVector @target column)-    _ -> Nothing--targetInfoFromValues :: (Ord target) => V.Vector target -> TargetInfo target-targetInfoFromValues vals = TargetInfo isBinary posClass vals-  where-    distinct = Set.toAscList (Set.fromList (V.toList vals))-    isBinary = length distinct == 2-    posClass = case distinct of-        (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.--}-distinctValuesUpTo :: (Ord a) => Int -> V.Vector a -> Either Int [a]-distinctValuesUpTo cap values = go Set.empty 0-  where-    n = V.length values-    go !s !i-        | i >= n = Right (Set.toAscList s)-        | 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.--}-orEqs :: (a -> Expr Bool) -> [a] -> Expr Bool-orEqs eqLit = foldr1 (.||.) . map eqLit--subsetSplits :: (a -> Expr Bool) -> [a] -> [Expr Bool]-subsetSplits eqLit = map (orEqs eqLit) . subsetLists---- | Proper non-empty, non-full subsets of the values.-subsetLists :: [a] -> [[a]]-subsetLists vs = drop 1 (init (subsequences vs))--singletonSplits :: (a -> Expr Bool) -> [a] -> [Expr Bool]-singletonSplits = map--singletonLists :: [a] -> [[a]]-singletonLists = map (: [])--breimanPrefixSplits ::-    (Ord a, Ord target) =>-    target ->-    V.Vector a ->-    V.Vector target ->-    [a] ->-    (a -> Expr Bool) ->-    [Expr Bool]-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.--}-breimanPrefixLists ::-    (Ord a, Ord target) => target -> V.Vector a -> V.Vector target -> [a] -> [[a]]-breimanPrefixLists pc values targetVals distinctVals =-    nonTrivialPrefixes (sortByRate (levelCounts pc values targetVals) distinctVals)--levelCounts ::-    (Ord a, Eq target) =>-    target -> V.Vector a -> V.Vector target -> M.Map a (Int, Int)-levelCounts pc values targetVals = V.ifoldl' add M.empty values-  where-    add acc i v = M.insertWith plus v (indicator (V.unsafeIndex targetVals i == pc), 1) acc-    plus (p1, n1) (p2, n2) = (p1 + p2, n1 + n2)-    indicator b = if b then 1 else 0--laplaceRate :: (Ord a) => M.Map a (Int, Int) -> a -> Double-laplaceRate counts v = case M.lookup v counts of-    Nothing -> 0.5-    Just (pos, n) -> (fromIntegral pos + 1) / (fromIntegral n + 2)--sortByRate :: (Ord a) => M.Map a (Int, Int) -> [a] -> [a]-sortByRate counts = sortBy (compare `on` (\v -> (laplaceRate counts v, v)))--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.--}-catValueLists ::-    (Ord a, Ord target) =>-    Bool -> Maybe target -> V.Vector target -> Int -> V.Vector a -> [[a]]-catValueLists isBinary posClass targetVals subsetCap values-    | V.null values = []-    | isBinary, Just pc <- posClass = binaryLists pc targetVals values-    | otherwise = multiclassLists subsetCap values--binaryLists ::-    (Ord a, Ord target) => target -> V.Vector target -> V.Vector a -> [[a]]-binaryLists pc targetVals values-    | length distinct < 2 = []-    | otherwise = breimanPrefixLists pc values targetVals distinct-  where-    distinct = fromRight (ascDistinct values) (distinctValuesUpTo 64 values)--multiclassLists :: (Ord a) => Int -> V.Vector a -> [[a]]-multiclassLists subsetCap values = case distinctValuesUpTo subsetCap values of-    Right vs | length vs >= 2 -> subsetLists vs-    Right _ -> []-    Left _ -> singletonLists (ascDistinct values)--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.--}-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.--}-data CatCtx target = CatCtx-    { ccBinary :: !Bool-    , ccPos :: !(Maybe target)-    , ccTargets :: !(V.Vector target)-    , ccSubsetCap :: !Int-    , ccOrds :: !ColumnOrdering-    }--catCtx :: TargetInfo target -> TreeConfig -> CatCtx target-catCtx ti cfg =-    CatCtx-        (tiIsBinary ti)-        (tiPositiveClass ti)-        (tiValues ti)-        (maxCategoricalSubsetCardinality (synthConfig cfg))-        (columnOrdering cfg)--catValueListsFor :: (Ord a, Ord target) => CatCtx target -> V.Vector a -> [[a]]-catValueListsFor ctx = catValueLists (ccBinary ctx) (ccPos ctx) (ccTargets ctx) (ccSubsetCap ctx)---- | True for numeric columns (handled by the numeric pool, not here).-isNumericKind :: forall a. (Columnable a) => Bool-isNumericKind = case sFloating @a of-    STrue -> True-    SFalse -> case sIntegral @a of-        STrue -> True-        SFalse -> False--{- | All equality-based candidate splits from non-numeric columns: per-column-categorical conditions plus cross-column equality/order conditions.--}-discreteConditions ::-    forall target.-    (Columnable target, Ord target) =>-    TargetInfo target -> TreeConfig -> DataFrame -> [Expr Bool]-discreteConditions targetInfo cfg df =-    concatMap (columnConds (catCtx targetInfo cfg) df) (columnNames df)-        ++ crossColumnConds cfg df--columnConds ::-    (Columnable target, Ord target) =>-    CatCtx target -> DataFrame -> T.Text -> [Expr Bool]-columnConds ctx df colName = case unsafeGetColumn colName df of-    BoxedColumn Nothing (column :: V.Vector a) -> nonNullColConds ctx colName column-    BoxedColumn (Just bm) (column :: V.Vector a) -> nullableColConds ctx colName bm column-    UnboxedColumn _ (_ :: VU.Vector a) -> []-    pt@(PackedText _ _) -> case materializePacked pt of-        BoxedColumn Nothing (column :: V.Vector a) -> nonNullColConds ctx colName column-        BoxedColumn (Just bm) (column :: V.Vector a) -> nullableColConds ctx colName bm column-        _ -> []--nonNullColConds ::-    forall a target.-    (Columnable a, Ord target) =>-    CatCtx target -> T.Text -> V.Vector a -> [Expr Bool]-nonNullColConds ctx colName column =-    fromMaybe-        []-        ( withOrdFrom @a-            (ccOrds ctx)-            (map (orEqs (eqExprFor @a colName)) (catValueListsFor ctx column))-        )--nullableColConds ::-    forall a target.-    (Columnable a, Ord target) =>-    CatCtx target -> T.Text -> Bitmap -> V.Vector a -> [Expr Bool]-nullableColConds ctx colName bm column-    | isNumericKind @a || V.null valid = []-    | otherwise =-        fromMaybe-            []-            ( withOrdFrom @a-                (ccOrds ctx)-                (map (orEqs (eqJustFor @a colName)) (catValueListsFor ctx valid))-            )-  where-    valid = validBoxedValues bm column--eqExprFor :: forall a. (Columnable a) => T.Text -> a -> Expr Bool-eqExprFor colName v = Col @a colName .==. Lit v--eqJustFor :: forall a. (Columnable a) => T.Text -> a -> Expr Bool-eqJustFor colName v = Col @(Maybe a) colName .==. Lit (Just v)---- | Cross-column equality/order conditions over pairs of same-typed columns.-crossColumnConds :: TreeConfig -> DataFrame -> [Expr Bool]-crossColumnConds cfg df = concatMap (pairConds (columnOrdering cfg) df) (allowedPairs cfg df)--allowedPairs :: TreeConfig -> DataFrame -> [(T.Text, T.Text)]-allowedPairs cfg df =-    [ (l, r)-    | l <- columnNames df-    , r <- columnNames df-    , l /= r-    , not (isDisallowedPair cfg l r)-    ]--isDisallowedPair :: TreeConfig -> T.Text -> T.Text -> Bool-isDisallowedPair cfg l r =-    any-        (\(l', r') -> sort [l', r'] == sort [l, r])-        (disallowedCombinations (synthConfig cfg))--pairConds :: ColumnOrdering -> DataFrame -> (T.Text, T.Text) -> [Expr Bool]-pairConds ords df (l, r) = case ( materializePacked (unsafeGetColumn l df)-                                , materializePacked (unsafeGetColumn r df)-                                ) of-    (BoxedColumn Nothing (_ :: V.Vector a), BoxedColumn Nothing (_ :: V.Vector b)) -> strictPairConds @a @b l r-    (BoxedColumn (Just _) (_ :: V.Vector a), BoxedColumn (Just _) (_ :: V.Vector b)) -> nullablePairConds @a @b ords l r-    _ -> []--strictPairConds ::-    forall a b. (Columnable a, Columnable b) => T.Text -> T.Text -> [Expr Bool]-strictPairConds l r = case testEquality (typeRep @a) (typeRep @b) of-    Just Refl -> [Col @a l .==. Col @a r]-    Nothing -> []--nullablePairConds ::-    forall a b.-    (Columnable a, Columnable b) =>-    ColumnOrdering -> T.Text -> T.Text -> [Expr Bool]-nullablePairConds ords l r = case testEquality (typeRep @a) (typeRep @b) of-    Nothing -> []-    Just Refl -> nullableEqOrLe @a ords l r--nullableEqOrLe ::-    forall a. (Columnable a) => ColumnOrdering -> T.Text -> T.Text -> [Expr Bool]-nullableEqOrLe ords l r-    | isTextType @a = eqOnly-    | otherwise =-        maybe-            eqOnly-            (++ eqOnly)-            (withOrdFrom @a ords [Col @(Maybe a) l .<=. Col @(Maybe a) r])-  where-    eqOnly = [Col @(Maybe a) l .==. Col @(Maybe a) r]--isTextType :: forall a. (Columnable a) => Bool-isTextType = case testEquality (typeRep @a) (typeRep @T.Text) of-    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.--}-discreteCondVecs ::-    forall target.-    (Columnable target, Ord target) =>-    TargetInfo target -> TreeConfig -> DataFrame -> [CondVec]-discreteCondVecs targetInfo cfg df =-    concatMap (columnCondVecs (catCtx targetInfo cfg) df) (columnNames df)-        ++ mapMaybe (materializeCondVec df) (crossColumnConds cfg df)--columnCondVecs ::-    (Columnable target, Ord target) =>-    CatCtx target -> DataFrame -> T.Text -> [CondVec]-columnCondVecs ctx df colName = case unsafeGetColumn colName df of-    BoxedColumn Nothing (column :: V.Vector a) -> nonNullColCondVecs ctx colName column-    BoxedColumn (Just bm) (column :: V.Vector a) -> mapMaybe (materializeCondVec df) (nullableColConds ctx colName bm column)-    UnboxedColumn _ (_ :: VU.Vector a) -> []-    pt@(PackedText _ _) -> case materializePacked pt of-        BoxedColumn Nothing (column :: V.Vector a) -> nonNullColCondVecs ctx colName column-        BoxedColumn (Just bm) (column :: V.Vector a) -> mapMaybe (materializeCondVec df) (nullableColConds ctx colName bm column)-        _ -> []--nonNullColCondVecs ::-    forall a target.-    (Columnable a, Ord target) => CatCtx target -> T.Text -> V.Vector a -> [CondVec]-nonNullColCondVecs ctx colName column =-    fromMaybe-        []-        ( withOrdFrom @a-            (ccOrds ctx)-            (map (membershipCondVec colName column) (catValueListsFor ctx column))-        )--membershipCondVec ::-    forall a. (Columnable a, Ord a) => T.Text -> V.Vector a -> [a] -> CondVec-membershipCondVec colName column vs = CondVec (orEqs (eqExprFor @a colName) vs) (membershipVec column vs)
− src/DataFrame/DecisionTree/CondVec.hs
@@ -1,180 +0,0 @@-{-# LANGUAGE GADTs #-}-{-# LANGUAGE OverloadedStrings #-}-{-# 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.--}-module DataFrame.DecisionTree.CondVec (-    CondVec (..),-    materializeCondVec,-    CondCache,-    condCacheKey,-    condCacheFromVecs,-    addTreeCondsToCache,-    lookupCondVec,-    partitionByVec,-    countErrorsByVec,-    consolidateThreshold,-    combineAndVec,-    combineOrVec,-) where--import DataFrame.DecisionTree.Types (CarePoint (..), Direction (..), Tree (..))-import qualified DataFrame.Functions as F-import DataFrame.Internal.Column (TypedColumn (..), toVector)-import DataFrame.Internal.DataFrame (DataFrame)-import DataFrame.Internal.Expression (-    BinaryOp (binaryName),-    Expr (..),-    eqExpr,-    normalize,- )-import DataFrame.Internal.Interpreter (interpret)--import qualified Data.Map.Strict as M-import Data.Maybe (fromMaybe)-import qualified Data.Text as T-import Data.Type.Equality (testEquality, (:~:) (..))-import qualified Data.Vector as V-import qualified Data.Vector.Unboxed as VU-import Type.Reflection (typeRep)---- | A boolean condition paired with its truth vector over the full DataFrame.-data CondVec = CondVec-    { cvExpr :: !(Expr Bool)-    , cvVec :: !(VU.Vector Bool)-    }--{- | Interpret a condition once over the DataFrame; 'Nothing' on a-type/interpret failure so the candidate is silently dropped.--}-materializeCondVec :: DataFrame -> Expr Bool -> Maybe CondVec-materializeCondVec df cond = case interpret @Bool df cond of-    Left _ -> Nothing-    Right (TColumn column) -> CondVec cond <$> eitherToMaybe (toVector @Bool @VU.Vector column)--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.--}-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).--}-condCacheKey :: Expr Bool -> T.Text-condCacheKey = T.pack . show . normalize---- | Seed a cache from already-materialized candidate 'CondVec's (no interpret).-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).--}-addTreeCondsToCache :: DataFrame -> Tree a -> CondCache -> CondCache-addTreeCondsToCache df = go-  where-    go (Leaf _) c = c-    go (Branch cond l r) c = go r (go l (insertCond df cond c))--insertCond :: DataFrame -> Expr Bool -> CondCache -> CondCache-insertCond df cond c-    | M.member k c = c-    | otherwise =-        maybe c (\cv -> M.insert k (cvVec cv) c) (materializeCondVec df cond)-  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).--}-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.-partitionByVec :: VU.Vector Bool -> V.Vector Int -> (V.Vector Int, V.Vector Int)-partitionByVec boolVals = V.partition (boolVals VU.!)---- | Count care points the truth vector routes to the wrong child.-countErrorsByVec :: VU.Vector Bool -> [CarePoint] -> Int-countErrorsByVec boolVals = length . filter misrouted-  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.--}-data ThreshCmp = ThreshCmp-    { tcCol :: !T.Text-    , tcName :: !T.Text-    , tcThr :: !Double-    , tcRebuild :: Double -> Expr Bool-    }--asDoubleThreshold :: Expr Bool -> Maybe ThreshCmp-asDoubleThreshold (Binary op (Col c :: Expr cc) (Lit (t :: tt))) =-    case ( testEquality (typeRep @cc) (typeRep @Double)-         , testEquality (typeRep @tt) (typeRep @Double)-         ) of-        (Just Refl, Just Refl) -> Just (ThreshCmp c (binaryName op) t (Binary op (Col c) . Lit))-        _ -> Nothing-asDoubleThreshold _ = Nothing--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).--}-chooseThreshold :: Bool -> T.Text -> Double -> Double -> Double-chooseThreshold isAnd name t1 t2-    | leftDir = if isAnd then min t1 t2 else max t1 t2-    | otherwise = if isAnd then max t1 t2 else min t1 t2-  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.--}-consolidateThreshold :: Bool -> Expr Bool -> Expr Bool -> Maybe (Expr Bool)-consolidateThreshold isAnd ea eb = do-    a <- asDoubleThreshold ea-    b <- asDoubleThreshold eb-    if tcCol a == tcCol b && tcName a == tcName b && tcName a `elem` directionalNames-        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.--}-combineAndVec :: CondVec -> CondVec -> CondVec-combineAndVec a b-    | eqExpr (cvExpr a) (cvExpr b) = a-    | otherwise = CondVec expr (VU.zipWith (&&) (cvVec a) (cvVec b))-  where-    expr =-        fromMaybe-            (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').--}-combineOrVec :: CondVec -> CondVec -> CondVec-combineOrVec a b-    | eqExpr (cvExpr a) (cvExpr b) = a-    | otherwise = CondVec expr (VU.zipWith (||) (cvVec a) (cvVec b))-  where-    expr =-        fromMaybe-            (F.or (cvExpr a) (cvExpr b))-            (consolidateThreshold False (cvExpr a) (cvExpr b))
− src/DataFrame/DecisionTree/Fit.hs
@@ -1,213 +0,0 @@-{-# LANGUAGE AllowAmbiguousTypes #-}-{-# LANGUAGE FlexibleContexts #-}-{-# 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.--}-module DataFrame.DecisionTree.Fit (-    treeToExpr,-    fitDecisionTree,-    buildTree,-    pruneTree,-    partitionDataFrame,-    calculateGini,-    majorityValue,-    getCounts,-    percentile,-    ProbTree,-    probsFromIndices,-    buildProbTree,-    fitProbTree,-    probExprs,-) where--import DataFrame.DecisionTree.Cart (buildCartTree)-import DataFrame.DecisionTree.Categorical (-    TargetInfo (..),-    discreteCondVecs,-    discreteConditions,-    mkTargetInfo,- )-import DataFrame.DecisionTree.CondVec (CondVec)-import DataFrame.DecisionTree.Numeric (numericCondVecs, numericConditions)-import DataFrame.DecisionTree.Pool (dedupCVByExpr, nubByExpr)-import DataFrame.DecisionTree.Predict (partitionIndices)-import DataFrame.DecisionTree.Prune (pruneDead, pruneExpr)-import DataFrame.DecisionTree.Tao (taoOptimize, taoOptimizeCV)-import DataFrame.DecisionTree.Types (Tree (..), TreeConfig (..))-import qualified DataFrame.Functions as F-import DataFrame.Internal.Column (Columnable, TypedColumn (..), toVector)-import DataFrame.Internal.DataFrame (DataFrame)-import DataFrame.Internal.Expression (Expr (..))-import DataFrame.Internal.Interpreter (interpret)-import DataFrame.Operations.Core (nRows)-import DataFrame.Operations.Subset (exclude, filterWhere)--import Control.Exception (throw)-import Data.Function (on)-import Data.List (foldl', maximumBy, nub, sort)-import qualified Data.Map.Strict as M-import Data.Maybe (fromMaybe)-import qualified Data.Text as T-import qualified Data.Vector as V---- | Convert a fitted tree to a nested-conditional expression.-treeToExpr :: (Columnable a) => Tree a -> Expr a-treeToExpr (Leaf v) = Lit v-treeToExpr (Branch cond left right) = F.ifThenElse cond (treeToExpr left) (treeToExpr right)---- | Fit a TAO decision tree (CART-seeded) and return it as an expression.-fitDecisionTree ::-    forall a. (Columnable a, Ord a) => TreeConfig -> Expr a -> DataFrame -> Expr a-fitDecisionTree cfg (Col target) df =-    pruneExpr-        (treeToExpr (taoOptimizeCV @a cfg target condVecs df indices initialTree))-  where-    condVecs = candidatePool @a cfg target df-    initialTree = buildCartTree @a cfg target df-    indices = V.enumFromN 0 (nRows df)-fitDecisionTree _ expr _ = error ("Cannot create tree for compound expression: " ++ show expr)---- | The deduplicated numeric + discrete candidate pool for a target column.-candidatePool ::-    forall a.-    (Columnable a, Ord a) => TreeConfig -> T.Text -> DataFrame -> [CondVec]-candidatePool cfg target df = dedupCVByExpr (numericCVs ++ discreteCVs)-  where-    dfNoTarget = exclude [target] df-    numericCVs = numericCondVecs cfg dfNoTarget df-    discreteCVs = discreteCondVecs (targetInfoOrEmpty @a target df) cfg dfNoTarget--targetInfoOrEmpty ::-    forall a. (Columnable a, Ord a) => T.Text -> DataFrame -> TargetInfo a-targetInfoOrEmpty target df = fromMaybe (TargetInfo False Nothing V.empty) (mkTargetInfo @a target df)---- | Fit a tree at a given depth from a raw condition list (CART + TAO + prune).-buildTree ::-    forall a.-    (Columnable a, Ord a) =>-    TreeConfig -> Int -> T.Text -> [Expr Bool] -> DataFrame -> Expr a-buildTree cfg depth target conds df =-    pruneExpr (treeToExpr (taoOptimize @a cfg target conds df indices tree))-  where-    tree = buildCartTree @a cfg{maxTreeDepth = depth} target df-    indices = V.enumFromN 0 (nRows df)--pruneTree :: forall a. (Columnable a) => Expr a -> Expr a-pruneTree = pruneExpr--partitionDataFrame :: Expr Bool -> DataFrame -> (DataFrame, DataFrame)-partitionDataFrame cond df = (filterWhere cond df, filterWhere (F.not cond) df)---- | Laplace-smoothed Gini impurity of the target distribution.-calculateGini ::-    forall a. (Columnable a, Ord a) => T.Text -> DataFrame -> Double-calculateGini target df-    | n == 0 = 0-    | otherwise = 1 - sum (map (^ (2 :: Int)) probs)-  where-    n = fromIntegral (nRows df)-    counts = getCounts @a target df-    numClasses = fromIntegral (M.size counts)-    probs = map (\c -> (fromIntegral c + 1) / (n + numClasses)) (M.elems counts)--majorityValue :: forall a. (Columnable a, Ord a) => T.Text -> DataFrame -> a-majorityValue target df-    | M.null counts = error "Empty DataFrame in leaf"-    | otherwise = fst (maximumBy (compare `on` snd) (M.toList counts))-  where-    counts = getCounts @a target df--getCounts ::-    forall a. (Columnable a, Ord a) => T.Text -> DataFrame -> M.Map a Int-getCounts target df = case interpret @a df (Col target) of-    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)---- | The @p@-th percentile of an expression's values (@0@ on failure/empty).-percentile :: Int -> Expr Double -> DataFrame -> Double-percentile p expr df = case interpret @Double df expr of-    Right (TColumn column) -> either (const 0) (percentileOfVec p) (toVector @Double column)-    _ -> 0--percentileOfVec :: Int -> V.Vector Double -> Double-percentileOfVec p vals-    | n == 0 = 0-    | otherwise = sorted V.! min (n - 1) (max 0 ((p * n) `div` 100))-  where-    sorted = V.fromList (sort (V.toList vals))-    n = V.length sorted---- | A tree whose leaves hold class-probability distributions.-type ProbTree a = Tree (M.Map a Double)---- | Normalised class probabilities over a subset of training rows.-probsFromIndices ::-    forall a.-    (Columnable a, Ord a) => T.Text -> DataFrame -> V.Vector Int -> M.Map a Double-probsFromIndices target df indices = case interpret @a df (Col target) of-    Right (TColumn column) -> either (const M.empty) (normaliseCounts indices) (toVector @a column)-    _ -> M.empty--normaliseCounts :: (Ord a) => V.Vector Int -> V.Vector a -> M.Map a Double-normaliseCounts indices vals = M.map (\c -> fromIntegral c / total) counts-  where-    counts =-        V.foldl'-            (\acc i -> M.insertWith (+) (vals V.! i) (1 :: Int) acc)-            M.empty-            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.--}-buildProbTree ::-    forall a.-    (Columnable a, Ord a) =>-    Tree a -> T.Text -> DataFrame -> V.Vector Int -> ProbTree a-buildProbTree (Leaf _) target df indices = Leaf (probsFromIndices @a target df indices)-buildProbTree (Branch cond left right) target df indices =-    Branch-        cond-        (buildProbTree @a left target df l)-        (buildProbTree @a right target df r)-  where-    (l, r) = partitionIndices cond df indices---- | Fit a TAO tree and return one probability expression per class.-fitProbTree ::-    forall a.-    (Columnable a, Ord a) =>-    TreeConfig -> Expr a -> DataFrame -> M.Map a (Expr Double)-fitProbTree cfg (Col target) df = probExprs (buildProbTree @a pruned target df indices)-  where-    conds =-        nubByExpr-            ( numericConditions cfg dfNoTarget-                ++ discreteConditions (targetInfoOrEmpty @a target df) cfg dfNoTarget-            )-    dfNoTarget = exclude [target] df-    indices = V.enumFromN 0 (nRows df)-    pruned =-        pruneDead-            (taoOptimize @a cfg target conds df indices (buildCartTree @a cfg target df))-fitProbTree _ expr _ = error ("Cannot create prob tree for compound expression: " ++ show expr)---- | Convert a 'ProbTree' into one @Expr Double@ per class.-probExprs ::-    forall a. (Columnable a, Ord a) => ProbTree a -> M.Map a (Expr Double)-probExprs tree = M.fromList [(c, classExpr c tree) | c <- nub (allClasses tree)]--allClasses :: ProbTree a -> [a]-allClasses (Leaf m) = M.keys m-allClasses (Branch _ l r) = allClasses l ++ allClasses r--classExpr :: (Ord a) => a -> ProbTree a -> Expr Double-classExpr c (Leaf m) = Lit (M.findWithDefault 0.0 c m)-classExpr c (Branch cond l r) = F.ifThenElse cond (classExpr c l) (classExpr c r)
− src/DataFrame/DecisionTree/Linear.hs
@@ -1,161 +0,0 @@-{-# 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.--}-module DataFrame.DecisionTree.Linear (-    bestLinearCandidate,-    fitLinearCandidate,-    careRowsFromFeatures,-    careLabels,-    featName,-    imputeMean,-    materializeFeatureForCare,-) where--import DataFrame.DecisionTree.Numeric (NumExpr (..), numericCols)-import DataFrame.DecisionTree.Types (-    CarePoint (..),-    Direction (..),-    TreeConfig (..),- )-import DataFrame.Internal.Column (TypedColumn (..), toVector)-import DataFrame.Internal.DataFrame (DataFrame)-import DataFrame.Internal.Expression (Expr, getColumns)-import DataFrame.Internal.Interpreter (interpret)-import qualified DataFrame.LinearSolver as LS--import Data.Maybe (catMaybes, fromMaybe, mapMaybe)-import qualified Data.Text as T-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.--}-bestLinearCandidate ::-    TreeConfig -> DataFrame -> [CarePoint] -> Maybe (Expr Bool)-bestLinearCandidate cfg df carePoints-    | not (useLinearSolver cfg) = Nothing-    | length carePoints < minCarePointsForLinear cfg = Nothing-    | otherwise = fitLinearCandidate cfg 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.--}-fitLinearCandidate ::-    TreeConfig -> DataFrame -> [CarePoint] -> Maybe (Expr Bool)-fitLinearCandidate cfg df carePoints = case materializedFeatures df carePoints of-    [] -> Nothing-    mats -> linearFromFeatures cfg carePoints mats--materializedFeatures :: DataFrame -> [CarePoint] -> [(T.Text, VU.Vector Double)]-materializedFeatures df carePoints = mapMaybe (materializeFeatureForCare df carePoints) (numericCols df)--linearFromFeatures ::-    TreeConfig -> [CarePoint] -> [(T.Text, VU.Vector Double)] -> Maybe (Expr Bool)-linearFromFeatures cfg carePoints mats-    | VU.all (== 0) weights = Nothing-    | degenerateHyperplane rows weights (LS.lmIntercept model) = Nothing-    | otherwise = Just (LS.modelToExpr model)-  where-    rows = careRowsFromFeatures (length carePoints) mats-    labels = careLabels carePoints-    model =-        LS.fitL1Logistic-            (solverConfigFor cfg labels)-            rows-            labels-            (V.fromList (map fst mats))-    weights = LS.lmWeights model--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).--}-classBalancedWeights :: VU.Vector Double -> Maybe (VU.Vector Double)-classBalancedWeights labels-    | nPos > 0 && nNeg > 0 = Just (VU.generate nCare weightAt)-    | otherwise = Nothing-  where-    nCare = VU.length labels-    nPos = VU.length (VU.filter (> 0) labels)-    nNeg = nCare - nPos-    weightAt i-        | 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).--}-degenerateHyperplane ::-    V.Vector (VU.Vector Double) -> VU.Vector Double -> Double -> Bool-degenerateHyperplane rows weights bias =-    nCare > 0 && (VU.minimum scores > 0 || VU.maximum scores < 0)-  where-    nCare = V.length rows-    scores =-        VU.generate-            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).--}-careRowsFromFeatures ::-    Int -> [(T.Text, VU.Vector Double)] -> V.Vector (VU.Vector Double)-careRowsFromFeatures nCare mats =-    V.generate nCare (\i -> VU.generate nFeat (\j -> snd (matsVec V.! j) VU.! i))-  where-    matsVec = V.fromList mats-    nFeat = V.length matsVec---- | Solver labels: @+1@ when 'GoLeft' is correct, @-1@ otherwise.-careLabels :: [CarePoint] -> VU.Vector Double-careLabels carePoints =-    VU.fromList [if cpCorrectDir cp == GoLeft then 1.0 else -1.0 | cp <- carePoints]---- | First column referenced by an expression, or a placeholder when none.-featName :: Expr b -> T.Text-featName expr = case getColumns expr of-    (c : _) -> c-    [] -> "<feat>"--{- | Replace missing values with the mean of present ones; 'Nothing' when-nothing is present so the caller can drop the feature.--}-imputeMean :: [Maybe Double] -> Maybe (VU.Vector Double)-imputeMean careRaw = case catMaybes careRaw of-    [] -> Nothing-    present -> Just (VU.fromList [fromMaybe (mean present) mv | mv <- careRaw])-  where-    mean xs = sum xs / fromIntegral (length xs)--interpretDoubleVals :: DataFrame -> Expr Double -> Maybe (V.Vector Double)-interpretDoubleVals df expr = case interpret @Double df expr of-    Right (TColumn column) -> either (const Nothing) Just (toVector @Double column)-    _ -> Nothing--interpretMaybeDoubleVals ::-    DataFrame -> Expr (Maybe Double) -> Maybe (V.Vector (Maybe Double))-interpretMaybeDoubleVals df expr = case interpret @(Maybe Double) df expr of-    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.--}-materializeFeatureForCare ::-    DataFrame -> [CarePoint] -> NumExpr -> Maybe (T.Text, VU.Vector Double)-materializeFeatureForCare df carePoints (NDouble expr) = do-    vals <- interpretDoubleVals df expr-    Just (featName expr, VU.fromList [vals V.! cpIndex cp | cp <- carePoints])-materializeFeatureForCare df carePoints (NMaybeDouble expr) = do-    vals <- interpretMaybeDoubleVals df expr-    imputed <- imputeMean [vals V.! cpIndex cp | cp <- carePoints]-    Just (featName expr, imputed)
src/DataFrame/DecisionTree/Model.hs view
@@ -1,6 +1,7 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-}  {- | sklearn-style standalone tree estimators returning inspectable records@@ -11,6 +12,7 @@ the classifier @Expr@. -} module DataFrame.DecisionTree.Model (+    module DataFrame.Model,     DecisionTreeClassifier (..),     DecisionTreeRegressor (..), ) where@@ -27,7 +29,7 @@ import DataFrame.Featurize.Internal (targetDoubles) import DataFrame.Internal.Column (Columnable) import DataFrame.Internal.Expression (Expr (..), getColumns)-import DataFrame.Model (Fit (..), Predict (..))+import DataFrame.Model  -- | A fitted classification tree with structural diagnostics. data DecisionTreeClassifier a = DecisionTreeClassifier@@ -48,7 +50,8 @@     }     deriving (Show) -instance (Columnable a, Ord a) => Fit TreeConfig (Expr a) (DecisionTreeClassifier a) where+instance (Columnable a, Ord a) => Fit TreeConfig (Expr a) where+    type ModelOf TreeConfig (Expr a) = (DecisionTreeClassifier a)     fit cfg target df =         DecisionTreeClassifier             e@@ -58,10 +61,12 @@       where         e = fitDecisionTree cfg target df -instance Predict (DecisionTreeClassifier a) a where+instance Predict (DecisionTreeClassifier a) where+    type Prediction (DecisionTreeClassifier a) = Expr a     predict = dtcExpr -instance Fit RegTreeConfig (Expr Double) DecisionTreeRegressor where+instance Fit RegTreeConfig (Expr Double) where+    type ModelOf RegTreeConfig (Expr Double) = DecisionTreeRegressor     fit cfg target df =         DecisionTreeRegressor             t@@ -82,7 +87,8 @@                     ("fit @DecisionTreeRegressor: target must be a column, got " ++ show target)         e = treeToExpr t -instance Predict DecisionTreeRegressor Double where+instance Predict DecisionTreeRegressor where+    type Prediction DecisionTreeRegressor = Expr Double     predict = dtrExpr  usageCounts :: [T.Text] -> M.Map T.Text Int
− src/DataFrame/DecisionTree/Numeric.hs
@@ -1,256 +0,0 @@-{-# LANGUAGE AllowAmbiguousTypes #-}-{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE GADTs #-}-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TypeApplications #-}--{- | Numeric split candidates: per-column Double expressions, arithmetic-expansion, and threshold conditions. 'numericCondVecs' materializes the-pool with a single interpret per distinct expression, deriving every-threshold/operator truth vector by direct comparison.--}-module DataFrame.DecisionTree.Numeric (-    NumExpr (..),-    numExprCols,-    numExprEq,-    combineNumExprs,-    numericConditions,-    generateNumericConds,-    percentilesOf,-    numericCondVecs,-    numericExprsWithTerms,-    numericCols,-) where--import DataFrame.DecisionTree.CondVec (CondVec (..))-import DataFrame.DecisionTree.Types (SynthConfig (..), TreeConfig (..))-import qualified DataFrame.Functions as F-import DataFrame.Internal.Column-import DataFrame.Internal.DataFrame (DataFrame, columnNames, unsafeGetColumn)-import DataFrame.Internal.Expression (Expr (..), eqExpr, getColumns, normalize)-import DataFrame.Internal.Interpreter (interpret)-import DataFrame.Internal.Types-import DataFrame.Operators--import Data.List (sort)-import Data.Maybe (fromMaybe)-import qualified Data.Set as Set-import qualified Data.Text as T-import Data.Type.Equality (testEquality, (:~:) (..))-import qualified Data.Vector as V-import qualified Data.Vector.Unboxed as VU-import Type.Reflection (typeRep)---- | A numeric feature expression, non-nullable or nullable.-data NumExpr-    = NDouble !(Expr Double)-    | NMaybeDouble !(Expr (Maybe Double))--numExprCols :: NumExpr -> [T.Text]-numExprCols (NDouble e) = getColumns e-numExprCols (NMaybeDouble e) = getColumns e--numExprEq :: NumExpr -> NumExpr -> Bool-numExprEq (NDouble e1) (NDouble e2) = eqExpr e1 e2-numExprEq (NMaybeDouble e1) (NMaybeDouble e2) = eqExpr e1 e2-numExprEq _ _ = False---- | Safe division: @0@ (or @Nothing@) where the divisor is zero.-safeDivD :: Expr Double -> Expr Double -> Expr Double-safeDivD a b = F.ifThenElse (b ./= F.lit (0 :: Double)) (a ./ b) (F.lit (0 :: Double))--safeDivMaybe :: Expr Bool -> Expr (Maybe Double) -> Expr (Maybe Double)-safeDivMaybe nonZero q = F.ifThenElse nonZero q (F.lit (Nothing :: Maybe Double))---- | Arithmetic combinations (@+@, @-@, @*@, safe @/@) of two numeric exprs.-combineNumExprs :: NumExpr -> NumExpr -> [NumExpr]-combineNumExprs (NDouble e1) (NDouble e2) =-    map NDouble [e1 .+ e2, e1 .- e2, e1 .* e2, safeDivD e1 e2]-combineNumExprs (NDouble e1) (NMaybeDouble e2) =-    map-        NMaybeDouble-        [ e1 .+ e2-        , e1 .- e2-        , e1 .* e2-        , safeDivMaybe (F.fromMaybe False (e2 ./= F.lit (0 :: Double))) (e1 ./ e2)-        ]-combineNumExprs (NMaybeDouble e1) (NDouble e2) =-    map-        NMaybeDouble-        [ e1 .+ e2-        , e1 .- e2-        , e1 .* e2-        , safeDivMaybe (e2 ./= F.lit (0 :: Double)) (e1 ./ e2)-        ]-combineNumExprs (NMaybeDouble e1) (NMaybeDouble e2) =-    map-        NMaybeDouble-        [ e1 .+ e2-        , e1 .- e2-        , e1 .* e2-        , safeDivMaybe (F.fromMaybe False (e2 ./= F.lit (0 :: Double))) (e1 ./ e2)-        ]--numericConditions :: TreeConfig -> DataFrame -> [Expr Bool]-numericConditions = generateNumericConds--generateNumericConds :: TreeConfig -> DataFrame -> [Expr Bool]-generateNumericConds cfg df = do-    expr <- numericExprsWithTerms (synthConfig cfg) df-    threshold <- numericThresholds cfg df expr-    condsFromExpr expr threshold--numericThresholds :: TreeConfig -> DataFrame -> NumExpr -> [Double]-numericThresholds cfg df (NDouble e) = thresholdsForExpr cfg df e-numericThresholds cfg df (NMaybeDouble e) = thresholdsForExpr cfg df (F.fromMaybe 0 e)--thresholdsForExpr :: TreeConfig -> DataFrame -> Expr Double -> [Double]-thresholdsForExpr cfg df e =-    maybe [] (percentilesOf (percentiles cfg) . V.toList) (interpretDoubleCol df e)--condsFromExpr :: NumExpr -> Double -> [Expr Bool]-condsFromExpr (NDouble e) t = [e .<= F.lit t, e .>= F.lit t, e .< F.lit t, e .> F.lit t]-condsFromExpr (NMaybeDouble e) t =-    map-        (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.--}-percentilesOf :: [Int] -> [Double] -> [Double]-percentilesOf ps valsList-    | n == 0 = []-    | otherwise = map (\p -> sortedV V.! min (n - 1) (max 0 (p * n `div` 100))) ps-  where-    !sortedV = V.fromList (sort valsList)-    !n = V.length sortedV--interpretDoubleCol :: DataFrame -> Expr Double -> Maybe (V.Vector Double)-interpretDoubleCol df e = case interpret @Double df e of-    Right (TColumn column) -> either (const Nothing) Just (toVector @Double column)-    _ -> Nothing--interpretMaybeDoubleCol ::-    DataFrame -> Expr (Maybe Double) -> Maybe (V.Vector (Maybe Double))-interpretMaybeDoubleCol df e = case interpret @(Maybe Double) df e of-    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, but-avoids re-interpreting each LHS per threshold and operator.--}-numericCondVecs :: TreeConfig -> DataFrame -> DataFrame -> [CondVec]-numericCondVecs cfg dfGen df = concatMap forExpr (numericExprsWithTerms (synthConfig cfg) dfGen)-  where-    forExpr (NDouble e) = maybe [] (condsForDouble cfg e) (interpretDoubleCol df e)-    forExpr (NMaybeDouble e) = maybe [] (condsForMaybe cfg e) (interpretMaybeDoubleCol df e)--condsForDouble :: TreeConfig -> Expr Double -> V.Vector Double -> [CondVec]-condsForDouble cfg e vals = concatMap (doubleCondsAt e vals (V.length vals)) ts-  where-    ts = percentilesOf (percentiles cfg) (V.toList vals)--doubleCondsAt :: Expr Double -> V.Vector Double -> Int -> Double -> [CondVec]-doubleCondsAt e vals n t =-    [ CondVec (e .<= F.lit t) (gen (<= t))-    , CondVec (e .>= F.lit t) (gen (>= t))-    , CondVec (e .< F.lit t) (gen (< t))-    , CondVec (e .> F.lit t) (gen (> t))-    ]-  where-    gen p = VU.generate n (\i -> p (vals V.! i))--condsForMaybe ::-    TreeConfig -> Expr (Maybe Double) -> V.Vector (Maybe Double) -> [CondVec]-condsForMaybe cfg e mvals = concatMap (maybeCondsAt e mvals (V.length mvals)) ts-  where-    ts = percentilesOf (percentiles cfg) (map (fromMaybe 0) (V.toList mvals))--maybeCondsAt ::-    Expr (Maybe Double) -> V.Vector (Maybe Double) -> Int -> Double -> [CondVec]-maybeCondsAt e mvals n t =-    [ CondVec (F.fromMaybe False (e .<= F.lit t)) (gen (<= t))-    , CondVec (F.fromMaybe False (e .>= F.lit t)) (gen (>= t))-    , CondVec (F.fromMaybe False (e .< F.lit t)) (gen (< t))-    , CondVec (F.fromMaybe False (e .> F.lit t)) (gen (> t))-    ]-  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.--}-numericExprsWithTerms :: SynthConfig -> DataFrame -> [NumExpr]-numericExprsWithTerms cfg df-    | not (enableArithOps cfg) = base-    | otherwise =-        base ++ expandRounds cfg base (max 0 (maxExprDepth cfg - 1)) base seen0-  where-    base = numericCols df-    seen0 = Set.fromList (map keyNum base)--keyNum :: NumExpr -> String-keyNum (NDouble e) = show (normalize e)-keyNum (NMaybeDouble e) = show (normalize e)--isDisallowed :: SynthConfig -> NumExpr -> NumExpr -> Bool-isDisallowed cfg e1 e2 =-    any (\(l, r) -> l `elem` cols && r `elem` cols) (disallowedCombinations cfg)-  where-    cols = numExprCols e1 <> numExprCols e2--roundProducts :: SynthConfig -> [NumExpr] -> [NumExpr] -> [NumExpr]-roundProducts cfg frontier base =-    [ c-    | e1 <- frontier-    , e2 <- base-    , not (numExprEq e1 e2)-    , not (isDisallowed cfg e1 e2)-    , c <- combineNumExprs e1 e2-    ]--expandRounds ::-    SynthConfig -> [NumExpr] -> Int -> [NumExpr] -> Set.Set String -> [NumExpr]-expandRounds _ _ 0 _ _ = []-expandRounds cfg base d frontier seen-    | null admitted = []-    | otherwise = admitted ++ expandRounds cfg base (d - 1) admitted seen'-  where-    (admitted, seen') = admitNovel seen (roundProducts cfg frontier base)--admitNovel :: Set.Set String -> [NumExpr] -> ([NumExpr], Set.Set String)-admitNovel seen0 = go seen0 []-  where-    go seen acc [] = (reverse acc, seen)-    go seen acc (c : cs)-        | keyNum c `Set.member` seen = go seen acc cs-        | otherwise = go (Set.insert (keyNum c) seen) (c : acc) cs--numericCols :: DataFrame -> [NumExpr]-numericCols df = concatMap (numExprsOfColumn df) (columnNames df)--numExprsOfColumn :: DataFrame -> T.Text -> [NumExpr]-numExprsOfColumn df colName = case unsafeGetColumn colName df of-    UnboxedColumn Nothing (_ :: VU.Vector b) -> strictNumeric @b colName-    BoxedColumn (Just _) (_ :: V.Vector b) -> nullableNumeric @b colName-    UnboxedColumn (Just _) (_ :: VU.Vector b) -> nullableNumeric @b colName-    _ -> []--strictNumeric :: forall b. (Columnable b) => T.Text -> [NumExpr]-strictNumeric c = case testEquality (typeRep @b) (typeRep @Double) of-    Just Refl -> [NDouble (Col c)]-    Nothing -> case sIntegral @b of-        STrue -> [NDouble (F.toDouble (Col @b c))]-        SFalse -> []--nullableNumeric :: forall b. (Columnable b) => T.Text -> [NumExpr]-nullableNumeric c = case testEquality (typeRep @b) (typeRep @Double) of-    Just Refl -> [NMaybeDouble (Col @(Maybe b) c)]-    Nothing -> case sIntegral @b of-        STrue -> [NMaybeDouble (F.whenPresent (realToFrac @b @Double) (Col @(Maybe b) c))]-        SFalse -> []
− src/DataFrame/DecisionTree/Pool.hs
@@ -1,224 +0,0 @@-{-# 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.--}-module DataFrame.DecisionTree.Pool (-    evalWithPenaltyVec,-    primaryColExpr,-    primaryColCV,-    takeDiverse,-    candidateParChunk,-    bestDiscreteCandidate,-    boolExprsVec,-    DedupMode (..),-    saturateCandidates,-    roundProducts,-    admitKeys,-    admitVecs,-    dedupCVByExpr,-    nubByExpr,-) where--import DataFrame.DecisionTree.CondVec (-    CondVec (..),-    combineAndVec,-    combineOrVec,-    countErrorsByVec,- )-import DataFrame.DecisionTree.Types (-    CarePoint,-    SynthConfig (..),-    TreeConfig (..),- )-import DataFrame.Internal.Expression (-    Expr,-    compareExpr,-    eSize,-    eqExpr,-    getColumns,-    normalize,- )--import Control.Parallel.Strategies (parListChunk, rdeepseq, using)-import Data.Function (on)-import Data.List (minimumBy, sortBy)-import qualified Data.Map.Strict as M-import qualified Data.Set as Set-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.--}-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.--}-primaryColExpr :: Expr Bool -> T.Text-primaryColExpr e = case getColumns e of-    [] -> "<noncol>"-    (c : _) -> c--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).--}-takeDiverse :: Int -> Maybe Int -> (a -> T.Text) -> [a] -> [a]-takeDiverse k Nothing _ = take k-takeDiverse k (Just quota) primary = go M.empty 0-  where-    go !_ !_ [] = []-    go !seen !n (x : xs)-        | n >= k = []-        | M.findWithDefault 0 col seen >= quota = go seen n xs-        | otherwise = x : go (M.insertWith (+) col 1 seen) (n + 1) xs-      where-        !col = primary x--{- | Chunk size for the parallel per-node candidate scans; tuned by an -N-sweep, not correctness-affecting.--}-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 :: (CondVec -> (Int, Int)) -> [CondVec] -> [((Int, Int), CondVec)]-decorate penaltyCV xs = zip (map penaltyCV xs `using` parListChunk candidateParChunk rdeepseq) xs---- | The diverse top-@expressionPairs@ valid candidates by penalty.-sortedTopK :: TreeConfig -> (CondVec -> (Int, Int)) -> [CondVec] -> [CondVec]-sortedTopK cfg penaltyCV validCondVecs =-    map-        snd-        ( takeDiverse-            (expressionPairs cfg)-            (perColumnQuota (synthConfig cfg))-            (primaryColCV . snd)-            sorted-        )-  where-    sorted = sortBy (compare `on` fst) (decorate penaltyCV validCondVecs)---- | Lowest-penalty candidate after boolean saturation of the diverse top-K.-bestDiscreteCandidate ::-    TreeConfig -> (CondVec -> (Int, Int)) -> [CondVec] -> Maybe CondVec-bestDiscreteCandidate _ _ [] = Nothing-bestDiscreteCandidate cfg penaltyCV validCondVecs =-    case saturateCandidates-        Structural-        (boolExpansion (synthConfig cfg))-        (sortedTopK cfg penaltyCV validCondVecs) of-        [] -> 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).--}-boolExprsVec :: [CondVec] -> [CondVec] -> Int -> Int -> [CondVec]-boolExprsVec baseExprs prevExprs depth maxDepth-    | depth == 0 =-        baseExprs ++ boolExprsVec baseExprs prevExprs (depth + 1) maxDepth-    | depth >= maxDepth = []-    | otherwise = combined ++ boolExprsVec baseExprs combined (depth + 1) maxDepth-  where-    combined = roundProducts prevExprs baseExprs--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).--}-saturateCandidates :: DedupMode -> Int -> [CondVec] -> [CondVec]-saturateCandidates Structural maxDepth base = base' ++ go 1 base' seen0-  where-    (base', seen0) = admitKeys Set.empty base-    go !depth frontier seen-        | depth >= maxDepth || null frontier = []-        | otherwise =-            let (admitted, seen') = admitKeys seen (roundProducts frontier base)-             in admitted ++ go (depth + 1) admitted seen'-saturateCandidates TruthVector maxDepth base = M.elems (go 1 frontier0 reps0)-  where-    (reps0, frontier0) = admitVecs M.empty base-    go !depth frontier reps-        | depth >= maxDepth || null frontier = reps-        | otherwise =-            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).--}-roundProducts :: [CondVec] -> [CondVec] -> [CondVec]-roundProducts frontier base =-    [ c-    | e1 <- frontier-    , e2 <- base-    , not (eqExpr (cvExpr e1) (cvExpr e2))-    , c <- [combineAndVec e1 e2, combineOrVec e1 e2]-    ]---- | Admit candidates with a not-yet-seen normalized form, preserving order.-admitKeys :: Set.Set String -> [CondVec] -> ([CondVec], Set.Set String)-admitKeys = go []-  where-    go acc seen [] = (reverse acc, seen)-    go acc !seen (c : cs)-        | structuralKey c `Set.member` seen = go acc seen cs-        | otherwise = go (c : acc) (Set.insert (structuralKey c) seen) cs--structuralKey :: CondVec -> String-structuralKey = show . normalize . cvExpr--{- | Admit candidates by distinct truth vector, keeping the smallest-expression-representative per vector.--}-admitVecs ::-    M.Map (VU.Vector Bool) CondVec ->-    [CondVec] ->-    (M.Map (VU.Vector Bool) CondVec, [CondVec])-admitVecs = go []-  where-    go acc reps [] = (reps, reverse acc)-    go acc !reps (c : cs) = case M.lookup (cvVec c) reps of-        Nothing -> go (c : acc) (M.insert (cvVec c) c reps) cs-        Just r -> go acc (M.insert (cvVec c) (smaller r c) reps) cs--smaller :: CondVec -> CondVec -> CondVec-smaller a b = case compare (eSize (cvExpr a)) (eSize (cvExpr b)) of-    LT -> a-    GT -> b-    EQ -> if compareExpr (cvExpr a) (cvExpr b) /= GT then a else b---- | Deduplicate 'CondVec's by normalized 'cvExpr', keeping the first.-dedupCVByExpr :: [CondVec] -> [CondVec]-dedupCVByExpr = go Set.empty-  where-    go _ [] = []-    go seen (cv : cvs)-        | structuralKey cv `Set.member` seen = go seen cvs-        | otherwise = cv : go (Set.insert (structuralKey cv) seen) cvs---- | Deduplicate expressions by normalized form, keeping the first.-nubByExpr :: [Expr Bool] -> [Expr Bool]-nubByExpr = go Set.empty-  where-    go _ [] = []-    go seen (e : es)-        | k `Set.member` seen = go seen es-        | otherwise = e : go (Set.insert k seen) es-      where-        k = show (normalize e)
− src/DataFrame/DecisionTree/Predict.hs
@@ -1,216 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# 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.--}-module DataFrame.DecisionTree.Predict (-    predictWithTree,-    predictManyWithTree,-    predictManyWithTreeCached,-    identifyCarePoints,-    identifyCarePointsCached,-    countCarePointErrors,-    partitionIndices,-    partitionIndicesCached,-    majorityValueFromIndices,-    computeTreeLoss,-    computeTreeLossCached,-    isValidAtNode,-) where--import DataFrame.DecisionTree.CondVec (-    CondCache,-    countErrorsByVec,-    lookupCondVec,- )-import DataFrame.DecisionTree.Types (-    CarePoint (..),-    Direction (..),-    Tree (..),-    TreeConfig (..),- )-import DataFrame.Internal.Column (Columnable, TypedColumn (..), toVector)-import DataFrame.Internal.DataFrame (DataFrame)-import DataFrame.Internal.Expression (Expr (..))-import DataFrame.Internal.Interpreter (interpret)--import Control.Exception (throw)-import Control.Monad.ST (ST)-import Data.Function (on)-import Data.List (maximumBy)-import qualified Data.Map.Strict as M-import qualified Data.Text as T-import qualified Data.Vector as V-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).--}-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)-    _ -> Nothing---- | The target column as a label vector, or 'Nothing' on failure.-interpretLabelCol ::-    forall a. (Columnable a) => DataFrame -> T.Text -> Maybe (V.Vector a)-interpretLabelCol df target = case interpret @a df (Col target) of-    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).-predictWithTree ::-    forall a. (Columnable a) => T.Text -> DataFrame -> Int -> Tree a -> a-predictWithTree _ _ _ (Leaf v) = v-predictWithTree target df idx (Branch cond left right) =-    predictWithTree @a target df idx (childFor cond left right idx df)--childFor :: Expr Bool -> Tree a -> Tree a -> Int -> DataFrame -> Tree a-childFor cond left right idx df = case branchBool df cond of-    Nothing -> left-    Just boolVals -> if boolVals VU.! idx then left else right--predictManyWithTree ::-    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.--}-predictManyWithTreeCached ::-    forall a.-    (Columnable a) => CondCache -> Tree a -> DataFrame -> V.Vector Int -> V.Vector a-predictManyWithTreeCached cache tree df indices = V.create $ do-    mv <- VM.new (V.length indices)-    fill mv (V.zip (V.enumFromN 0 (V.length indices)) indices) tree-    pure mv-  where-    fill :: VM.MVector s a -> V.Vector (Int, Int) -> Tree a -> ST s ()-    fill mv prs (Leaf v) = V.mapM_ (\(p, _) -> VM.write mv p v) prs-    fill mv prs (Branch cond left right) = case lookupCondVec cache df cond of-        Nothing -> fill mv prs left-        Just boolVals -> fillSplit mv (V.partition (\(_, i) -> boolVals VU.! i) prs) left right--    fillSplit ::-        VM.MVector s a ->-        (V.Vector (Int, Int), V.Vector (Int, Int)) ->-        Tree a ->-        Tree a ->-        ST s ()-    fillSplit mv (leftPrs, rightPrs) left right = fill mv leftPrs left >> fill mv rightPrs right--identifyCarePoints ::-    forall a.-    (Columnable a) =>-    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.--}-identifyCarePointsCached ::-    forall a.-    (Columnable a) =>-    CondCache ->-    T.Text ->-    DataFrame ->-    V.Vector Int ->-    Tree a ->-    Tree a ->-    [CarePoint]-identifyCarePointsCached cache target df indices leftTree rightTree =-    maybe [] carePoints (interpretLabelCol @a df target)-  where-    leftPreds = predictManyWithTreeCached cache leftTree df indices-    rightPreds = predictManyWithTreeCached cache rightTree df indices-    carePoints targetVals = V.toList (V.imapMaybe (checkPoint targetVals leftPreds rightPreds) indices)--checkPoint ::-    (Eq a) =>-    V.Vector a -> V.Vector a -> V.Vector a -> Int -> Int -> Maybe CarePoint-checkPoint targetVals leftPreds rightPreds k idx =-    case (leftPreds V.! k == trueLabel, rightPreds V.! k == trueLabel) of-        (True, False) -> Just (CarePoint idx GoLeft)-        (False, True) -> Just (CarePoint idx GoRight)-        _ -> Nothing-  where-    trueLabel = targetVals V.! idx---- | Care points a free condition misroutes (uncached; for the linear path).-countCarePointErrors :: Expr Bool -> DataFrame -> [CarePoint] -> Int-countCarePointErrors cond df carePoints =-    maybe (length carePoints) (`countErrorsByVec` carePoints) (branchBool df cond)--partitionIndices ::-    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).--}-partitionIndicesCached ::-    CondCache ->-    Expr Bool ->-    DataFrame ->-    V.Vector Int ->-    (V.Vector Int, V.Vector Int)-partitionIndicesCached cache cond df indices = case lookupCondVec cache df cond of-    Nothing -> (indices, V.empty)-    Just boolVals -> V.partition (boolVals VU.!) indices---- | A split is valid at a node when both children keep at least 'minLeafSize'.-isValidAtNode :: TreeConfig -> DataFrame -> V.Vector Int -> Expr Bool -> Bool-isValidAtNode cfg df indices c =-    V.length t >= minLeafSize cfg && V.length f >= minLeafSize cfg-  where-    (t, f) = partitionIndices c df indices--majorityValueFromIndices ::-    forall a. (Columnable a, Ord a) => T.Text -> DataFrame -> V.Vector Int -> a-majorityValueFromIndices target df indices = majorityOf (countLabels (labelColOrThrow @a df target) indices)--labelColOrThrow :: forall a. (Columnable a) => DataFrame -> T.Text -> V.Vector a-labelColOrThrow df target = case interpret @a df (Col target) of-    Left e -> throw e-    Right (TColumn column) -> either throw id (toVector @a column)--countLabels :: (Ord a) => V.Vector a -> V.Vector Int -> M.Map a Int-countLabels vals = V.foldl' (\acc i -> M.insertWith (+) (vals V.! i) (1 :: Int) acc) M.empty--majorityOf :: M.Map a Int -> a-majorityOf counts-    | M.null counts = error "Empty indices in majorityValueFromIndices"-    | otherwise = fst (maximumBy (compare `on` snd) (M.toList counts))--computeTreeLoss ::-    forall a.-    (Columnable a) => T.Text -> DataFrame -> V.Vector Int -> Tree a -> Double-computeTreeLoss = computeTreeLossCached @a M.empty---- | 0/1 loss of a tree over @indices@, with a 'CondCache' for the predictions.-computeTreeLossCached ::-    forall a.-    (Columnable a) =>-    CondCache -> T.Text -> DataFrame -> V.Vector Int -> Tree a -> Double-computeTreeLossCached cache target df indices tree-    | V.null indices = 0-    | otherwise =-        maybe 1.0 (treeLoss cache tree df indices) (interpretLabelCol @a df target)--treeLoss ::-    (Columnable a) =>-    CondCache -> Tree a -> DataFrame -> V.Vector Int -> V.Vector a -> Double-treeLoss cache tree df indices targetVals =-    fromIntegral (countMismatches targetVals indices preds)-        / fromIntegral (V.length indices)-  where-    preds = predictManyWithTreeCached cache tree df indices--countMismatches :: (Eq a) => V.Vector a -> V.Vector Int -> V.Vector a -> Int-countMismatches targetVals indices preds =-    V.length-        (V.ifilter (\k _ -> targetVals V.! (indices V.! k) /= preds V.! k) preds)
− src/DataFrame/DecisionTree/Prune.hs
@@ -1,66 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# 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.--}-module DataFrame.DecisionTree.Prune (-    pruneDead,-    treeEq,-    pruneExpr,-) where--import DataFrame.DecisionTree.Types (Tree (..))-import DataFrame.Internal.Column (Columnable)-import DataFrame.Internal.Expression (Expr (..), eqExpr)-import DataFrame.Internal.Simplify (PredFact, entails, factFalse, 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.--}-pruneDead :: forall a. (Columnable a) => Tree a -> Tree a-pruneDead = go []-  where-    go :: [PredFact] -> Tree a -> Tree a-    go _ (Leaf v) = Leaf v-    go facts (Branch cond left right) = case entails facts cond of-        Just True -> go facts left-        Just False -> go facts right-        Nothing ->-            reconcile-                cond-                (go (addFact (factTrue cond) facts) left)-                (go (addFact (factFalse cond) facts) right)--reconcile :: (Columnable a) => Expr Bool -> Tree a -> Tree a -> Tree a-reconcile cond left right-    | treeEq left right = left-    | otherwise = Branch cond left right--addFact :: Maybe PredFact -> [PredFact] -> [PredFact]-addFact (Just f) fs = f : fs-addFact Nothing fs = fs--treeEq :: (Columnable a) => Tree a -> Tree a -> Bool-treeEq (Leaf x) (Leaf y) = x == y-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.--}-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)-pruneExpr (Binary op l r) = Binary op (pruneExpr l) (pruneExpr r)-pruneExpr e = e--collapseIf :: (Columnable a) => Expr Bool -> Expr a -> Expr a -> Expr a-collapseIf cond t f-    | eqExpr t f = t-    | If ci ti _ <- t, eqExpr cond ci = If cond ti f-    | If ci _ fi <- f, eqExpr cond ci = If cond t fi-    | otherwise = If cond t f
src/DataFrame/DecisionTree/Regression.hs view
@@ -1,24 +1,23 @@ {-# LANGUAGE BangPatterns #-} {-# LANGUAGE ScopedTypeVariables #-} -{- | Variance-reduction (weighted-SSE) regression trees, reusing the CART-feature machinery. Leaves predict the (weighted) mean of their rows. The-matrix-level 'fitRegTreeOn' lets gradient boosting refit on residuals without-re-extracting features each round.+{- | 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. -} module DataFrame.DecisionTree.Regression (     RegTreeConfig (..),     defaultRegTreeConfig,+    -- | Implementation verb used by the fit\/predict instances and boosting.     fitRegTreeOn, ) where -import Data.Function (on)+import Control.Parallel (par, pseq) import Data.Maybe (maybeToList) import qualified Data.Vector as V-import qualified Data.Vector.Algorithms.Merge as VA import qualified Data.Vector.Unboxed as VU -import DataFrame.DecisionTree.Cart (CartFeature (..))+import DataFrame.DecisionTree.Cart (CartFeature (..), sortIndicesByValue) import DataFrame.DecisionTree.Types (Tree (..))  -- | Stopping criteria for the regression tree.@@ -48,97 +47,95 @@     VU.Vector Double ->     Maybe (VU.Vector Double) ->     Tree Double-fitRegTreeOn cfg feats y mw = go 0 (VU.enumFromN 0 n)+fitRegTreeOn cfg feats y mw = buildNode 0 (VU.enumFromN 0 n) featSorted   where     n = VU.length y-    wt i = maybe 1 (VU.! i) mw-    go depth idxs-        | VU.length idxs < rtMinSamplesSplit cfg-            || depth >= rtMaxDepth cfg =-            Leaf (nodeMean idxs)-        | otherwise = case bestSplit idxs of-            Nothing -> Leaf (nodeMean idxs)-            Just (fj, thr) ->-                let vals = cfValues (feats V.! fj)-                    (lefts, rights) = VU.partition (\i -> vals VU.! i <= thr) idxs-                 in if VU.null lefts || VU.null rights-                        then Leaf (nodeMean idxs)-                        else-                            Branch-                                (cfPred (feats V.! fj) thr)-                                (go (depth + 1) lefts)-                                (go (depth + 1) rights)-    nodeMean idxs =-        let (sw, sy) = VU.foldl' (\(!a, !b) i -> (a + wt i, b + wt i * (y VU.! i))) (0, 0) idxs-         in if sw == 0 then 0 else sy / sw-    bestSplit idxs =-        let (totW, totSY, totSY2) = moments idxs-            nodeSSE = totSY2 - safeDiv (totSY * totSY) totW-            candidates =-                [ (red, fj, thr)-                | fj <- [0 .. V.length feats - 1]-                , (thr, red) <- featureSplits idxs fj totW totSY totSY2 nodeSSE-                ]-         in case candidates of-                [] -> Nothing-                _ ->-                    let (red, fj, thr) = maximumByFst candidates-                     in if red >= rtMinImpurityDecrease cfg && red > 0-                            then Just (fj, thr)-                            else Nothing-    featureSplits idxs fj totW totSY totSY2 nodeSSE =-        let vals = cfValues (feats V.! fj)-            sorted = sortByVal vals idxs-         in sweep sorted vals totW totSY totSY2 nodeSSE-    sweep sorted vals totW totSY totSY2 nodeSSE = go0 0 0 0 0 Nothing+    weightAt i = maybe 1 (VU.! i) mw+    featSorted = V.map (sortIndicesByValue . cfValues) feats++    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)++    splitNode depth idxs sortedByFeat (fj, thr)+        | VU.null lefts || VU.null rights = Leaf (weightedMean idxs)+        | otherwise =+            forceTree l `par` (forceTree r `pseq` Branch (cfPred (feats V.! fj) thr) l r)+      where+        vals = cfValues (feats V.! fj)+        goesLeft i = 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)+        r =+            buildNode (depth + 1) rights (V.map (VU.filter (not . goesLeft)) sortedByFeat)++    weightedMean idxs =+        let (w, sy) = VU.foldl' step (0, 0) idxs+            step (!a, !b) i = (a + weightAt i, b + weightAt i * (y VU.! i))+         in if w == 0 then 0 else sy / w++    bestSplit idxs sortedByFeat+        | null candidates = Nothing+        | red > 0 && red >= rtMinImpurityDecrease cfg = Just (fj, thr)+        | otherwise = Nothing+      where+        (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++    bestThreshold fj sorted totW totSY totSY2 nodeSSE = maybeToList (go 0 0 0 0 Nothing)+      where+        vals = cfValues (feats V.! fj)         m = VU.length sorted-        go0 !k !wl !syl !syl2 best-            | k >= m - 1 = maybeToList best-            | otherwise =-                let i = sorted VU.! k-                    wi = wt i-                    yi = y VU.! i-                    wl' = wl + wi-                    syl' = syl + wi * yi-                    syl2' = syl2 + wi * yi * yi-                    vCur = vals VU.! i-                    vNext = vals VU.! (sorted VU.! (k + 1))-                    nl = k + 1-                    nr = m - nl-                    wr = totW - wl'-                    valid =-                        vCur /= vNext-                            && nl >= rtMinLeafSize cfg-                            && nr >= rtMinLeafSize cfg-                            && wl' > 0-                            && wr > 0-                    red =-                        nodeSSE-                            - ( (syl2' - safeDiv (syl' * syl') wl')-                                    + ( (totSY2 - syl2')-                                            - safeDiv ((totSY - syl') * (totSY - syl')) wr-                                      )-                              )-                    thr = (vCur + vNext) / 2-                    best' =-                        if valid && maybe True (\(_, b) -> red > b) best-                            then Just (thr, red)-                            else best-                 in go0 (k + 1) wl' syl' syl2' best'-    moments =-        VU.foldl'-            ( \(!w, !sy, !sy2) i ->-                let wi = wt i; yi = y VU.! i-                 in (w + wi, sy + wi * yi, sy2 + wi * yi * yi)-            )-            (0, 0, 0)+        go !k !wl !syl !syl2 best+            | k >= m - 1 = 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)+            best'+                | splittable && maybe True ((reduction >) . snd) best =+                    Just ((vals VU.! i + vals VU.! next) / 2, reduction)+                | otherwise = best +    moments = VU.foldl' step (0, 0, 0)+      where+        step (!w, !sy, !sy2) i =+            let wi = weightAt i; yi = y VU.! i+             in (w + wi, sy + wi * yi, sy2 + wi * yi * yi)+ safeDiv :: Double -> Double -> Double safeDiv a b = if b == 0 then 0 else a / b -sortByVal :: VU.Vector Double -> VU.Vector Int -> VU.Vector Int-sortByVal vals = VU.modify (VA.sortBy (compare `on` (vals VU.!)))+-- | Weighted SSE of a node from its Σy, Σy², and total weight: @Σy² − (Σy)²/w@.+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  maximumByFst :: (Ord a) => [(a, b, c)] -> (a, b, c) maximumByFst = foldr1 (\x@(a, _, _) y@(b, _, _) -> if a >= b then x else y)
− src/DataFrame/DecisionTree/Tao.hs
@@ -1,266 +0,0 @@-{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE FlexibleContexts #-}-{-# 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.--}-module DataFrame.DecisionTree.Tao (-    taoOptimize,-    taoOptimizeCV,-    taoIteration,-    taoIterationCV,-    optimizeNode,-    findBestSplitTAO,-) where--import DataFrame.DecisionTree.CondVec-import DataFrame.DecisionTree.Linear (bestLinearCandidate)-import DataFrame.DecisionTree.Pool (-    bestDiscreteCandidate,-    candidateParChunk,-    evalWithPenaltyVec,- )-import DataFrame.DecisionTree.Predict-import DataFrame.DecisionTree.Prune (pruneDead)-import DataFrame.DecisionTree.Types-import DataFrame.Internal.Column (Columnable)-import DataFrame.Internal.DataFrame (DataFrame)-import DataFrame.Internal.Expression (Expr)--import Control.Parallel (par, pseq)-import Control.Parallel.Strategies (parListChunk, rdeepseq, using)-import Data.Function (on)-import Data.List (foldl', 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).--}-data TaoEnv = TaoEnv-    { teCache :: !CondCache-    , teCfg :: !TreeConfig-    , teTarget :: !T.Text-    , teConds :: ![CondVec]-    , teDf :: !DataFrame-    }---- | Public TAO entry point over raw conditions; materializes each once.-taoOptimize ::-    forall a.-    (Columnable a, Ord a) =>-    TreeConfig ->-    T.Text ->-    [Expr Bool] ->-    DataFrame ->-    V.Vector Int ->-    Tree a ->-    Tree a-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.--}-taoOptimizeCV ::-    forall a.-    (Columnable a, Ord a) =>-    TreeConfig ->-    T.Text ->-    [CondVec] ->-    DataFrame ->-    V.Vector Int ->-    Tree a ->-    Tree a-taoOptimizeCV cfg target condVecs df rootIndices initialTree =-    go 0 initialTree (lossWith baseCache initialTree)-  where-    baseCache = condCacheFromVecs condVecs-    lossWith cache = computeTreeLossCached @a cache target df rootIndices-    go iter tree prevLoss-        | iter >= taoIterations cfg = pruneDead tree-        | prevLoss - newLoss < taoConvergenceTol cfg = pruneDead tree'-        | otherwise = go (iter + 1) tree' newLoss-      where-        cache = addTreeCondsToCache df tree baseCache-        tree' = taoIterationCV @a cache cfg target condVecs df rootIndices tree-        newLoss = lossWith cache tree'---- | Public single-iteration entry point.-taoIteration ::-    forall a.-    (Columnable a, Ord a) =>-    TreeConfig ->-    T.Text ->-    [Expr Bool] ->-    DataFrame ->-    V.Vector Int ->-    Tree a ->-    Tree a-taoIteration cfg target conds df rootIndices tree =-    let condVecs = mapMaybe (materializeCondVec df) conds-        cache = addTreeCondsToCache df tree (condCacheFromVecs condVecs)-     in taoIterationCV @a cache cfg target condVecs df rootIndices tree---- | One bottom-to-top sweep: re-optimize every node level by level.-taoIterationCV ::-    forall a.-    (Columnable a, Ord a) =>-    CondCache ->-    TreeConfig ->-    T.Text ->-    [CondVec] ->-    DataFrame ->-    V.Vector Int ->-    Tree a ->-    Tree a-taoIterationCV cache cfg target condVecs df rootIndices tree =-    foldl'-        (optimizeDepthLevel env rootIndices)-        tree-        [treeDepth tree, treeDepth tree - 1 .. 0]-  where-    env = TaoEnv cache cfg target condVecs df--optimizeDepthLevel ::-    forall a.-    (Columnable a, Ord a) => TaoEnv -> V.Vector Int -> Tree a -> Int -> Tree a-optimizeDepthLevel env rootIndices tree = optimizeAtDepth @a env rootIndices tree 0--optimizeAtDepth ::-    forall a.-    (Columnable a, Ord a) =>-    TaoEnv -> V.Vector Int -> Tree a -> Int -> Int -> Tree a-optimizeAtDepth env indices tree currentDepth targetDepth-    | currentDepth == targetDepth = optimizeNode @a env indices tree-    | otherwise = case tree of-        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).--}-optimizeChildren ::-    forall a.-    (Columnable a, Ord a) =>-    TaoEnv -> V.Vector Int -> Expr Bool -> Tree a -> Tree a -> Int -> Int -> Tree a-optimizeChildren env indices cond left right currentDepth targetDepth =-    forceTreeWork left' `par` (forceTreeWork right' `pseq` Branch cond left' right')-  where-    (indicesL, indicesR) = partitionIndicesCached (teCache env) cond (teDf env) indices-    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.--}-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'.--}-optimizeNode ::-    forall a. (Columnable a, Ord a) => TaoEnv -> V.Vector Int -> Tree a -> Tree a-optimizeNode env indices tree-    | V.null indices = tree-    | otherwise = case tree of-        Leaf _ -> leaf-        Branch oldCond left right -> rebuiltBranch env indices oldCond left right leaf-  where-    leaf = Leaf (majorityValueFromIndices @a (teTarget env) (teDf env) indices)--rebuiltBranch ::-    forall a.-    (Columnable a, Ord a) =>-    TaoEnv -> V.Vector Int -> Expr Bool -> Tree a -> Tree a -> Tree a -> Tree a-rebuiltBranch env indices oldCond left right leaf-    | underflows = leaf-    | otherwise = Branch newCond left right-  where-    newCond = findBestSplitTAO @a env indices left right oldCond-    (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.--}-findBestSplitTAO ::-    forall a.-    (Columnable a) =>-    TaoEnv -> V.Vector Int -> Tree a -> Tree a -> Expr Bool -> Expr Bool-findBestSplitTAO env indices leftTree rightTree currentCond-    | V.null indices || null carePoints = currentCond-    | pureReplacementLinear cfg-    , Just c <- linearCandidate-    , isValidAtNode cfg (teDf env) indices c =-        c-    | otherwise = bestOfPool penaltyCV currentCond pool-  where-    cfg = teCfg env-    carePoints =-        identifyCarePointsCached @a-            (teCache env)-            (teTarget env)-            (teDf env)-            indices-            leftTree-            rightTree-    penaltyCV = evalWithPenaltyVec cfg carePoints-    linearCandidate = bestLinearCandidate cfg (teDf env) carePoints-    valid = filterValidCandidates cfg indices (teConds env)-    pool =-        candidatePool-            env-            indices-            currentCond-            (bestDiscreteCandidate cfg penaltyCV valid)-            linearCandidate--bestOfPool :: (CondVec -> (Int, Int)) -> Expr Bool -> [CondVec] -> Expr Bool-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.--}-filterValidCandidates :: TreeConfig -> V.Vector Int -> [CondVec] -> [CondVec]-filterValidCandidates cfg indices condVecs = map snd (filter fst (zip validity condVecs))-  where-    validity =-        map (validAtNode cfg indices) condVecs-            `using` parListChunk candidateParChunk rdeepseq--validAtNode :: TreeConfig -> V.Vector Int -> CondVec -> Bool-validAtNode cfg indices cv = nTrue >= minLeaf && (V.length indices - nTrue) >= minLeaf-  where-    minLeaf = minLeafSize cfg-    nTrue =-        V.foldl'-            (\ !acc i -> if cvVec cv VU.! i then acc + 1 else acc)-            (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.--}-candidatePool ::-    TaoEnv ->-    V.Vector Int ->-    Expr Bool ->-    Maybe CondVec ->-    Maybe (Expr Bool) ->-    [CondVec]-candidatePool env indices currentCond discreteCV linearCandidate =-    filter-        (isValidAtNode (teCfg env) (teDf env) indices . cvExpr)-        (catMaybes [currentCV, discreteCV, linearCV])-  where-    currentCV = CondVec currentCond <$> lookupCondVec (teCache env) (teDf env) currentCond-    linearCV = linearCandidate >>= materializeCondVec (teDf env)
− src/DataFrame/DecisionTree/Types.hs
@@ -1,190 +0,0 @@-{-# LANGUAGE AllowAmbiguousTypes #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE GADTs #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TypeApplications #-}--{- | Shared types, configuration and ordering machinery for the decision-tree-learner. Imported by every other @DataFrame.DecisionTree.*@ module.--}-module DataFrame.DecisionTree.Types (-    Tree (..),-    treeDepth,-    TreeConfig (..),-    SynthConfig (..),-    defaultTreeConfig,-    defaultSynthConfig,-    ColumnOrdering (..),-    orderable,-    defaultColumnOrdering,-    withOrdFrom,-    CarePoint (..),-    Direction (..),-    ttrace,-) where--import DataFrame.Internal.Column (Columnable)-import DataFrame.Internal.Expression (Expr (..))-import qualified DataFrame.LinearSolver as LS--import Data.Int (Int16, Int32, Int64, Int8)-import qualified Data.Map.Strict as M-import Data.Proxy (Proxy (..))-import qualified Data.Text as T-import Data.Type.Equality (testEquality, (:~:) (..))-import Data.Word (Word16, Word32, Word64, Word8)-import qualified Debug.Trace as Trace-import System.Environment (lookupEnv)-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.--}-data Tree a-    = Leaf !a-    | Branch !(Expr Bool) !(Tree a) !(Tree a)-    deriving (Show)--treeDepth :: Tree a -> Int-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).--}-data CarePoint = CarePoint-    { cpIndex :: !Int-    , cpCorrectDir :: !Direction-    }-    deriving (Eq, Show)--data Direction = GoLeft | GoRight-    deriving (Eq, Show)--data TreeConfig = TreeConfig-    { maxTreeDepth :: Int-    , minSamplesSplit :: Int-    , minLeafSize :: Int-    , percentiles :: [Int]-    , expressionPairs :: Int-    , synthConfig :: SynthConfig-    , taoIterations :: Int-    , taoConvergenceTol :: Double-    , columnOrdering :: ColumnOrdering-    , useLinearSolver :: Bool-    , linearSolverConfig :: LS.SolverConfig-    , minCarePointsForLinear :: Int-    , pureReplacementLinear :: Bool-    }--data SynthConfig = SynthConfig-    { maxExprDepth :: Int-    , boolExpansion :: Int-    , disallowedCombinations :: [(T.Text, T.Text)]-    , complexityPenalty :: Double-    , enableStringOps :: Bool-    , enableCrossCols :: Bool-    , enableArithOps :: Bool-    , maxCategoricalSubsetCardinality :: Int-    , perColumnQuota :: Maybe Int-    }-    deriving (Eq, Show)--defaultSynthConfig :: SynthConfig-defaultSynthConfig =-    SynthConfig-        { maxExprDepth = 2-        , boolExpansion = 2-        , disallowedCombinations = []-        , complexityPenalty = 0.05-        , enableStringOps = True-        , enableCrossCols = True-        , enableArithOps = True-        , maxCategoricalSubsetCardinality = 4-        , perColumnQuota = Just 3-        }--defaultTreeConfig :: TreeConfig-defaultTreeConfig =-    TreeConfig-        { maxTreeDepth = 4-        , minSamplesSplit = 5-        , minLeafSize = 1-        , percentiles = [0, 10 .. 100]-        , expressionPairs = 10-        , synthConfig = defaultSynthConfig-        , taoIterations = 10-        , taoConvergenceTol = 1e-6-        , columnOrdering = defaultColumnOrdering-        , useLinearSolver = True-        , linearSolverConfig = LS.defaultSolverConfig-        , minCarePointsForLinear = 10-        , pureReplacementLinear = False-        }--{- | Which column types support ordering for splits. Register a type with-'orderable' and combine with @<>@.--}-newtype ColumnOrdering = ColumnOrdering (M.Map SomeTypeRep OrdDict)--instance Semigroup ColumnOrdering where-    ColumnOrdering a <> ColumnOrdering b = ColumnOrdering (a <> b)--instance Monoid ColumnOrdering where-    mempty = ColumnOrdering M.empty---- | Register a type as orderable for decision-tree splits.-orderable :: forall a. (Columnable a, Ord a) => ColumnOrdering-orderable = ColumnOrdering (M.singleton (SomeTypeRep (typeRep @a)) (OrdDict (Proxy @a)))---- | All standard numeric, text, and primitive types.-defaultColumnOrdering :: ColumnOrdering-defaultColumnOrdering = mconcat (numericOrderings ++ otherOrderings)--numericOrderings :: [ColumnOrdering]-numericOrderings =-    [ orderable @Int-    , orderable @Int8-    , orderable @Int16-    , orderable @Int32-    , orderable @Int64-    , orderable @Word-    , orderable @Word8-    , orderable @Word16-    , orderable @Word32-    , orderable @Word64-    , orderable @Integer-    , orderable @Double-    , orderable @Float-    ]--otherOrderings :: [ColumnOrdering]-otherOrderings =-    [orderable @Bool, orderable @Char, orderable @T.Text, orderable @String]---- | Existential @Ord@ dictionary keyed by type representation.-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.--}-withOrdFrom ::-    forall a r. (Columnable a) => ColumnOrdering -> ((Ord a) => r) -> Maybe r-withOrdFrom (ColumnOrdering m) k = case M.lookup (SomeTypeRep (typeRep @a)) m of-    Just (OrdDict (_ :: Proxy b)) -> case testEquality (typeRep @a) (typeRep @b) of-        Just Refl -> Just k-        Nothing -> Nothing-    Nothing -> Nothing--{-# NOINLINE taoTraceEnabled #-}-taoTraceEnabled :: Bool-taoTraceEnabled = unsafePerformIO (fmap (== Just "1") (lookupEnv "TAO_TRACE"))---- | Emit a trace line when @TAO_TRACE=1@; a no-op otherwise.-ttrace :: String -> a -> a-ttrace msg x-    | taoTraceEnabled = Trace.trace ("[TAO] " ++ msg) x-    | otherwise = x
− src/DataFrame/Featurize/Internal.hs
@@ -1,146 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}-{-# 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,-and the common expression builders (affine score, arg-max/arg-min over named-scores) that every linear model and classifier would otherwise re-implement.--}-module DataFrame.Featurize.Internal (-    -- * Supervised extraction-    featureNames,-    numericMatrix,-    targetDoubles,-    targetValues,--    -- * Unsupervised extraction-    Features (..),-    extractFeatures,-    columnExprName,-    materializeColumn,--    -- * Expression builders-    affineExpr,-    argMaxExpr,-    argMinExpr,-) where--import Control.Exception (throw)-import qualified Data.Text as T-import qualified Data.Vector as V-import qualified Data.Vector.Unboxed as VU--import DataFrame.Errors (DataFrameException (..), TypeErrorContext (..))-import qualified DataFrame.Functions as F-import DataFrame.Internal.Column (Columnable)-import DataFrame.Internal.DataFrame (DataFrame, columnNames)-import DataFrame.Internal.Expression (Expr (..))-import DataFrame.LinearAlgebra (Matrix, transposeM)-import DataFrame.Operations.Core (columnAsDoubleVector, columnAsVector)-import DataFrame.Operators ((.&&.), (.*.), (.+.), (.<=.), (.>=.))---- | Every column name except the supervised target's.-featureNames :: Expr a -> DataFrame -> [T.Text]-featureNames (Col target) df = filter (/= target) (columnNames df)-featureNames _ df = columnNames df--{- | The named columns as a row-major @n×d@ matrix of doubles (non-numeric-columns are coerced through 'columnAsDoubleVector'), 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 column names)-    column name = case columnAsDoubleVector (F.col @Double name) df of-        Right v -> v-        Left e -> throw (asFeatureError name e)--asFeatureError :: T.Text -> DataFrameException -> DataFrameException-asFeatureError name (TypeMismatchException ctx) =-    TypeMismatchException-        ctx-            { errorColumnName = Just (T.unpack name)-            , callingFunctionName =-                Just-                    "model fit (feature columns must be numeric Double; drop or encode non-numeric columns)"-            }-asFeatureError _ e = e---- | The target column as a vector of doubles.-targetDoubles :: Expr Double -> DataFrame -> VU.Vector Double-targetDoubles expr df = case columnAsDoubleVector expr df of-    Right v -> v-    Left e -> throw e---- | The target column as a vector of its own type (for classifiers).-targetValues :: (Columnable a) => Expr a -> DataFrame -> V.Vector a-targetValues expr df = case columnAsVector expr df of-    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.--}-data Features = Features-    { ftNames :: ![T.Text]-    , ftCols :: ![VU.Vector Double]-    , ftRows :: !Matrix-    , ftN :: !Int-    , ftD :: !Int-    }---- | Extract the given feature columns once, in every shape the fitters use.-extractFeatures :: [Expr Double] -> DataFrame -> Features-extractFeatures features df = Features names cols rows n d-  where-    names = map columnExprName features-    cols = map (materializeColumn df) features-    n = case cols of-        (x: _) -> VU.length x-        _      -> 0-    d = length cols-    rows = V.generate n (\i -> VU.generate d (\j -> (cols !! j) VU.! i))---- | The column name behind a @Col@ feature expression.-columnExprName :: Expr Double -> T.Text-columnExprName (Col n) = n-columnExprName e = error ("expected a column expression, got " ++ show e)---- | Interpret a @Col@ (or numeric) expression to a @Double@ vector.-materializeColumn :: DataFrame -> Expr Double -> VU.Vector Double-materializeColumn df e = case columnAsDoubleVector e df of-    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).--}-affineExpr :: Double -> [(Double, T.Text)] -> Expr Double-affineExpr b terms =-    foldr-        (.+.)-        (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.--}-argMaxExpr :: (Columnable a) => [(a, Expr Double)] -> Expr a-argMaxExpr = argExtreme (.>=.)---- | The class whose score is smallest (e.g. nearest cluster by distance).-argMinExpr :: (Columnable a) => [(a, Expr Double)] -> Expr a-argMinExpr = argExtreme (.<=.)--argExtreme ::-    (Columnable a) =>-    (Expr Double -> Expr Double -> Expr Bool) -> [(a, Expr Double)] -> Expr a-argExtreme _ [] = error "argExtreme: no classes"-argExtreme _ [(c, _)] = Lit c-argExtreme cmp ((c, sc) : rest) =-    If-        (foldr ((\o acc -> cmp sc o .&&. acc) . snd) (F.lit True) rest)-        (Lit c)-        (argExtreme cmp rest)
src/DataFrame/GMM.hs view
@@ -3,6 +3,7 @@ {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# 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@@ -11,6 +12,7 @@ log-densities are available via 'gmmLogDensityExprs'. -} module DataFrame.GMM (+    module DataFrame.Model,     CovType (..),     GMMConfig (..),     defaultGMMConfig,@@ -33,7 +35,7 @@ import DataFrame.Internal.Expression (Expr (..)) import DataFrame.LinearAlgebra (Matrix, logSumExp) import DataFrame.LinearAlgebra.Solve (backSubst, cholesky, forwardSubst)-import DataFrame.Model (Fit (..), Predict (..))+import DataFrame.Model import DataFrame.Operators ((.*.), (.+.), (.-.)) import DataFrame.Random (mkGen, sampleIndices) @@ -74,10 +76,12 @@     }     deriving (Eq, Show) -instance Fit GMMConfig [Expr Double] GMMModel where+instance Fit GMMConfig [Expr Double] where+    type ModelOf GMMConfig [Expr Double] = GMMModel     fit = fitGMM -instance Predict GMMModel Int where+instance Predict GMMModel where+    type Prediction GMMModel = Expr Int     predict = gmmAssignExpr  -- | Fit a Gaussian mixture over the given feature columns.
src/DataFrame/KMeans.hs view
@@ -2,6 +2,7 @@ {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# 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@@ -9,6 +10,7 @@ 'kmeansDistanceExprs' / 'kmeansTransform'. -} module DataFrame.KMeans (+    module DataFrame.Model,     KMeansConfig (..),     defaultKMeansConfig,     KMeansModel (..),@@ -17,6 +19,7 @@ ) where  import Data.List (minimumBy)+import Data.Maybe (fromMaybe, listToMaybe) import Data.Ord (comparing) import qualified Data.Text as T import qualified Data.Vector as V@@ -27,7 +30,7 @@ import DataFrame.Internal.DataFrame (DataFrame) import DataFrame.Internal.Expression (Expr (..), UExpr (..)) import DataFrame.LinearAlgebra (Matrix, nearestCenter, sqDist)-import DataFrame.Model (Fit (..), Predict (..))+import DataFrame.Model import DataFrame.Operators ((.*.), (.+.), (.-.)) import DataFrame.Random (Gen, mkGen, nextDouble, nextIntR, splitGen) import DataFrame.Transform (Transform (..))@@ -55,10 +58,12 @@     }     deriving (Eq, Show) -instance Fit KMeansConfig [Expr Double] KMeansModel where+instance Fit KMeansConfig [Expr Double] where+    type ModelOf KMeansConfig [Expr Double] = KMeansModel     fit = fitKMeans -instance Predict KMeansModel Int where+instance Predict KMeansModel where+    type Prediction KMeansModel = Expr Int     predict m = argMinExpr (zip [0 :: Int ..] (map snd (kmeansDistanceExprs m)))  -- | Fit k-means over the given feature columns.@@ -110,7 +115,7 @@  meanOf :: [VU.Vector Double] -> VU.Vector Double meanOf vs =-    let d = VU.length (head vs)+    let d = VU.length (VU.empty `fromMaybe` listToMaybe vs)         s = foldr (VU.zipWith (+)) (VU.replicate d 0) vs      in VU.map (/ fromIntegral (length vs)) s 
+ src/DataFrame/Learn.hs view
@@ -0,0 +1,51 @@+{- | 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+@+-}+module DataFrame.Learn (+    module DataFrame.Model,+    module DataFrame.LinearModel,+    module DataFrame.SVM,+    module DataFrame.SVM.RFF,+    module DataFrame.PCA,+    module DataFrame.PCA.Kernel,+    module DataFrame.KMeans,+    module DataFrame.GMM,+    module DataFrame.DBSCAN,+    module DataFrame.Boosting,+    module DataFrame.SymbolicRegression,+    module DataFrame.Synthesis,+    module DataFrame.Segmented,+    module DataFrame.DecisionTree,+    module DataFrame.Metrics,+    module DataFrame.Metrics.Report,+    module DataFrame.ModelSelection,+    module DataFrame.Transform,+    module DataFrame.Transform.Serialize,+) where++import DataFrame.Boosting+import DataFrame.DBSCAN+import DataFrame.DecisionTree+import DataFrame.GMM+import DataFrame.KMeans+import DataFrame.LinearModel+import DataFrame.Metrics+import DataFrame.Metrics.Report+import DataFrame.Model+import DataFrame.ModelSelection+import DataFrame.PCA+import DataFrame.PCA.Kernel+import DataFrame.SVM+import DataFrame.SVM.RFF+import DataFrame.Segmented+import DataFrame.SymbolicRegression+import DataFrame.Synthesis+import DataFrame.Transform+import DataFrame.Transform.Serialize
− src/DataFrame/LinearAlgebra.hs
@@ -1,122 +0,0 @@-{-# LANGUAGE BangPatterns #-}--{- | Dependency-free dense linear algebra over row-major matrices, shared by the-models in @dataframe-learn@. Basic vector/matrix operations plus stability and-distance helpers; solvers live in "DataFrame.LinearAlgebra.Solve" and-eigenproblems in "DataFrame.LinearAlgebra.Eigen".--}-module DataFrame.LinearAlgebra (-    Matrix,-    dot,-    axpy,-    scaleV,-    matVec,-    tMatVec,-    gram,-    transposeM,-    identityM,-    logSumExp,-    sqDist,-    nearestCenter,-    epsNeighbors,-) where--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@.--}-type Matrix = V.Vector (VU.Vector Double)---- | Inner product of two equal-length vectors.-dot :: VU.Vector Double -> VU.Vector Double -> Double-dot a b = VU.sum (VU.zipWith (*) a b)-{-# INLINE dot #-}---- | @axpy a x y = a*x + y@.-axpy :: Double -> VU.Vector Double -> VU.Vector Double -> VU.Vector Double-axpy a = VU.zipWith (\xi yi -> a * xi + yi)-{-# INLINE axpy #-}---- | Scalar-vector product.-scaleV :: Double -> VU.Vector Double -> VU.Vector Double-scaleV a = VU.map (* a)-{-# INLINE scaleV #-}---- | @matVec A v@ for @A@ of shape @n×d@ and @v@ of length @d@; result length @n@.-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@.-tMatVec :: Matrix -> VU.Vector Double -> VU.Vector Double-tMatVec a v-    | V.null a = VU.empty-    | otherwise = V.foldl' step (VU.replicate d 0) (V.zipWith (,) vBoxed a)-  where-    d = VU.length (V.head a)-    vBoxed = V.generate (V.length a) (v VU.!)-    step !acc (vi, row) = axpy vi row acc---- | @gram A = Aᵀ A@, the @d×d@ symmetric matrix of column inner products.-gram :: Matrix -> Matrix-gram a-    | V.null a = V.empty-    | otherwise =-        V.generate d $ \i ->-            VU.generate d $ \j ->-                V.foldl' (\ !acc row -> acc + (row VU.! i) * (row VU.! j)) 0 a-  where-    d = VU.length (V.head a)---- | Transpose an @n×d@ matrix to @d×n@.-transposeM :: Matrix -> Matrix-transposeM a-    | V.null a = V.empty-    | otherwise = V.generate d $ \j -> VU.generate n $ \i -> (a V.! i) VU.! j-  where-    n = V.length a-    d = VU.length (V.head a)---- | @d×d@ identity matrix.-identityM :: Int -> Matrix-identityM d = V.generate d $ \i -> VU.generate d $ \j -> if i == j then 1 else 0---- | Numerically stable @log Σ exp xᵢ@.-logSumExp :: VU.Vector Double -> Double-logSumExp xs-    | VU.null xs = negate (1 / 0)-    | otherwise = m + log (VU.sum (VU.map (\x -> exp (x - m)) xs))-  where-    m = VU.maximum xs---- | Squared Euclidean distance.-sqDist :: VU.Vector Double -> VU.Vector Double -> Double-sqDist a b = VU.sum (VU.zipWith (\x y -> let z = x - y in z * z) a b)-{-# INLINE sqDist #-}---- | Index of and squared distance to the nearest centre.-nearestCenter ::-    V.Vector (VU.Vector Double) -> VU.Vector Double -> (Int, Double)-nearestCenter centers p =-    V.ifoldl'-        ( \(!bi, !bd) i c ->-            let dd = sqDist c p in if dd < bd then (i, dd) else (bi, bd)-        )-        (-1, 1 / 0)-        centers--{- | Indices @j@ (excluding @i@) within squared radius @eps²@ of row @i@, by-brute force; @O(n d)@ per query.--}-epsNeighbors :: Double -> Matrix -> Int -> VU.Vector Int-epsNeighbors eps rows i =-    VU.fromList-        [ j-        | j <- [0 .. n - 1]-        , j /= i-        , sqDist (rows V.! i) (rows V.! j) <= eps2-        ]-  where-    n = V.length rows-    eps2 = eps * eps
− src/DataFrame/LinearAlgebra/Eigen.hs
@@ -1,121 +0,0 @@-{-# 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.--}-module DataFrame.LinearAlgebra.Eigen (-    jacobiEigenSym,-    powerIterTop,-) where--import Control.Monad (forM_, when)-import Control.Monad.ST (runST)-import Data.List (sortBy)-import Data.Ord (Down (..), comparing)-import qualified Data.Vector as V-import qualified Data.Vector.Unboxed as VU-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 eigenvector-is sign-canonicalised (largest-magnitude component positive) so the output is-unique.--}-jacobiEigenSym :: Matrix -> (VU.Vector Double, Matrix)-jacobiEigenSym a0-    | V.null a0 = (VU.empty, V.empty)-    | otherwise = runST $ do-        a <- VUM.new (d * d)-        forM_ [0 .. d - 1] $ \i ->-            forM_ [0 .. d - 1] $ \j ->-                VUM.write a (i * d + j) ((a0 V.! i) VU.! j)-        v <- VUM.replicate (d * d) 0-        forM_ [0 .. d - 1] $ \i -> VUM.write v (i * d + i) 1-        sweep a v 0-        afrozen <- VU.freeze a-        vmat <- VU.freeze v-        let diag = VU.generate d (\i -> afrozen VU.! (i * d + i))-            vecs =-                V.generate d $ \col ->-                    VU.generate d $ \row -> vmat VU.! (row * d + col)-            paired =-                sortBy-                    (comparing (Down . fst))-                    (zip (VU.toList diag) (V.toList vecs))-        pure-            ( VU.fromList (map fst paired)-            , V.fromList (map (canonicalSign . snd) paired)-            )-  where-    d = V.length a0-    maxSweeps = 100-    tol = 1e-12-    sweep a v s-        | s >= maxSweeps = pure ()-        | otherwise = do-            off <- offNorm a-            when (off >= tol) $ do-                forM_ [0 .. d - 2] $ \p ->-                    forM_ [p + 1 .. d - 1] $ \q -> rotate a v p q-                sweep a v (s + 1)-    offNorm a = go 0 0-      where-        go i !acc-            | i >= d = pure acc-            | otherwise = do-                r <- goRow i (i + 1) acc-                go (i + 1) r-        goRow i j !acc-            | j >= d = pure acc-            | otherwise = do-                x <- VUM.read a (i * d + j)-                goRow i (j + 1) (acc + x * x)-    rotate a v p q = do-        apq <- VUM.read a (p * d + q)-        when (abs apq > 1e-300) $ do-            app <- VUM.read a (p * d + p)-            aqq <- VUM.read a (q * d + q)-            let theta = (aqq - app) / (2 * apq)-                s' = if theta == 0 then 1 else signum theta-                t = s' / (abs theta + sqrt (theta * theta + 1))-                c = 1 / sqrt (t * t + 1)-                sn = t * c-            forM_ [0 .. d - 1] $ \i -> do-                aip <- VUM.read a (i * d + p)-                aiq <- VUM.read a (i * d + q)-                VUM.write a (i * d + p) (c * aip - sn * aiq)-                VUM.write a (i * d + q) (sn * aip + c * aiq)-            forM_ [0 .. d - 1] $ \j -> do-                apj <- VUM.read a (p * d + j)-                aqj <- VUM.read a (q * d + j)-                VUM.write a (p * d + j) (c * apj - sn * aqj)-                VUM.write a (q * d + j) (sn * apj + c * aqj)-            forM_ [0 .. d - 1] $ \i -> do-                vip <- VUM.read v (i * d + p)-                viq <- VUM.read v (i * d + q)-                VUM.write v (i * d + p) (c * vip - sn * viq)-                VUM.write v (i * d + q) (sn * vip + c * viq)--canonicalSign :: VU.Vector Double -> VU.Vector Double-canonicalSign vec =-    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.--}-powerIterTop :: Int -> Matrix -> (Double, VU.Vector Double)-powerIterTop iters a-    | V.null a = (0, VU.empty)-    | otherwise = go iters (normalize (VU.replicate d 1))-  where-    d = V.length a-    normalize v =-        let nrm = sqrt (dot v v) in if nrm == 0 then v else scaleV (1 / nrm) v-    go 0 v = (dot v (matVec a v), v)-    go k v =-        let av = matVec a v-            nrm = sqrt (dot av av)-         in if nrm < 1e-300 then (0, v) else go (k - 1) (scaleV (1 / nrm) av)
− src/DataFrame/LinearAlgebra/Solve.hs
@@ -1,213 +0,0 @@-{-# 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.--}-module DataFrame.LinearAlgebra.Solve (-    qrLeastSquares,-    cholesky,-    choleskySolve,-    logDetFromChol,-    forwardSubst,-    backSubst,-) where--import Control.Monad (forM_)-import Control.Monad.ST (ST, runST)-import qualified Data.Vector as V-import qualified Data.Vector.Unboxed as VU-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.--}-qrLeastSquares :: Matrix -> VU.Vector Double -> Either [Int] (VU.Vector Double)-qrLeastSquares a b-    | V.null a = Right VU.empty-    | n < d = Left [0 .. d - 1]-    | otherwise = runST $ do-        mat <- VUM.new (n * d)-        forM_ [0 .. n - 1] $ \i ->-            forM_ [0 .. d - 1] $ \j ->-                VUM.write mat (j * n + i) ((a V.! i) VU.! j)-        rhs <- VUM.new n-        forM_ [0 .. n - 1] $ \i -> VUM.write rhs i (b VU.! i)-        deficient <- householder mat rhs n d-        if not (null deficient)-            then pure (Left deficient)-            else do-                x <- VUM.new d-                backSubstQR mat rhs n d x-                Right <$> VU.freeze x-  where-    n = V.length a-    d = VU.length (V.head a)--householder ::-    VUM.STVector s Double -> VUM.STVector s Double -> Int -> Int -> ST s [Int]-householder mat rhs n d = go 0 []-  where-    tol = 1e-10-    go k acc-        | k >= d = pure (reverse acc)-        | otherwise = do-            normSq <- sumSq k-            let alphaMag = sqrt normSq-            akk <- VUM.read mat (k * n + k)-            let alpha = if akk > 0 then negate alphaMag else alphaMag-            if alphaMag < tol-                then go (k + 1) (k : acc)-                else do-                    VUM.write mat (k * n + k) (akk - alpha)-                    vNormSq <- sumSq k-                    if vNormSq < tol * tol-                        then go (k + 1) acc-                        else do-                            forM_ [k + 1 .. d - 1] $ \j -> reflectColumn k j vNormSq-                            reflectRhs k vNormSq-                            VUM.write mat (k * n + k) alpha-                            go (k + 1) acc-    sumSq k = foldRows k-      where-        foldRows i-            | i >= n = pure 0-            | otherwise = do-                x <- VUM.read mat (k * n + i)-                rest <- foldRows (i + 1)-                pure (x * x + rest)-    reflectColumn k j vNormSq = do-        dotv <- dotV k j k-        let beta = 2 * dotv / vNormSq-        forM_ [k .. n - 1] $ \i -> do-            vi <- VUM.read mat (k * n + i)-            aij <- VUM.read mat (j * n + i)-            VUM.write mat (j * n + i) (aij - beta * vi)-    reflectRhs k vNormSq = do-        dotv <- dotRhs k k-        let beta = 2 * dotv / vNormSq-        forM_ [k .. n - 1] $ \i -> do-            vi <- VUM.read mat (k * n + i)-            bi <- VUM.read rhs i-            VUM.write rhs i (bi - beta * vi)-    dotV k j i-        | i >= n = pure 0-        | otherwise = do-            vi <- VUM.read mat (k * n + i)-            aij <- VUM.read mat (j * n + i)-            rest <- dotV k j (i + 1)-            pure (vi * aij + rest)-    dotRhs k i-        | i >= n = pure 0-        | otherwise = do-            vi <- VUM.read mat (k * n + i)-            bi <- VUM.read rhs i-            rest <- dotRhs k (i + 1)-            pure (vi * bi + rest)--backSubstQR ::-    VUM.STVector s Double ->-    VUM.STVector s Double ->-    Int ->-    Int ->-    VUM.STVector s Double ->-    ST s ()-backSubstQR mat rhs n d x = forM_ [d - 1, d - 2 .. 0] $ \i -> do-    bi <- VUM.read rhs i-    s <- sumAbove i (i + 1) 0-    rii <- VUM.read mat (i * n + i)-    VUM.write x i ((bi - s) / rii)-  where-    sumAbove i j !acc-        | j >= d = pure acc-        | otherwise = do-            rij <- VUM.read mat (j * n + i)-            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 :: Matrix -> Maybe Matrix-cholesky a-    | V.null a = Just V.empty-    | otherwise = runST $ do-        l <- VUM.replicate (d * d) 0-        ok <- buildL l-        if ok then Just <$> freezeLower l else pure Nothing-  where-    d = V.length a-    buildL l = go 0-      where-        go j-            | j >= d = pure True-            | otherwise = do-                s <- sumLk l j j (j - 1) 0-                let ajj = (a V.! j) VU.! j-                    diag = ajj - s-                if diag <= 0-                    then pure False-                    else do-                        let ljj = sqrt diag-                        VUM.write l (j * d + j) ljj-                        forM_ [j + 1 .. d - 1] $ \i -> do-                            sij <- sumLk l i j (j - 1) 0-                            let aij = (a V.! i) VU.! j-                            VUM.write l (i * d + j) ((aij - sij) / ljj)-                        go (j + 1)-    sumLk l i j k !acc-        | k < 0 = pure acc-        | otherwise = do-            lik <- VUM.read l (i * d + k)-            ljk <- VUM.read l (j * d + k)-            sumLk l i j (k - 1) (acc + lik * ljk)-    freezeLower l = do-        frozen <- VU.freeze l-        pure $ V.generate d $ \i -> VU.slice (i * d) d frozen---- | Solve @L y = b@ for lower-triangular @L@.-forwardSubst :: Matrix -> VU.Vector Double -> VU.Vector Double-forwardSubst l b = runST $ do-    y <- VUM.new d-    forM_ [0 .. d - 1] $ \i -> do-        let row = l V.! i-        s <- sumKnown y row i 0 0-        VUM.write y i ((b VU.! i - s) / (row VU.! i))-    VU.freeze y-  where-    d = V.length l-    sumKnown y row i j !acc-        | j >= i = pure acc-        | otherwise = do-            yj <- VUM.read y j-            sumKnown y row i (j + 1) (acc + (row VU.! j) * yj)---- | Solve @Lᵀ x = y@ for lower-triangular @L@.-backSubst :: Matrix -> VU.Vector Double -> VU.Vector Double-backSubst l y = runST $ do-    x <- VUM.new d-    forM_ [d - 1, d - 2 .. 0] $ \i -> do-        s <- sumKnown x i (i + 1) 0-        VUM.write x i ((y VU.! i - s) / ((l V.! i) VU.! i))-    VU.freeze x-  where-    d = V.length l-    sumKnown x i j !acc-        | j >= d = pure acc-        | otherwise = do-            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.--}-choleskySolve :: Matrix -> VU.Vector Double -> Maybe (VU.Vector Double)-choleskySolve a b = do-    l <- cholesky a-    pure (backSubst l (forwardSubst l b))---- | @log det A = 2 Σ log Lᵢᵢ@ from a Cholesky factor @L@.-logDetFromChol :: Matrix -> Double-logDetFromChol l = 2 * V.sum (V.imap (\i row -> log (row VU.! i)) l)
src/DataFrame/LinearModel.hs view
@@ -5,7 +5,17 @@ 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.+    SolverConfig (..),+    defaultSolverConfig,+    LinearModel (..), ) where  import DataFrame.LinearModel.Logistic import DataFrame.LinearModel.Regression+import DataFrame.LinearSolver (+    LinearModel (..),+    SolverConfig (..),+    defaultSolverConfig,+ )
src/DataFrame/LinearModel/Logistic.hs view
@@ -1,6 +1,7 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-}  {- | Logistic regression: binary and one-vs-rest multiclass over the FISTA@@ -9,6 +10,7 @@ auxiliary expressions. -} module DataFrame.LinearModel.Logistic (+    module DataFrame.Model,     LogisticConfig (..),     defaultLogisticConfig,     LogisticModel (..),@@ -16,6 +18,7 @@     logisticProbExprs, ) where +import Control.Parallel.Strategies (Strategy, parList, rseq, using) import Data.List (sort) import qualified Data.Map.Strict as M import qualified Data.Vector as V@@ -38,7 +41,7 @@     defaultSolverConfig,     fitL1Logistic,  )-import DataFrame.Model (Fit (..), Predict (..))+import DataFrame.Model  -- | Hyperparameters for logistic regression (the FISTA solver config). newtype LogisticConfig = LogisticConfig {lgSolver :: SolverConfig}@@ -56,10 +59,12 @@     }     deriving (Eq, Show) -instance (Columnable a, Ord a) => Fit LogisticConfig (Expr a) (LogisticModel a) where+instance (Columnable a, Ord a) => Fit LogisticConfig (Expr a) where+    type ModelOf LogisticConfig (Expr a) = (LogisticModel a)     fit = fitLogistic -instance (Columnable a, Ord a) => Predict (LogisticModel a) a where+instance (Columnable a, Ord a) => Predict (LogisticModel a) where+    type Prediction (LogisticModel a) = Expr a     predict m = argMaxExpr (labelledMargins m)  -- | Fit one-vs-rest logistic regression; the target column supplies the classes.@@ -67,7 +72,7 @@     (Columnable a, Ord a) =>     LogisticConfig -> Expr a -> DataFrame -> LogisticModel a fitLogistic (LogisticConfig cfg) target df =-    LogisticModel (V.fromList classes) (V.fromList (map fitOne classes))+    LogisticModel (V.fromList classes) (V.fromList models)   where     names = featureNames target df     (nameVec, mat) = numericMatrix names df@@ -78,6 +83,24 @@         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.+    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).+-}+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.+-}+forceModel :: Strategy LinearModel+forceModel m = lmWeights m `seq` rseq m  -- | The raw margin @Expr@ for each class. logisticMarginExprs ::
src/DataFrame/LinearModel/Regression.hs view
@@ -1,17 +1,18 @@+{-# LANGUAGE DataKinds #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE TypeFamilies #-} -{- | Linear regression with the standard penalties: ordinary least squares-(Householder QR), ridge (Cholesky on the regularized normal equations), and-lasso / elastic net (FISTA). 'fit' produces a 'LinearRegressor' record;-'predict' compiles it to an @Expr Double@ over the raw feature columns.+{- | 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. -} module DataFrame.LinearModel.Regression (+    module DataFrame.Model,     Penalty (..),     LinearConfig (..),     defaultLinearConfig,     LinearRegressor (..),-    predictLinear, ) where  import qualified Data.Text as T@@ -34,7 +35,7 @@     fitProx,  ) import DataFrame.LinearSolver.Loss (squaredLoss)-import DataFrame.Model (Fit (..), Predict (..))+import DataFrame.Model  -- | Regularization choice. @alpha@ is the penalty strength; @l1Ratio@ mixes L1/L2. data Penalty@@ -65,7 +66,9 @@     }     deriving (Eq, Show) -instance Fit LinearConfig (Expr Double) LinearRegressor where+instance Fit LinearConfig (Expr Double) where+    type ModelOf LinearConfig (Expr Double) = LinearRegressor+    type FrameReq LinearConfig (Expr Double) = 'AllDoubleFrame     fit (LinearConfig penalty cfg) target df =         case penalty of             OLS -> closedForm (olsSolve mat y)@@ -84,7 +87,8 @@                 m = fitProx squaredLoss proxCfg mat y nameVec              in LinearRegressor (lmWeights m) (lmIntercept m) nameVec penalty -instance Predict LinearRegressor Double where+instance Predict LinearRegressor where+    type Prediction LinearRegressor = Expr Double     predict m =         affineExpr             (regIntercept m)
− src/DataFrame/LinearSolver.hs
@@ -1,472 +0,0 @@-{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE ScopedTypeVariables #-}--{- | Proximal-gradient (FISTA) solver for L1/L2-regularized generalized linear-models. 'fitL1Logistic' is the binary logistic split solver used by-'DataFrame.DecisionTree'; 'fitProx' generalizes it to any 'SmoothLoss'-(squared loss for lasso/elastic-net, squared hinge for LinearSVC). Features are-standardized internally and weights de-standardized, so the model applies to-raw column values.--}-module DataFrame.LinearSolver (-    -- * Model-    LinearModel (..),--    -- * Configuration-    SolverConfig (..),-    defaultSolverConfig,--    -- * Solvers-    fitL1Logistic,-    fitProx,--    -- * Expr conversion-    modelToExpr,--    -- * Internals (exposed for testing)-    standardize,-    columnStats,-    softThreshold,-    sigmoid,-    dotProduct,-) where--import qualified DataFrame.Functions as F-import DataFrame.Internal.Expression (Expr (..))-import DataFrame.LinearAlgebra (Matrix, gram, scaleV)-import DataFrame.LinearAlgebra.Eigen (powerIterTop)-import DataFrame.LinearSolver.Loss (-    SmoothLoss (..),-    logisticLoss,-    sigmoid,- )-import DataFrame.Operators ((.*.), (.+.), (.>.))--import Control.Monad.ST (ST, runST)-import qualified Data.Text as T-import qualified Data.Vector as V-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').--}-data LinearModel = LinearModel-    { lmWeights :: !(VU.Vector Double)-    , lmIntercept :: !Double-    , lmFeatureNames :: !(V.Vector T.Text)-    }-    deriving (Eq, Show)---- | Hyper-parameters for the FISTA solver.-data SolverConfig = SolverConfig-    { 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 gives the standard elastic-net-    objective @λ₁·|w|₁ + (λ₂/2)·|w|²@. At @scL2Lambda = 0@ the solver-    reduces to pure L1 (the original behaviour). The Friedman/Hastie/-    Tibshirani 2010 glmnet proximal step under step size @1/L@ is-    @softThreshold(z, λ₁/L) / (1 + λ₂/L)@ with @L = (d+1)/4 + λ₂@.-    -}-    , 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-    weight 1 (legacy behaviour, A1-A18 path). The 1/N gradient-    normalisation is preserved by convention: weights should have mean-    1 (i.e. @Σ w_i = N@) so the existing Lipschitz bound stays valid.-    See 'fitLinearCandidate' in 'DataFrame.DecisionTree' for the-    class-balanced construction @w_i = N / (2 · N_class(label_i))@.-    -}-    }-    deriving (Eq, Show)--defaultSolverConfig :: SolverConfig-defaultSolverConfig =-    SolverConfig-        { scL1Lambda = 0.005-        , scL2Lambda = 0.005-        , scMaxIter = 200-        , scTol = 1.0e-4-        , 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.--}-fitL1Logistic ::-    SolverConfig ->-    V.Vector (VU.Vector Double) ->-    VU.Vector Double ->-    V.Vector T.Text ->-    LinearModel-{-# INLINEABLE fitL1Logistic #-}-fitL1Logistic = runFista logisticLoss logisticLipschitz-  where-    logisticLipschitz _ keepN = fromIntegral (keepN + 1) / 4--{- | Fit any 'SmoothLoss' with the elastic-net proximal-gradient engine. The-Lipschitz constant uses the spectral norm of the standardized Gram matrix-(power iteration), which is tight for squared and squared-hinge losses where-the logistic trace bound would be far too loose.--}-fitProx ::-    SmoothLoss ->-    SolverConfig ->-    V.Vector (VU.Vector Double) ->-    VU.Vector Double ->-    V.Vector T.Text ->-    LinearModel-fitProx loss = runFista loss specNormLipschitz-  where-    specNormLipschitz xKept _ =-        let n = V.length xKept-            gramN = V.map (scaleV (1 / fromIntegral n)) (gram xKept)-            (specNorm, _) = powerIterTop 50 gramN-         in slCurvBound loss * (specNorm + 1)--{- | Shared FISTA scaffolding: standardize, drop near-constant columns, run the-inner loop, de-standardize. @lipschitzOf@ receives the standardized kept-feature-matrix and the number of kept columns and returns the smooth-part Lipschitz-bound; the L2 penalty contribution @λ₂@ is added here.--}-runFista ::-    SmoothLoss ->-    (Matrix -> Int -> Double) ->-    SolverConfig ->-    V.Vector (VU.Vector Double) ->-    VU.Vector Double ->-    V.Vector T.Text ->-    LinearModel-runFista loss lipschitzOf cfg rows labels featureNames-    | n == 0 || d == 0 = zeroModel-    | otherwise =-        let (!means, !stds, !variances) = columnStats rows-            !keep = keptIndices variances-         in if VU.null keep-                then zeroModel-                else-                    let !meansKept = gatherBy keep means-                        !stdsKept = gatherBy keep stds-                        !xKept = V.map (standardizeRowKept keep means stds) rows-                        !lipschitz =-                            lipschitzOf xKept (VU.length keep) + scL2Lambda cfg-                        (!wStdKept, !bStd) =-                            fistaLoop-                                loss-                                (scL1Lambda cfg)-                                (scL2Lambda cfg)-                                lipschitz-                                (scMaxIter cfg)-                                (scTol cfg)-                                (scSampleWeights cfg)-                                xKept-                                labels-                                (VU.replicate (VU.length keep) 0)-                                0-                        !wRawKept = VU.zipWith (/) wStdKept stdsKept-                        !bRaw = bStd - VU.sum (VU.zipWith (*) wRawKept meansKept)-                     in LinearModel (expandWeights d keep wRawKept) bRaw featureNames-  where-    !n = V.length rows-    !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@.--}-keptIndices :: VU.Vector Double -> VU.Vector Int-keptIndices variances =-    VU.fromList-        [ j-        | j <- [0 .. VU.length variances - 1]-        , 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.--}-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.--}-standardizeRowKept ::-    VU.Vector Int ->-    VU.Vector Double ->-    VU.Vector Double ->-    VU.Vector Double ->-    VU.Vector Double-standardizeRowKept keep means stds row = VU.map standardizeAt keep-  where-    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.--}-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)@.--}-modelToExpr :: LinearModel -> Expr Bool-modelToExpr m =-    case nonZero of-        [] -> F.lit (b > 0)-        (w0, n0) : rest -> score rest (term w0 n0) .>. F.lit (0 :: Double)-  where-    b = lmIntercept m-    nonZero =-        [ (w, n)-        | (w, n) <- zip (VU.toList (lmWeights m)) (V.toList (lmFeatureNames m))-        , w /= 0-        ]-    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@.--}-columnStats ::-    V.Vector (VU.Vector Double) ->-    (VU.Vector Double, VU.Vector Double, VU.Vector Double)-columnStats x-    | V.null x = (VU.empty, VU.empty, VU.empty)-    | otherwise =-        let !d = VU.length (V.unsafeHead x)-            !invN = 1 / fromIntegral (V.length x)-            !means = columnMeans d invN x-            !variances = columnVariances d invN means x-            !stds = VU.map (\v -> if v < 1e-12 then 1 else sqrt v) variances-         in (means, stds, variances)---- | Mean of each of the @d@ columns; @invN@ is @1 / nRows@.-columnMeans :: Int -> Double -> V.Vector (VU.Vector Double) -> VU.Vector Double-columnMeans d invN x = runST $ do-    acc <- VUM.replicate d 0-    V.forM_ x $ \row ->-        VU.iforM_ row $ \j v -> VUM.unsafeModify acc (+ v) j-    scaleInPlace invN acc-    VU.unsafeFreeze acc---- | Variance of each of the @d@ columns about the supplied @means@.-columnVariances ::-    Int ->-    Double ->-    VU.Vector Double ->-    V.Vector (VU.Vector Double) ->-    VU.Vector Double-columnVariances d invN means x = runST $ do-    acc <- VUM.replicate d 0-    V.forM_ x $ \row ->-        VU.iforM_ row $ \j v ->-            let !c = v - VU.unsafeIndex means j in VUM.unsafeModify acc (+ c * c) j-    scaleInPlace invN acc-    VU.unsafeFreeze acc---- | Multiply every element of a mutable vector by @factor@ in place.-scaleInPlace :: Double -> VUM.MVector s Double -> ST s ()-scaleInPlace factor mv = go 0-  where-    go !j-        | 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 ::-    V.Vector (VU.Vector Double) ->-    ( V.Vector (VU.Vector Double)-    , VU.Vector Double-    , VU.Vector Double-    , VU.Vector Double-    )-standardize x-    | V.null x = (x, VU.empty, VU.empty, VU.empty)-    | otherwise =-        let (!means, !stds, !variances) = columnStats x-            !d = VU.length (V.unsafeHead x)-            standardizeRow row =-                VU.generate d $ \j ->-                    (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.--}-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.--}-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)@. Returns @(gradW, gradB)@.--Sample-weighted variant: when @sampleWeights@ is @Just ws@ the per-row-contribution is multiplied by @ws[i]@. With weights of mean 1-(i.e. @Σ w_i = N@; the class-balanced convention used by-'fitLinearCandidate'), the @1/N@ normalisation is preserved exactly.--}-lossGradient ::-    SmoothLoss ->-    Maybe (VU.Vector Double) ->-    V.Vector (VU.Vector Double) ->-    VU.Vector Double ->-    VU.Vector Double ->-    Double ->-    (VU.Vector Double, Double)-lossGradient loss sampleWeights features labels w b = (gradW, gradB)-  where-    !invN = 1 / fromIntegral (V.length features)-    !coeffs = rowCoeffs loss sampleWeights features labels w b invN-    !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]@.--unsafeIndex is safe: @i@ ranges over @[0,n-1]@ and @labels@ /-@sampleWeights@ both have length @n@ by construction.--}-rowCoeffs ::-    SmoothLoss ->-    Maybe (VU.Vector Double) ->-    V.Vector (VU.Vector Double) ->-    VU.Vector Double ->-    VU.Vector Double ->-    Double ->-    Double ->-    VU.Vector Double-rowCoeffs loss sampleWeights features labels w b invN =-    VU.generate (V.length features) $ \i ->-        let !yi = VU.unsafeIndex labels i-            !row = V.unsafeIndex features i-            !z = dotProduct w row + b-            !base = slGradZ loss yi z * invN-         in case sampleWeights of-                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.--}-accumulateGradW ::-    Int -> V.Vector (VU.Vector Double) -> VU.Vector Double -> VU.Vector Double-accumulateGradW d features coeffs = runST $ do-    mv <- VUM.replicate d 0-    V.iforM_ features $ \i row ->-        let !c = VU.unsafeIndex coeffs i-         in VU.iforM_ row $ \j v -> VUM.unsafeModify mv (+ c * v) j-    VU.unsafeFreeze mv--{- | Inner FISTA loop over standardized features. Returns the final @(w, b)@;-the caller is responsible for de-standardization.--@lambda1@ and @lambda2@ are the L1 / L2 penalty strengths; @lp@ is the-Lipschitz constant of the smooth part @(d+1)/4 + λ₂@. The Elastic-Net-proximal step is applied per FHT 2010 glmnet §2.6:-@prox(z) = softThreshold(z, λ₁/lp) / (1 + λ₂/lp)@.--}-fistaLoop ::-    SmoothLoss ->-    Double ->-    Double ->-    Double ->-    Int ->-    Double ->-    Maybe (VU.Vector Double) ->-    V.Vector (VU.Vector Double) ->-    VU.Vector Double ->-    VU.Vector Double ->-    Double ->-    (VU.Vector Double, Double)-fistaLoop loss lambda1 lambda2 lp maxIter tol sampleWeights features labels w0 b0 =-    go 0 w0 b0 w0 b0 1.0-  where-    !shrink = lambda1 / lp-    !ridgeDenom = 1 + lambda2 / lp-    !stepInv = 1 / lp-    proxStep = fistaProxStep loss sampleWeights features labels shrink ridgeDenom stepInv-    go !iter !xWPrev !xBPrev !yW !yB !t-        | iter >= maxIter = (xWPrev, xBPrev)-        | iter > 0 && delta < tol = (xW, xB)-        | otherwise = go (iter + 1) xW xB yWNew yBNew tNew-      where-        (!xW, !xB) = proxStep yW yB-        !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 (soft-threshold then ridge shrinkage), without-materializing the intermediate trial weights.--The Elastic-Net prox of @g(w) = λ₁·|w|₁ + (λ₂/2)·|w|²@ at step @1/lp@ is-@softThreshold(z, λ₁/lp) / (1 + λ₂/lp)@ (FHT 2010 glmnet §2.6; Beck &-Teboulle 2009 §4). The intercept is unregularised (no L1 or L2 applied).--}-fistaProxStep ::-    SmoothLoss ->-    Maybe (VU.Vector Double) ->-    V.Vector (VU.Vector Double) ->-    VU.Vector Double ->-    Double ->-    Double ->-    Double ->-    VU.Vector Double ->-    Double ->-    (VU.Vector Double, Double)-fistaProxStep loss sampleWeights features labels shrink ridgeDenom stepInv yW yB =-    let (gW, gB) = lossGradient loss sampleWeights features labels yW yB-        !wNew =-            VU.zipWith-                (\yi gi -> softThreshold shrink (yi - gi * stepInv) / ridgeDenom)-                yW-                gW-        !bNew = yB - gB * stepInv-     in (wNew, bNew)--{- | Nesterov momentum extrapolation: new look-ahead point @(yW, yB)@ and the-updated step size @t@.--}-fistaMomentum ::-    Double ->-    VU.Vector Double ->-    Double ->-    VU.Vector Double ->-    Double ->-    (VU.Vector Double, Double, Double)-fistaMomentum t xWPrev xBPrev xW xB =-    let !tNew = (1 + sqrt (1 + 4 * t * t)) / 2-        !mom = (t - 1) / tNew-        !yW = VU.zipWith (\new old -> new + mom * (new - old)) xW xWPrev-        !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.--}-{-# 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/DataFrame/LinearSolver/Loss.hs
@@ -1,47 +0,0 @@-{-# 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).--}-module DataFrame.LinearSolver.Loss (-    SmoothLoss (..),-    sigmoid,-    logisticLoss,-    squaredLoss,-    sqHingeLoss,-) where--import qualified Data.Text as T--{- | A convex, @C¹@ per-sample loss @ℓ(y, z)@. 'slGradZ' is @∂ℓ/∂z@;-'slCurvBound' bounds @∂²ℓ/∂z²@ over all @(y, z)@.--}-data SmoothLoss = SmoothLoss-    { slName :: !T.Text-    , slGradZ :: Double -> Double -> Double-    , slCurvBound :: !Double-    }---- | Numerically stable logistic sigmoid.-sigmoid :: Double -> Double-sigmoid z-    | z >= 0 = 1 / (1 + exp (-z))-    | otherwise = let ez = exp z in ez / (1 + ez)---- | Binary logistic loss for labels in @{\-1,+1}@: @ℓ = log(1 + exp(-y z))@.-logisticLoss :: SmoothLoss-logisticLoss =-    SmoothLoss "logistic" (\y z -> negate (y * sigmoid (negate (y * z)))) 0.25---- | Squared error for regression: @ℓ = ½ (z - y)²@.-squaredLoss :: SmoothLoss-squaredLoss = SmoothLoss "squared" (flip (-)) 1.0---- | Squared hinge for classification (LinearSVC default), labels @{\-1,+1}@.-sqHingeLoss :: SmoothLoss-sqHingeLoss =-    SmoothLoss-        "squared_hinge"-        (\y z -> let m = 1 - y * z in if m > 0 then negate (2 * y * m) else 0)-        2.0
src/DataFrame/Model.hs view
@@ -1,5 +1,12 @@+{-# LANGUAGE ConstraintKinds #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE FunctionalDependencies #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE UndecidableInstances #-}  {- | The two verbs every model speaks. Instead of a per-model @fitX@ / @xExpr@ zoo, every estimator is an instance of these classes:@@ -20,50 +27,127 @@ functions — they are not the one canonical prediction, so they are not forced through 'predict'. -Supervised 'fit' treats every non-target column as a feature; use-'selectFeatures' to restrict to an explicit set when the frame also carries ids,-timestamps, or a second candidate target.+'fit' is a single, frame-polymorphic verb: it accepts an untyped 'DataFrame' or a+phantom-typed 'DataFrame.Typed.Types.TypedDataFrame', via 'ToDataFrame'. When a+model declares a schema requirement (via 'FrameReq'), that requirement is checked+at /compile time/ for a typed frame and is a no-op for an untyped one. Linear+regression, for instance, requires an all-'Double' frame ('AllDoubleFrame'), so+@fit@ on a typed frame with a non-'Double' column is a compile error; on an+untyped frame the same mistake surfaces as a fit-time error. -} module DataFrame.Model (     Fit (..),+    ToDataFrame (..),+    Fitted (..),+    FitResult,+    FrameFor,+    FrameKind (..),+    CheckFrame,+    AllDouble,     Predict (..),-    selectFeatures,+    AsTExpr,+    ToTExpr (..), ) where -import qualified Data.Text as T+import Data.Kind (Constraint, Type)  import DataFrame.Internal.DataFrame (DataFrame) import DataFrame.Internal.Expression (Expr (..))-import DataFrame.Operations.Subset (select)+import DataFrame.Typed.Freeze (ToDataFrame (..), thaw)+import DataFrame.Typed.Schema (AllDouble)+import DataFrame.Typed.Types (AsTExpr, TExpr (..), ToTExpr (..), TypedDataFrame) -{- | Train a model. @cfg@ is the hyperparameter config; @input@ is the supervised-target @Expr a@ or the unsupervised feature list @[Expr Double]@. The config and-input together determine the model, so @fit cfg target df@ needs no annotation-(a classifier's label type comes from its @Expr a@ target).+{- | 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.). -}-class Fit cfg input model | cfg input -> model where-    fit :: cfg -> input -> DataFrame -> model+newtype Fitted (cols :: [Type]) model = Fitted {fittedModel :: model} -{- | Compile a fitted model's canonical prediction to an expression over the raw-columns. The result type @r@ is determined by the 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. -}-class Predict model r | model -> r where-    predict :: model -> Expr r+type family FitResult (f :: Type) (model :: Type) :: Type where+    FitResult DataFrame model = model+    FitResult (TypedDataFrame cols) model = Fitted cols model -{- | Restrict @df@ to exactly the named feature columns plus the supervised-target (when the target is a column), so a following 'fit' trains on those-features only.+{- | The frame a given training @input@ is fit against: an untyped target/feature+input pairs with a plain 'DataFrame'; a typed input ('TExpr' / @['TExpr']@) pairs+with a 'TypedDataFrame' over the /same/ schema. So the target expression and the+frame are forced to share their columns at compile time.+-}+type family FrameFor (input :: Type) :: Type where+    FrameFor (Expr a) = DataFrame+    FrameFor (TExpr cols a) = TypedDataFrame cols+    FrameFor [Expr Double] = DataFrame+    FrameFor [TExpr cols Double] = TypedDataFrame cols -Supervised 'fit' otherwise uses /every/ non-target column as a feature —-convenient on a clean frame, a leakage hazard when the frame carries ids,-timestamps, or a second candidate target. This mirrors the explicit-@[Expr Double]@ feature list the unsupervised fitters already take:+{- | The schema requirement a model places on its training frame. 'AnyFrame'+imposes nothing (the default); 'AllDoubleFrame' demands every column be 'Double'.+-}+data FrameKind = AnyFrame | AllDoubleFrame -> model = fit defaultLinearConfig target (selectFeatures ["age", "income"] target df)+{- | 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. -}-selectFeatures :: [T.Text] -> Expr a -> DataFrame -> DataFrame-selectFeatures cols target = select (cols ++ targetCols target)-  where-    targetCols :: Expr b -> [T.Text]-    targetCols (Col n) = [n]-    targetCols _ = []+type family CheckFrame (req :: FrameKind) (f :: Type) :: Constraint where+    CheckFrame _ DataFrame = ()+    CheckFrame 'AnyFrame _ = ()+    CheckFrame 'AllDoubleFrame (TypedDataFrame cols) = AllDouble cols++{- | Train a model. @cfg@ is the hyperparameter config; @input@ is the supervised+target @Expr a@ or the unsupervised feature list @[Expr Double]@; the frame is+any 'ToDataFrame' source (an untyped 'DataFrame' or a 'TypedDataFrame'). The+config and input together determine the model, so no annotation is needed (a+classifier's label type comes from its @Expr a@ target).++A model overrides 'FrameReq' to demand a schema shape (e.g. 'AllDoubleFrame'),+which 'fit' enforces at compile time on a typed frame; the default is 'AnyFrame'.++The frame is determined by the @input@ (via 'FrameFor'): an untyped target pairs+with a 'DataFrame' and yields the bare model; a typed target ('TExpr' over @cols@)+pairs with a @TypedDataFrame cols@ and yields a @Fitted cols model@, so 'predict'+gives a typed 'TExpr'. A non-'Double' typed column is then a compile error.+-}+class Fit cfg input where+    {- | The model this config + input trains, computed as a type family so it is+    visible in the instance head (and so the typed-lift instances below can+    forward it) without needing a result annotation at the call site.+    -}+    type ModelOf cfg input :: Type++    type FrameReq cfg input :: FrameKind+    type FrameReq cfg input = 'AnyFrame+    fit ::+        (CheckFrame (FrameReq cfg input) (FrameFor input)) =>+        cfg ->+        input ->+        FrameFor input ->+        FitResult (FrameFor input) (ModelOf cfg input)++{- | Lift any model fittable on an untyped target @Expr a@ to a typed target+@TExpr cols a@ over a @TypedDataFrame cols@, returning a schema-tagged 'Fitted'.+-}+instance (Fit cfg (Expr a)) => Fit cfg (TExpr cols a) where+    type ModelOf cfg (TExpr cols a) = ModelOf cfg (Expr a)+    type FrameReq cfg (TExpr cols a) = FrameReq cfg (Expr a)+    fit cfg (TExpr e) tdf = Fitted (fit cfg e (thaw tdf))++-- | The same lift for the unsupervised feature-list inputs.+instance (Fit cfg [Expr Double]) => Fit cfg [TExpr cols Double] where+    type ModelOf cfg [TExpr cols Double] = ModelOf cfg [Expr Double]+    type FrameReq cfg [TExpr cols Double] = FrameReq cfg [Expr Double]+    fit cfg feats tdf = Fitted (fit cfg (map unTExpr feats) (thaw tdf))++{- | Compile a fitted model's canonical prediction to an expression over the raw+columns. The 'Prediction' type tracks the model: a bare model gives @Expr r@; a+'Fitted' model (trained on a typed frame) gives @TExpr cols r@.+-}+class Predict model where+    type Prediction model :: Type+    predict :: model -> Prediction model++instance (Predict model, ToTExpr cols (Prediction model)) => Predict (Fitted cols model) where+    type Prediction (Fitted cols model) = AsTExpr cols (Prediction model)+    predict (Fitted m) = toTExpr @cols (predict m)
src/DataFrame/ModelSelection.hs view
@@ -2,10 +2,9 @@ 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' / 'randomSplit' from @dataframe-operations@.+the deterministic 'kFolds' from @dataframe-operations@. -} module DataFrame.ModelSelection (-    trainTestSplit,     crossValScore,     crossValidate,     GridSearchResult (..),@@ -20,13 +19,7 @@ import DataFrame.Internal.Expression (Expr) import DataFrame.Metrics (Metric, evaluate) import DataFrame.Operations.Merge ()-import DataFrame.Operations.Subset (kFolds, randomSplit)--{- | Split into @(train, test)@ with the given training fraction and seed-(deterministic).--}-trainTestSplit :: Double -> Int -> DataFrame -> (DataFrame, DataFrame)-trainTestSplit trainFrac seed = randomSplit (mkStdGen seed) trainFrac+import DataFrame.Operations.Subset (kFolds)  {- | 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.
src/DataFrame/PCA.hs view
@@ -2,6 +2,7 @@ {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeFamilies #-}  {- | Principal component analysis via the symmetric Jacobi eigensolver on the covariance of the (optionally standardized) feature columns. 'fit' trains a@@ -9,6 +10,7 @@ 'pcaExprs' / 'pcaTransform' (PCA is a transformer, so it has no 'Predict'). -} module DataFrame.PCA (+    module DataFrame.Model,     NComponents (..),     PCAConfig (..),     defaultPCAConfig,@@ -27,7 +29,7 @@ import DataFrame.Internal.Expression (Expr (..), UExpr (..)) import DataFrame.LinearAlgebra (gram) import DataFrame.LinearAlgebra.Eigen (jacobiEigenSym)-import DataFrame.Model (Fit (..))+import DataFrame.Model import DataFrame.Operators ((.*.), (.+.), (.-.)) import DataFrame.Transform (Transform (..)) @@ -58,7 +60,8 @@     }     deriving (Eq, Show) -instance Fit PCAConfig [Expr Double] PCAModel where+instance Fit PCAConfig [Expr Double] where+    type ModelOf PCAConfig [Expr Double] = PCAModel     fit = fitPCA  -- | Fit PCA on the given feature columns (each must be a @Col@).
src/DataFrame/PCA/Kernel.hs view
@@ -1,6 +1,7 @@ {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# 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@@ -8,6 +9,7 @@ 'kernelPCAExprs' / 'kernelPcaTransform' (a transformer, so no 'Predict'). -} module DataFrame.PCA.Kernel (+    module DataFrame.Model,     KernelPCAConfig (..),     defaultKernelPCAConfig,     KernelPCAModel (..),@@ -25,7 +27,7 @@ import DataFrame.Internal.Expression (Expr (..), UExpr (..)) import DataFrame.LinearAlgebra (sqDist) import DataFrame.LinearAlgebra.Eigen (jacobiEigenSym)-import DataFrame.Model (Fit (..))+import DataFrame.Model import DataFrame.Operators ((.*.), (.+.), (.-.)) import DataFrame.Random (mkGen, sampleIndices) import DataFrame.Transform (Transform (..))@@ -60,7 +62,8 @@     }     deriving (Eq, Show) -instance Fit KernelPCAConfig [Expr Double] KernelPCAModel where+instance Fit KernelPCAConfig [Expr Double] where+    type ModelOf KernelPCAConfig [Expr Double] = KernelPCAModel     fit = fitKernelPCA  -- | Fit kernel PCA over the given feature columns.
− src/DataFrame/Random.hs
@@ -1,121 +0,0 @@-{-# LANGUAGE CPP #-}--{- | Deterministic, platform-independent random sampling for the stochastic-fitters. Built on @random@'s SplitMix 'StdGen' (only 'genWord64' and a-version-bridged split are used, the stable surface across @random@ versions);-the distributions here are our own so a seeded fit is bit-reproducible on-Linux, macOS, and Windows.--}-module DataFrame.Random (-    Gen,-    mkGen,-    splitGen,-    nextWord64,-    nextDouble,-    nextIntR,-    gaussianPair,-    gaussianVector,-    shuffleInts,-    sampleIndices,-) where--import Control.Monad (forM_)-import Control.Monad.ST (runST)-import Data.Bits (shiftR)-import qualified Data.Vector.Unboxed as VU-import qualified Data.Vector.Unboxed.Mutable as VUM-import Data.Word (Word64)-import System.Random (StdGen, genWord64, mkStdGen)-import qualified System.Random as R---- | The pure splittable generator. A fit is a function of @(seed, data)@.-type Gen = StdGen---- | Seed a generator from an 'Int'.-mkGen :: Int -> Gen-mkGen = mkStdGen---- | Split into two independent generators.-splitGen :: Gen -> (Gen, Gen)-#if MIN_VERSION_random(1,3,0)-splitGen = R.splitGen-#else-splitGen = R.split-#endif---- | Raw 64-bit draw.-nextWord64 :: Gen -> (Word64, Gen)-nextWord64 = genWord64---- | Uniform 'Double' in @[0, 1)@ from the top 53 bits (exact mantissa).-nextDouble :: Gen -> (Double, Gen)-nextDouble g =-    let (w, g') = genWord64 g-        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@.--}-nextIntR :: (Int, Int) -> Gen -> (Int, Gen)-nextIntR (lo, hi) g-    | hi <= lo = (lo, g)-    | otherwise = loop g-  where-    range = fromIntegral (hi - lo + 1) :: Word64-    threshold = negate range `mod` range-    loop gg =-        let (w, gg') = genWord64 gg-         in if w >= threshold-                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.--}-gaussianPair :: Gen -> ((Double, Double), Gen)-gaussianPair g =-    let (u1, g1) = nextDouble g-        (u2, g2) = nextDouble g1-        u1' = if u1 <= 0 then 2.220446049250313e-16 else u1-        r = sqrt (-(2 * log u1'))-        a = 2 * pi * u2-     in ((r * cos a, r * sin a), g2)---- | A length-@n@ vector of standard normals.-gaussianVector :: Int -> Gen -> (VU.Vector Double, Gen)-gaussianVector n g0 = go n g0 []-  where-    go k g acc-        | k <= 0 = (VU.fromList (take n (reverse acc)), g)-        | otherwise =-            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.--}-shuffleInts :: Int -> Gen -> (VU.Vector Int, Gen)-shuffleInts n g0-    | n <= 1 = (VU.enumFromN 0 (max 0 n), g0)-    | otherwise =-        let (swaps, g1) = genSwaps (n - 1) g0 []-            v = runST $ do-                m <- VU.thaw (VU.enumFromN 0 n)-                forM_ swaps $ uncurry (VUM.swap m)-                VU.freeze m-         in (v, g1)-  where-    genSwaps i g acc-        | i < 1 = (reverse acc, g)-        | otherwise =-            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@.--}-sampleIndices :: Int -> Int -> Gen -> (VU.Vector Int, Gen)-sampleIndices k n g =-    let (perm, g') = shuffleInts n g-     in (VU.take (min k n) perm, g')
src/DataFrame/SVM.hs view
@@ -1,18 +1,21 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-} -{- | Linear support vector classification: L2-regularized squared hinge fitted-with the FISTA engine (sklearn's LinearSVC default loss). 'fit' trains a-one-vs-rest 'LinearSVCModel'; 'predict' is the arg-max class margin. There is no-@predict_proba@, matching sklearn's LinearSVC.+{- | 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). -} module DataFrame.SVM (+    module DataFrame.Model,     LinearSVCModel (..),     SVCConfig (..),     defaultSVCConfig,     svcMarginExprs,+    -- | Surfaced by @LinearSVCModel.svcModels@.+    LinearModel (..), ) where  import Data.List (sort)@@ -32,7 +35,7 @@ import DataFrame.Internal.Expression (Expr) import DataFrame.LinearSolver (LinearModel (..), SolverConfig (..), fitProx) import DataFrame.LinearSolver.Loss (sqHingeLoss)-import DataFrame.Model (Fit (..), Predict (..))+import DataFrame.Model  -- | Hyper-parameters. @svcC@ is the inverse regularization strength (sklearn @C@). data SVCConfig = SVCConfig@@ -52,10 +55,12 @@     }     deriving (Eq, Show) -instance (Columnable a, Ord a) => Fit SVCConfig (Expr a) (LinearSVCModel a) where+instance (Columnable a, Ord a) => Fit SVCConfig (Expr a) where+    type ModelOf SVCConfig (Expr a) = (LinearSVCModel a)     fit = fitLinearSVC -instance (Columnable a, Ord a) => Predict (LinearSVCModel a) a where+instance (Columnable a, Ord a) => Predict (LinearSVCModel a) where+    type Prediction (LinearSVCModel a) = Expr a     predict m = argMaxExpr (labelledMargins m)  -- | Fit a one-vs-rest linear SVC.
src/DataFrame/SVM/RFF.hs view
@@ -3,6 +3,7 @@ {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-}  {- | Approximate RBF-kernel SVM via Random Fourier Features (Rahimi & Recht): map@@ -11,6 +12,7 @@ @Σ_r β_r·cos(…)@ expression of size @O(D·d)@, independent of the row count. -} module DataFrame.SVM.RFF (+    module DataFrame.Model,     RFFConfig (..),     defaultRFFConfig,     RFFSVMModel (..),@@ -29,7 +31,7 @@ import DataFrame.LinearAlgebra (dot) import DataFrame.LinearSolver (LinearModel (..), SolverConfig (..), fitProx) import DataFrame.LinearSolver.Loss (sqHingeLoss)-import DataFrame.Model (Fit (..), Predict (..))+import DataFrame.Model import DataFrame.Operators ((.*.), (.+.), (.>.)) import DataFrame.Random (Gen, gaussianVector, mkGen, nextDouble) @@ -69,10 +71,12 @@     }     deriving (Show) -instance (Columnable a, Ord a) => Fit RFFConfig (Expr a) (RFFSVMModel a) where+instance (Columnable a, Ord a) => Fit RFFConfig (Expr a) where+    type ModelOf RFFConfig (Expr a) = (RFFSVMModel a)     fit = fitRFFSVM -instance (Columnable a) => Predict (RFFSVMModel a) a where+instance (Columnable a) => Predict (RFFSVMModel a) where+    type Prediction (RFFSVMModel a) = Expr a     predict m =         If (margin .>. F.lit 0) (Lit (rffPosClass m)) (Lit (rffNegClass m))       where
+ src/DataFrame/Segmented.hs view
@@ -0,0 +1,359 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-}+{-# 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.+-}+module DataFrame.Segmented (+    module DataFrame.Model,+    Segmented (..),+    segmented,+    segmentOn,+    pooled,+    Segment (..),+    SegmentedModel (..),+    SegmentFit (..),+) where++import Data.List (foldl', (\\))+import qualified Data.Map.Strict as M+import Data.Maybe (isJust)+import qualified Data.Set as Set+import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU++import DataFrame.Featurize.Internal (featureNames, numericMatrix, targetDoubles)+import DataFrame.Internal.Column (+    Column (..),+    Columnable,+    bitmapTestBit,+    columnBitmap,+    hasElemType,+ )+import DataFrame.Internal.DataFrame (DataFrame, unsafeGetColumn)+import DataFrame.Internal.Expression (Expr (..))+import DataFrame.Internal.Types (SBool (..), sIntegral)+import DataFrame.LinearAlgebra (Matrix, gram, matVec, tMatVec)+import DataFrame.LinearAlgebra.Solve (choleskySolve, qrLeastSquares)+import DataFrame.LinearModel.Logistic (LogisticConfig)+import DataFrame.LinearModel.Regression (+    LinearConfig (..),+    LinearRegressor (..),+ )+import DataFrame.Model+import DataFrame.Operations.Core (nRows)+import DataFrame.Operations.Subset (columnToTextVec, exclude, rowsAtIndices)+import DataFrame.Operators ((.&&.), (.==.))+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 @λ@.+-}+data Segmented cfg = Segmented+    { segBase :: !cfg+    , segOn :: !(Maybe [T.Text])+    , segMaxCard :: !Int+    , segMinRows :: !Int+    , segPool :: !Double+    }+    deriving (Eq, Show)++-- | Wrap a base config with the defaults: auto-detect, cap 32, min 30 rows, no pooling.+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.+segmentOn :: Segmented cfg -> [T.Text] -> Segmented cfg+segmentOn s cols = s{segOn = Just cols}++-- | Set the pooling strength @λ@ (shrink segments toward the reference).+pooled :: Segmented cfg -> Double -> Segmented cfg+pooled s lam = s{segPool = lam}++-- | One fitted segment: its categorical key, row count, and base model.+data Segment model = Segment+    { segKey :: ![T.Text]+    , segN :: !Int+    , segModel :: !model+    }+    deriving (Show)++{- | A fitted segmented model: 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.+-}+data SegmentedModel a model = SegmentedModel+    { smCatCols :: ![T.Text]+    , smSegments :: ![Segment model]+    , smFellBack :: ![([T.Text], Int)]+    , smFallback :: !model+    , smExpr :: !(Expr a)+    }+    deriving (Show)++{- | How a base estimator fits 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.+-}+class (Fit cfg (Expr a)) => SegmentFit cfg a where+    -- | Fit the qualifying segments (each a numeric-only frame, in order).+    fitSegments :: cfg -> Double -> Expr a -> [DataFrame] -> [ModelOf cfg (Expr a)]+    fitSegments cfg lam target dfs+        | lam == 0 = map (fit cfg target) dfs+        | otherwise =+            error+                "Segmented: pooling (lambda > 0) is not supported for this base model; use lambda = 0 or a linear base."++-- | Logistic segments support independent fitting (lambda = 0) only, for now.+instance (Columnable a, Ord a) => SegmentFit LogisticConfig a++-- | Symbolic-regression segments support independent fitting (lambda = 0) only.+instance SegmentFit SRConfig Double++instance+    ( Fit cfg (Expr a)+    , SegmentFit cfg a+    , Predict (ModelOf cfg (Expr a))+    , Prediction (ModelOf cfg (Expr a)) ~ Expr a+    , Columnable a+    ) =>+    Fit (Segmented cfg) (Expr a)+    where+    type ModelOf (Segmented cfg) (Expr a) = SegmentedModel a (ModelOf cfg (Expr a))+    type FrameReq (Segmented cfg) (Expr a) = 'AnyFrame+    fit = fitSegmented++instance Predict (SegmentedModel a model) where+    type Prediction (SegmentedModel a model) = Expr a+    predict = smExpr++fitSegmented ::+    forall cfg a.+    ( Fit cfg (Expr a)+    , SegmentFit cfg a+    , Predict (ModelOf cfg (Expr a))+    , Prediction (ModelOf cfg (Expr a)) ~ Expr a+    , Columnable a+    ) =>+    Segmented cfg ->+    Expr a ->+    DataFrame ->+    SegmentedModel a (ModelOf cfg (Expr a))+fitSegmented (Segmented base mcols maxCard minRows lam) target df =+    seq (guardNumeric df textCols target) result+  where+    mTarget = case target of+        Col n -> Just n+        _ -> Nothing+    feats = featureNames target df+    textCols = [c | c <- feats, isTextCol df c]+    catCols = resolveCatCols df mTarget textCols mcols maxCard+    numericFrame = exclude textCols+    globalM = fit base target (numericFrame df)+    result+        | null catCols =+            SegmentedModel [] [] [] globalM (predict globalM)+        | otherwise =+            let d = length (feats \\ textCols)+                floor' = max minRows (d + 1)+                grouped = groupByKey df catCols+                (qualifying, undersized) =+                    span' (\(_, ixs) -> VU.length ixs >= floor') grouped+                qualFrames =+                    [numericFrame (rowsAtIndices ixs df) | (_, ixs) <- qualifying]+                segModels = fitSegments base lam target qualFrames+                segments =+                    zipWith+                        (\(k, ixs) m -> Segment k (VU.length ixs) m)+                        qualifying+                        segModels+                fellBack = [(k, VU.length ixs) | (k, ixs) <- undersized]+                expr = buildExpr catCols segments globalM+             in SegmentedModel catCols segments fellBack globalM expr++-- | 'span' over a predicate that need not hold contiguously (a filter partition).+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.+-}+buildExpr ::+    (Columnable a, Predict model, Prediction model ~ Expr a) =>+    [T.Text] ->+    [Segment model] ->+    model ->+    Expr a+buildExpr catCols segs fallback =+    foldr+        (\(Segment key _ m) acc -> If (keyCond catCols key) (predict m) acc)+        (predict fallback)+        segs++-- | @col1 == v1 && col2 == v2 && ...@ for a segment's key.+keyCond :: [T.Text] -> [T.Text] -> Expr Bool+keyCond catCols vals =+    foldr1 (.&&.) [(Col c :: Expr T.Text) .==. Lit v | (c, v) <- zip catCols vals]++{- | The Text feature columns to segment on, 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.+-}+resolveCatCols ::+    DataFrame -> Maybe T.Text -> [T.Text] -> Maybe [T.Text] -> Int -> [T.Text]+resolveCatCols df mTarget textCols mcols maxCard = case mcols of+    Just cols ->+        let bad = filter (\c -> not (isTextCol df c) || Just c == mTarget) cols+         in if null bad+                then cols+                else+                    error+                        ( "Segmented: segmentOn columns must be Text features (not the target); invalid: "+                            ++ show bad+                        )+    Nothing -> [c | c <- textCols, distinctCount df c <= maxCard]++isTextCol :: DataFrame -> T.Text -> Bool+isTextCol df c = hasElemType @T.Text (unsafeGetColumn c df)++distinctCount :: DataFrame -> T.Text -> Int+distinctCount df c =+    Set.size (Set.fromList (V.toList (columnToTextVec (unsafeGetColumn c df))))++{- | Reject feature columns that are neither Text (dropped\/segmented) nor+non-null 'Double', naming each with its fix — clearer than the base fitter's+raw type mismatch.+-}+guardNumeric :: DataFrame -> [T.Text] -> Expr a -> ()+guardNumeric df textCols target =+    case problems of+        [] -> ()+        ps ->+            error+                ( "Segmented: unusable feature column(s):\n"+                    ++ unlines (map fmt ps)+                )+  where+    problems =+        [ (c, r)+        | c <- featureNames target df \\ textCols+        , Just r <- [reason (unsafeGetColumn c df)]+        ]+    fmt (c, r) = "  " ++ T.unpack c ++ ": " ++ r+    reason col+        | isJust (columnBitmap col) =+            Just+                "has missing values — drop them (filterJust / filterAllJust) or model missingness explicitly; imputing risks train/inference skew"+        | isIntegralCol col =+            Just "is an integer column — cast to Double with F.toDouble"+        | not (hasElemType @Double col) =+            Just+                "is not Double — convert to Double (numeric) or segment on it (categorical)"+        | otherwise = Nothing+    isIntegralCol col = case col of+        UnboxedColumn _ (_ :: VU.Vector b) -> case sIntegral @b of+            STrue -> True+            _ -> False+        _ -> False++{- | Group row indices by their composite categorical key, dropping rows whose key+has a null in any segmented column (served by the fallback). Ascending key order.+-}+groupByKey :: DataFrame -> [T.Text] -> [([T.Text], VU.Vector Int)]+groupByKey df catCols =+    map (\(k, is) -> (k, VU.fromList (reverse is))) (M.toAscList grouped)+  where+    n = nRows df+    cols = map (`unsafeGetColumn` df) catCols+    textVecs = map columnToTextVec cols+    bitmaps = map columnBitmap cols+    validRow i = all (maybe True (`bitmapTestBit` i)) bitmaps+    keyOf i = [tv V.! i | tv <- textVecs]+    grouped =+        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.+-}+instance SegmentFit LinearConfig Double where+    fitSegments cfg lam target dfs+        | lam == 0 = map (fit cfg target) dfs+        | null dfs = []+        | otherwise = shrinkLinear cfg lam target dfs++shrinkLinear ::+    LinearConfig -> Double -> Expr Double -> [DataFrame] -> [LinearRegressor]+shrinkLinear cfg lam target dfs = map toReg dsegs+  where+    names = case dfs of+        (d0 : _) -> featureNames target d0+        [] -> error "shrinkLinear: no segments"+    d = length names+    mats = [snd (numericMatrix names dframe) | dframe <- dfs]+    ys = [targetDoubles target dframe | dframe <- dfs]+    ns = map V.length mats+    pooledRows = V.concat mats+    nP = V.length pooledRows+    means =+        VU.generate d $ \j ->+            sum [(pooledRows V.! i) VU.! j | i <- [0 .. nP - 1]] / fromIntegral nP+    sds =+        VU.generate d $ \j ->+            let m = means VU.! j+                var =+                    sum [sq ((pooledRows V.! i) VU.! j - m) | i <- [0 .. nP - 1]]+                        / fromIntegral nP+             in sqrt var+    stdValue j x = let s = sds VU.! j in if s == 0 then 0 else (x - means VU.! j) / s+    augStd row = VU.cons 1 (VU.imap stdValue row)+    zMats = [V.map augStd m | m <- mats]+    olsAll = zipWith fitOLS zMats ys+    fitOLS z y = either (const Nothing) Just (qrLeastSquares z y)+    good = [(n, sol) | (n, Just sol) <- zip ns olsAll]+    totW = fromIntegral (sum (map fst good)) :: Double+    dref+        | null good = VU.replicate (d + 1) 0+        | otherwise =+            VU.generate (d + 1) $ \k ->+                sum [fromIntegral n * (sol VU.! k) | (n, sol) <- good] / totW+    dsegs = zipWith solveSeg zMats ys+    solveSeg z y =+        let r = VU.zipWith (-) y (matVec z dref)+            a = addDiagonal lam (gram z)+            rhs = tMatVec z r+         in case choleskySolve a rhs of+                Just eta -> VU.zipWith (+) dref eta+                Nothing -> dref+    toReg dseg =+        let bStd = dseg VU.! 0+            wStd = VU.drop 1 dseg+            rawCoef = VU.imap (\j w -> let s = sds VU.! j in if s == 0 then 0 else w / s) wStd+            adj =+                sum+                    [ let s = sds VU.! j+                       in if s == 0 then 0 else (wStd VU.! j) * (means VU.! j) / s+                    | j <- [0 .. d - 1]+                    ]+         in LinearRegressor rawCoef (bStd - adj) (V.fromList names) (lcPenalty cfg)++sq :: Double -> Double+sq x = x * x++-- | Add @lam@ to the diagonal of a square matrix.+addDiagonal :: Double -> Matrix -> Matrix+addDiagonal lam = V.imap (\i row -> row VU.// [(i, (row VU.! i) + lam)])
src/DataFrame/SymbolicRegression.hs view
@@ -1,6 +1,7 @@ {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies #-}  {- | Symbolic regression by genetic programming (modelled on the @symbolic-regression@ library, ported dependency-light: no e-graphs, no NLOPT).@@ -8,6 +9,7 @@ accuracy-vs-complexity Pareto front. Deterministic given the seed. -} module DataFrame.SymbolicRegression (+    module DataFrame.Model,     UnOp (..),     SRConfig (..),     defaultSRConfig,@@ -21,7 +23,7 @@ import DataFrame.Featurize.Internal (featureNames, targetDoubles) import DataFrame.Internal.DataFrame (DataFrame) import DataFrame.Internal.Expression (Expr (..))-import DataFrame.Model (Fit (..), Predict (..))+import DataFrame.Model import DataFrame.Operations.Core (columnAsDoubleVector) import DataFrame.Random (mkGen) import DataFrame.SymbolicRegression.Expr (@@ -71,10 +73,12 @@     , srGenerationsRun :: !Int     } -instance Fit SRConfig (Expr Double) SRModel where+instance Fit SRConfig (Expr Double) where+    type ModelOf SRConfig (Expr Double) = SRModel     fit = fitSymbolicRegression -instance Predict SRModel Double where+instance Predict SRModel where+    type Prediction SRModel = Expr Double     predict = srBest  -- | Search for an expression predicting @target@ from the other columns.
− src/DataFrame/SymbolicRegression/Expr.hs
@@ -1,122 +0,0 @@-{-# LANGUAGE FlexibleContexts #-}--{- | The symbolic-regression expression tree: a small first-order ADT (no hegg,-no 'Fix'). Vectorized evaluation over a feature matrix and a total translation-to a dataframe 'Expr Double' (the SR result IS a dataframe expression). Division,-log, and sqrt are protected so evaluation never produces @NaN@.--}-module DataFrame.SymbolicRegression.Expr (-    SRExpr (..),-    BinOp (..),-    UnOp (..),-    evalSR,-    toDataFrameExpr,-    srSize,-    constants,-    setConstants,-    allBinOps,-    allUnOps,-) where--import qualified Data.Text as T-import qualified Data.Vector as V-import qualified Data.Vector.Unboxed as VU--import qualified DataFrame.Functions as F-import DataFrame.Internal.Expression (Expr (..))-import DataFrame.Operators ((.*.), (.+.), (.-.), (./.))--data BinOp = SAdd | SSub | SMul | SDiv-    deriving (Eq, Ord, Show, Enum, Bounded)--data UnOp = SNeg | SSin | SCos | SExp | SLog | SSqrt-    deriving (Eq, Ord, Show, Enum, Bounded)---- | A symbolic-regression expression over feature variables and constants.-data SRExpr-    = SVar !Int-    | SConst !Double-    | SUn !UnOp SRExpr-    | SBin !BinOp SRExpr SRExpr-    deriving (Eq, Ord, Show)--allBinOps :: [BinOp]-allBinOps = [minBound .. maxBound]--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.--}-evalSR :: V.Vector (VU.Vector Double) -> Int -> SRExpr -> VU.Vector Double-evalSR feats n = go-  where-    go (SVar j)-        | j < V.length feats = feats V.! j-        | otherwise = VU.replicate n 0-    go (SConst c) = VU.replicate n c-    go (SUn op e) = VU.map (unFn op) (go e)-    go (SBin op a b) = VU.zipWith (binFn op) (go a) (go b)--binFn :: BinOp -> Double -> Double -> Double-binFn SAdd a b = a + b-binFn SSub a b = a - b-binFn SMul a b = a * b-binFn SDiv a b = if abs b < 1e-9 then 1 else a / b--unFn :: UnOp -> Double -> Double-unFn SNeg = negate-unFn SSin = sin-unFn SCos = cos-unFn SExp = exp . min 50-unFn SLog = \x -> log (abs x + 1e-9)-unFn SSqrt = sqrt . abs---- | Translate to a dataframe expression over the named feature columns.-toDataFrameExpr :: V.Vector T.Text -> SRExpr -> Expr Double-toDataFrameExpr names = go-  where-    go (SVar j)-        | j < V.length names = Col (names V.! j)-        | otherwise = F.lit 0-    go (SConst c) = F.lit c-    go (SUn op e) = unExpr op (go e)-    go (SBin op a b) = binExpr op (go a) (go b)-    unExpr SNeg = negate-    unExpr SSin = sin-    unExpr SCos = cos-    unExpr SExp = exp-    unExpr SLog = log-    unExpr SSqrt = sqrt-    binExpr SAdd = (.+.)-    binExpr SSub = (.-.)-    binExpr SMul = (.*.)-    binExpr SDiv = (./.)--srSize :: SRExpr -> Int-srSize (SVar _) = 1-srSize (SConst _) = 1-srSize (SUn _ e) = 1 + srSize e-srSize (SBin _ a b) = 1 + srSize a + srSize b---- | The constant values in left-to-right traversal order.-constants :: SRExpr -> [Double]-constants (SConst c) = [c]-constants (SVar _) = []-constants (SUn _ e) = constants e-constants (SBin _ a b) = constants a ++ constants b---- | Replace the constants in traversal order; extra values are ignored.-setConstants :: [Double] -> SRExpr -> SRExpr-setConstants vals e = fst (go vals e)-  where-    go vs (SConst _) = case vs of-        (v : rest) -> (SConst v, rest)-        [] -> (SConst 0, [])-    go vs (SVar j) = (SVar j, vs)-    go vs (SUn op a) = let (a', vs') = go vs a in (SUn op a', vs')-    go vs (SBin op a b) =-        let (a', vs') = go vs a-            (b', vs'') = go vs' b-         in (SBin op a' b', vs'')
− src/DataFrame/SymbolicRegression/GP.hs
@@ -1,207 +0,0 @@-{- | A compact generational genetic-programming search over 'SRExpr': ramped-random initialization, tournament selection, subtree crossover and mutation,-elitism, and a complexity-keyed Pareto archive. Deterministic given the seed-(the splitmix generator from "DataFrame.Random").--}-module DataFrame.SymbolicRegression.GP (-    GPParams (..),-    runGP,-) where--import Data.List (foldl', minimumBy, sortBy)-import qualified Data.Map.Strict as M-import Data.Ord (comparing)-import qualified Data.Vector as V-import qualified Data.Vector.Unboxed as VU--import DataFrame.Random (Gen, nextDouble, nextIntR)-import DataFrame.SymbolicRegression.Expr-import DataFrame.SymbolicRegression.Optimize (-    meanSquaredError,-    optimizeConstants,- )-import DataFrame.SymbolicRegression.Simplify (simplify)---- | GP hyper-parameters resolved from the public config.-data GPParams = GPParams-    { gpFeats :: !(V.Vector (VU.Vector Double))-    , gpN :: !Int-    , gpTarget :: !(VU.Vector Double)-    , gpNVars :: !Int-    , gpUnOps :: ![UnOp]-    , gpPopSize :: !Int-    , gpGenerations :: !Int-    , gpMaxSize :: !Int-    , gpTournament :: !Int-    , gpCrossoverP :: !Double-    , gpMutationP :: !Double-    , gpOptimizeP :: !Double-    , gpParsimony :: !Double-    }--type Scored = (SRExpr, Double)---- | Run the search; returns @(best, pareto front, generations run)@.-runGP :: GPParams -> Gen -> (SRExpr, [(Int, Double, SRExpr)], Int)-runGP p g0 =-    let (pop0, g1) = initPop p g0-        scored0 = map (scoreOf p) pop0-        arch0 = foldl' (archiveInsert p) M.empty scored0-        (_, finalArch, gN, _) =-            iterate' 0 scored0 arch0 g1-        best = bestOfArchive finalArch-        front =-            [ (sz, mse, e)-            | (sz, (mse, e)) <- M.toList finalArch-            ]-     in (snd3 best, sortBy (comparing fst3) front, gN)-  where-    iterate' gen pop arch g-        | gen >= gpGenerations p = (pop, arch, gen, g)-        | otherwise =-            let (pop', g') = nextGen p pop g-                arch' = foldl' (archiveInsert p) arch pop'-             in iterate' (gen + 1) pop' arch' g'-    fst3 (a, _, _) = a-    snd3 (_, b, _) = b-    bestOfArchive arch =-        case M.toList arch of-            [] -> (0 :: Int, SConst 0, 1 / 0)-            xs ->-                let (sz, (mse, e)) = minimumBy (comparing (fst . snd)) xs-                 in (sz, e, mse)--scoreOf :: GPParams -> SRExpr -> Scored-scoreOf p e = (e, meanSquaredError (gpFeats p) (gpN p) (gpTarget p) e)--fitness :: GPParams -> Scored -> Double-fitness p (e, mse) = mse + gpParsimony p * fromIntegral (srSize e)--archiveInsert ::-    GPParams -> M.Map Int (Double, SRExpr) -> Scored -> M.Map Int (Double, SRExpr)-archiveInsert _ arch (e, mse)-    | isNaN mse || isInfinite mse = arch-    | otherwise =-        let key = srSize (simplify e)-         in M.insertWith better key (mse, e) arch-  where-    better newv@(m1, _) oldv@(m2, _) = if m1 < m2 then newv else oldv--initPop :: GPParams -> Gen -> ([SRExpr], Gen)-initPop p = go (gpPopSize p) []-  where-    go 0 acc g = (acc, g)-    go k acc g =-        let (depth, g1) = nextIntR (1, 4) g-            (e, g2) = randomExpr p depth g1-         in go (k - 1) (e : acc) g2--randomExpr :: GPParams -> Int -> Gen -> (SRExpr, Gen)-randomExpr p depth g-    | depth <= 1 = randomLeaf p g-    | otherwise =-        let (r, g1) = nextDouble g-         in if r < 0.3-                then randomLeaf p g1-                else-                    let (isUn, g2) = nextDouble g1-                     in if isUn < 0.3 && not (null (gpUnOps p))-                            then-                                let (oi, g3) = nextIntR (0, length (gpUnOps p) - 1) g2-                                    (e, g4) = randomExpr p (depth - 1) g3-                                 in (SUn (gpUnOps p !! oi) e, g4)-                            else-                                let (oi, g3) = nextIntR (0, length allBinOps - 1) g2-                                    (a, g4) = randomExpr p (depth - 1) g3-                                    (b, g5) = randomExpr p (depth - 1) g4-                                 in (SBin (allBinOps !! oi) a b, g5)--randomLeaf :: GPParams -> Gen -> (SRExpr, Gen)-randomLeaf p g =-    let (r, g1) = nextDouble g-     in if r < 0.6 && gpNVars p > 0-            then let (j, g2) = nextIntR (0, gpNVars p - 1) g1 in (SVar j, g2)-            else let (c, g2) = nextDouble g1 in (SConst (c * 4 - 2), g2)--nextGen :: GPParams -> [Scored] -> Gen -> ([Scored], Gen)-nextGen p pop g0 =-    let elite = minimumBy (comparing (fitness p)) pop-        (rest, g1) = go (gpPopSize p - 1) [] g0-     in (elite : rest, g1)-  where-    go 0 acc g = (acc, g)-    go k acc g =-        let (child, g') = breed p pop g-            scored = optimizeMaybe p child g'-         in go (k - 1) (fst scored : acc) (snd scored)--optimizeMaybe :: GPParams -> SRExpr -> Gen -> (Scored, Gen)-optimizeMaybe p e g =-    let (r, g1) = nextDouble g-        e' =-            if r < gpOptimizeP p-                then optimizeConstants (gpFeats p) (gpN p) (gpTarget p) 15 e-                else e-     in (scoreOf p e', g1)--breed :: GPParams -> [Scored] -> Gen -> (SRExpr, Gen)-breed p pop g0 =-    let (pa, g1) = tournament p pop g0-        (doX, g2) = nextDouble g1-        (child, g3) =-            if doX < gpCrossoverP p-                then-                    let (pb, g2') = tournament p pop g2-                        (c, g3') = crossover pa pb g2'-                     in (c, g3')-                else (pa, g2)-        (doM, g4) = nextDouble g3-        (child', g5) =-            if doM < gpMutationP p then mutate p child g4 else (child, g4)-        capped = if srSize child' > gpMaxSize p then pa else child'-     in (simplify capped, g5)--tournament :: GPParams -> [Scored] -> Gen -> (SRExpr, Gen)-tournament p pop g0 =-    let (picks, g1) = pickN (gpTournament p) g0-        chosen = map (pop !!) picks-     in (fst (minimumBy (comparing (fitness p)) chosen), g1)-  where-    n = length pop-    pickN 0 g = ([], g)-    pickN k g =-        let (i, g') = nextIntR (0, n - 1) g-            (is, g'') = pickN (k - 1) g'-         in (i : is, g'')--crossover :: SRExpr -> SRExpr -> Gen -> (SRExpr, Gen)-crossover a b g0 =-    let (ia, g1) = nextIntR (0, srSize a - 1) g0-        (ib, g2) = nextIntR (0, srSize b - 1) g1-        sub = subtreeAt ib b-     in (replaceAt ia a sub, g2)--mutate :: GPParams -> SRExpr -> Gen -> (SRExpr, Gen)-mutate p e g0 =-    let (i, g1) = nextIntR (0, srSize e - 1) g0-        (depth, g2) = nextIntR (1, 3) g1-        (newSub, g3) = randomExpr p depth g2-     in (replaceAt i e newSub, g3)--subtreeAt :: Int -> SRExpr -> SRExpr-subtreeAt 0 e = e-subtreeAt i (SUn _ e) = subtreeAt (i - 1) e-subtreeAt i (SBin _ a b) =-    let sa = srSize a-     in if i <= sa then subtreeAt (i - 1) a else subtreeAt (i - 1 - sa) b-subtreeAt _ e = e--replaceAt :: Int -> SRExpr -> SRExpr -> SRExpr-replaceAt 0 _ new = new-replaceAt i (SUn op e) new = SUn op (replaceAt (i - 1) e new)-replaceAt i (SBin op a b) new =-    let sa = srSize a-     in if i <= sa-            then SBin op (replaceAt (i - 1) a new) b-            else SBin op a (replaceAt (i - 1 - sa) b new)-replaceAt _ e _ = e
− src/DataFrame/SymbolicRegression/Optimize.hs
@@ -1,68 +0,0 @@-{-# 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, which-is where random restarts plus a quasi-Newton step would otherwise be used.--}-module DataFrame.SymbolicRegression.Optimize (-    optimizeConstants,-    meanSquaredError,-) where--import qualified Data.Vector as V-import qualified Data.Vector.Unboxed as VU--import DataFrame.SymbolicRegression.Expr (-    SRExpr,-    constants,-    evalSR,-    setConstants,- )---- | Mean squared error of an expression's predictions against the target.-meanSquaredError ::-    V.Vector (VU.Vector Double) -> Int -> VU.Vector Double -> SRExpr -> Double-meanSquaredError feats n target e =-    let pred = evalSR feats n e-        diff = VU.zipWith (-) pred target-     in VU.sum (VU.map (\x -> x * x) diff) / fromIntegral (max 1 n)---- | Refine an expression's constants to reduce MSE (no-op when constant-free).-optimizeConstants ::-    V.Vector (VU.Vector Double) ->-    Int ->-    VU.Vector Double ->-    Int ->-    SRExpr ->-    SRExpr-optimizeConstants feats n target iters expr-    | null theta0 = expr-    | otherwise = setConstants (descend iters theta0) expr-  where-    theta0 = constants expr-    eps = 1e-6-    mseAt theta = meanSquaredError feats n target (setConstants theta expr)-    descend 0 theta = theta-    descend k theta =-        let f0 = mseAt theta-            g = numGrad theta-            gn = sqrt (sum (map (\x -> x * x) g))-         in if gn < 1e-10-                then theta-                else-                    let theta' = lineSearch theta g f0-                     in if theta' == theta then theta else descend (k - 1) theta'-    numGrad theta =-        [ (mseAt (bump i eps theta) - mseAt (bump i (negate eps) theta)) / (2 * eps)-        | i <- [0 .. length theta - 1]-        ]-    bump i delta theta =-        [if j == i then t + delta else t | (j, t) <- zip [0 ..] theta]-    lineSearch theta g f0 = go (1.0 :: Double)-      where-        go !step-            | step < 1e-8 = theta-            | otherwise =-                let theta' = zipWith (\t gi -> t - step * gi) theta g-                 in if mseAt theta' < f0 then theta' else go (step / 2)
− src/DataFrame/SymbolicRegression/Simplify.hs
@@ -1,65 +0,0 @@-{- | A fuel-bounded, deterministic algebraic simplifier — the dependency-light-stand-in for equality saturation. It is used as a canonical dedup key for the-Pareto archive and to tidy reported expressions; no confluence is claimed, only-that it is a total, size-non-increasing, idempotent function whose rewrites-preserve evaluation.--}-module DataFrame.SymbolicRegression.Simplify (-    simplify,-) where--import DataFrame.SymbolicRegression.Expr (BinOp (..), SRExpr (..), UnOp (..))---- | Simplify to a fixed point (bounded by a fuel counter).-simplify :: SRExpr -> SRExpr-simplify = go (10 :: Int)-  where-    go 0 e = e-    go fuel e =-        let e' = step e-         in if e' == e then e else go (fuel - 1) e'--step :: SRExpr -> SRExpr-step (SUn op e) = simplifyUn op (step e)-step (SBin op a b) = simplifyBin op (step a) (step b)-step e = e--simplifyUn :: UnOp -> SRExpr -> SRExpr-simplifyUn SNeg (SUn SNeg e) = e-simplifyUn op (SConst c) = SConst (foldUn op c)-simplifyUn op e = SUn op e--simplifyBin :: BinOp -> SRExpr -> SRExpr -> SRExpr-simplifyBin op (SConst a) (SConst b) = SConst (foldBin op a b)-simplifyBin SAdd a (SConst 0) = a-simplifyBin SAdd (SConst 0) b = b-simplifyBin SSub a (SConst 0) = a-simplifyBin SSub a b | a == b = SConst 0-simplifyBin SMul _ (SConst 0) = SConst 0-simplifyBin SMul (SConst 0) _ = SConst 0-simplifyBin SMul a (SConst 1) = a-simplifyBin SMul (SConst 1) b = b-simplifyBin SDiv a (SConst 1) = a-simplifyBin SDiv a b | a == b = SConst 1-simplifyBin op a b-    | commutative op && a > b = SBin op b a-    | otherwise = SBin op a b--commutative :: BinOp -> Bool-commutative SAdd = True-commutative SMul = True-commutative _ = False--foldBin :: BinOp -> Double -> Double -> Double-foldBin SAdd a b = a + b-foldBin SSub a b = a - b-foldBin SMul a b = a * b-foldBin SDiv a b = if abs b < 1e-9 then 1 else a / b--foldUn :: UnOp -> Double -> Double-foldUn SNeg = negate-foldUn SSin = sin-foldUn SCos = cos-foldUn SExp = exp . min 50-foldUn SLog = \x -> log (abs x + 1e-9)-foldUn SSqrt = sqrt . abs
src/DataFrame/Synthesis.hs view
@@ -2,6 +2,7 @@ {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies #-}  {- | Feature synthesis by bottom-up enumerative search with observational equivalence — the canonical enumerative method from Solar-Lezama's@@ -39,6 +40,7 @@ for very large frames, and piecewise (condition-abduction) features. -} module DataFrame.Synthesis (+    module DataFrame.Model,     LossFunction (..),     SynthesisConfig (..),     defaultSynthesisConfig,@@ -67,7 +69,7 @@     percentile',     variance',  )-import DataFrame.Model (Fit (..), Predict (..))+import DataFrame.Model import DataFrame.Operations.Core (columnAsDoubleVector)  -- | How a candidate's output column is scored against the target (higher is better).@@ -110,10 +112,12 @@     , sfFeatures :: ![(Expr Double, Double)]     } -instance Fit SynthesisConfig (Expr Double) SynthesizedFeature where+instance Fit SynthesisConfig (Expr Double) where+    type ModelOf SynthesisConfig (Expr Double) = SynthesizedFeature     fit = synthesizeFeatures -instance Predict SynthesizedFeature Double where+instance Predict SynthesizedFeature where+    type Prediction SynthesizedFeature = Expr Double     predict = sfExpr  -- | A candidate's evaluated column over the example rows.
+ src/DataFrame/Transform/Serialize.hs view
@@ -0,0 +1,42 @@+{- | 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+-}+module DataFrame.Transform.Serialize (+    encodeTransform,+    decodeTransform,+    saveTransformToFile,+    loadTransformFromFile,+) where++import qualified Data.Aeson as Aeson++import DataFrame.Expr.Serialize (+    decodeNamedExprs,+    encodeNamedExprs,+    loadPipelineFromFile,+    savePipelineToFile,+ )+import DataFrame.Transform (Transform (..))++-- | Encode a transform's output expressions to JSON.+encodeTransform :: Transform -> Either String Aeson.Value+encodeTransform = encodeNamedExprs . transformOutputs++-- | Decode a transform produced by 'encodeTransform'.+decodeTransform :: Aeson.Value -> Either String Transform+decodeTransform = fmap Transform . decodeNamedExprs++-- | Encode a transform and write it to a file. No file is written on failure.+saveTransformToFile :: FilePath -> Transform -> IO (Either String ())+saveTransformToFile fp = savePipelineToFile fp . transformOutputs++-- | Load a transform produced by 'saveTransformToFile'.+loadTransformFromFile :: FilePath -> IO (Either String Transform)+loadTransformFromFile fp = fmap (fmap Transform) (loadPipelineFromFile fp)
+ tests-internal/Cart.hs view
@@ -0,0 +1,104 @@+{-# 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.+-}+module Cart (tests) where++import Control.Exception (SomeException, try)+import Data.Aeson (FromJSON (..), eitherDecode, withObject, (.:))+import qualified Data.ByteString.Lazy as BL+import qualified Data.Text as T+import qualified Data.Vector as V+import Test.HUnit++import DataFrame.DecisionTree (+    TreeConfig (..),+    defaultTreeConfig,+ )+import DataFrame.DecisionTree.Cart (buildCartTree)+import DataFrame.DecisionTree.Predict (predictManyWithTree)+import qualified DataFrame.Operations.Subset as DSub+import qualified DataFrameApi as D++data Fold = Fold ![Int] ![Int]+instance FromJSON Fold where+    parseJSON = withObject "fold" $ \o -> Fold <$> o .: "train" <*> o .: "test"++newtype Folds = Folds [Fold]+instance FromJSON Folds where+    parseJSON = withObject "folds" $ \o -> Folds <$> o .: "folds"++data Fixture = Fixture ![Int] ![T.Text]+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).+cartCfg :: TreeConfig+cartCfg = defaultTreeConfig{maxTreeDepth = 4, minLeafSize = 1}++cartCases :: [(String, Int)]+cartCases =+    [("wine", i) | i <- [0 .. 4]] ++ [("bcw", i) | i <- [0 .. 4]] ++ [("adult", 0)]++tests :: [Test]+tests =+    [ TestLabel ("cart: " ++ n ++ " fold " ++ show i) (TestCase (runCase n i))+    | (n, i) <- cartCases+    ]++readJson :: (FromJSON a) => FilePath -> IO (Either String a)+readJson fp = do+    e <- try (BL.readFile fp) :: IO (Either SomeException BL.ByteString)+    pure $ case e of+        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.+runCase :: String -> Int -> IO ()+runCase name i = do+    efx <- readJson ("tests/fixtures/cart/" ++ name ++ "_fold" ++ show i ++ ".json")+    case efx of+        Left "missing" ->+            putStrLn+                ( "  [skip] cart "+                    ++ name+                    ++ " fold "+                    ++ show i+                    ++ ": fixture missing (run bench/export_cart_fixtures.py)"+                )+        Left e -> assertFailure ("fixture parse (" ++ name ++ "): " ++ e)+        Right (Fixture _ predExpected) -> do+            efolds <- readJson ("data/folds/" ++ name ++ ".json")+            case efolds of+                Left e -> assertFailure ("folds parse (" ++ name ++ "): " ++ e)+                Right (Folds fs) -> do+                    df <- D.readCsv ("data/uci/" ++ name ++ "_clean.csv")+                    let Fold trainIdx testIdx = fs !! i+                        trainDf = DSub.selectRows trainIdx df+                        tree = buildCartTree @Int cartCfg "target" trainDf+                        preds =+                            map+                                (T.pack . show)+                                (V.toList (predictManyWithTree tree df (V.fromList testIdx)))+                    if name == "wine"+                        then assertEqual ("cart " ++ name ++ " fold " ++ show i) predExpected preds+                        else do+                            let n = length predExpected+                                m = length (filter id (zipWith (==) predExpected preds))+                            putStrLn+                                ( "  [diagnostic] cart "+                                    ++ name+                                    ++ " fold "+                                    ++ show i+                                    ++ ": "+                                    ++ show m+                                    ++ "/"+                                    ++ show n+                                    ++ " predictions match sklearn(random_state=0) (remainder = sklearn's seeded equal-gain tie-break)"+                                )
+ tests-internal/DataFrameApi.hs view
@@ -0,0 +1,22 @@+{- | 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.+-}+module DataFrameApi (+    DataFrame,+    fromNamedColumns,+    fromUnnamedColumns,+    dimensions,+    nRows,+    rename,+    exclude,+    randomSplit,+    derive,+    readCsv,+) where++import DataFrame.Core (DataFrame, fromNamedColumns)+import DataFrame.IO.CSV (readCsv)+import DataFrame.Operations.Core (dimensions, fromUnnamedColumns, nRows, rename)+import DataFrame.Operations.Subset (exclude, randomSplit)+import DataFrame.Operations.Transformations (derive)
+ tests-internal/DecisionTree.hs view
@@ -0,0 +1,1360 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++module DecisionTree where++import DataFrame.DecisionTree+import DataFrame.DecisionTree.Cart (buildCartTree)+import DataFrame.DecisionTree.Categorical (+    TargetInfo,+    discreteConditions,+    mkTargetInfo,+ )+import DataFrame.DecisionTree.CondVec (+    CondVec (..),+    combineAndVec,+    combineOrVec,+    materializeCondVec,+ )+import DataFrame.DecisionTree.Fit (+    ProbTree,+    buildProbTree,+    fitDecisionTree,+    fitProbTree,+    probExprs,+    probsFromIndices,+ )+import DataFrame.DecisionTree.Numeric (+    NumExpr (NMaybeDouble),+    generateNumericConds,+    numericCols,+    numericExprsWithTerms,+ )+import DataFrame.DecisionTree.Predict (+    computeTreeLoss,+    countCarePointErrors,+    identifyCarePoints,+    majorityValueFromIndices,+    partitionIndices,+    predictWithTree,+ )+import DataFrame.DecisionTree.Tao (taoIteration, taoOptimize)+import DataFrame.DecisionTree.Types (CarePoint (..), Direction (..))+import qualified DataFrame.Functions as F+import qualified DataFrame.Internal.Column as DI+import DataFrame.Internal.Expression (Expr (..), eqExpr, getColumns)+import DataFrame.Internal.Interpreter (interpret)+import qualified DataFrame.LinearSolver+import DataFrame.Operators+import qualified DataFrameApi as D++import Data.Function (on)+import Data.List (maximumBy, sort)+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 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.+-}+requireTargetInfo :: T.Text -> D.DataFrame -> TargetInfo T.Text+requireTargetInfo target df = case mkTargetInfo @T.Text target df of+    Just ti -> ti+    Nothing -> error ("requireTargetInfo: no target info for " <> T.unpack target)++-- 4 rows: label = ["A","B","A","C"], x = [1.0,2.0,3.0,4.0]+fixtureDF :: D.DataFrame+fixtureDF =+    D.fromNamedColumns+        [ ("label", DI.fromList (["A", "B", "A", "C"] :: [T.Text]))+        , ("x", DI.fromList ([1.0, 2.0, 3.0, 4.0] :: [Double]))+        ]++allIndices :: V.Vector Int+allIndices = V.fromList [0, 1, 2, 3]++leftTree :: Tree T.Text+leftTree = Leaf "A"++rightTree :: Tree T.Text+rightTree = Leaf "B"++-- x <= 2.5: True for idx 0,1 (→ left); False for idx 2,3 (→ right)+splitCond :: Expr Bool+splitCond = F.col @Double "x" .<= F.lit (2.5 :: Double)++-- Pre-computed care points for the full fixture+carePoints3 :: [CarePoint]+carePoints3 =+    identifyCarePoints @T.Text "label" fixtureDF allIndices leftTree rightTree++------------------------------------------------------------------------+-- Unit tests: identifyCarePoints+------------------------------------------------------------------------++carePointsBothWrong :: Test+carePointsBothWrong =+    TestCase $+        assertBool+            "idx 3 (label=C, neither A nor B) should not be a care point"+            (3 `notElem` map cpIndex carePoints3)++carePointsLeftCorrect :: Test+carePointsLeftCorrect = TestCase $ do+    let cp0 = filter ((== 0) . cpIndex) carePoints3+    case cp0 of+        (c : _) ->+            assertEqual+                "idx 0 (label=A matches left Leaf A) should route GoLeft"+                GoLeft+                (cpCorrectDir c)+        [] -> assertFailure "idx 0 should be a care point"++carePointsRightCorrect :: Test+carePointsRightCorrect = TestCase $ do+    let cp1 = filter ((== 1) . cpIndex) carePoints3+    case cp1 of+        (c : _) ->+            assertEqual+                "idx 1 (label=B matches right Leaf B) should route GoRight"+                GoRight+                (cpCorrectDir c)+        [] -> assertFailure "idx 1 should be a care point"++carePointsMixed :: Test+carePointsMixed = TestCase $ do+    assertEqual "exactly 3 care points" 3 (length carePoints3)+    let idxs = map cpIndex carePoints3+    assertBool "idx 0 present" (0 `elem` idxs)+    assertBool "idx 1 present" (1 `elem` idxs)+    assertBool "idx 2 present" (2 `elem` idxs)+    assertBool "idx 3 absent" (3 `notElem` idxs)++carePointsBothCorrect :: Test+carePointsBothCorrect = TestCase $ do+    let df2 =+            D.fromNamedColumns+                [ ("label", DI.fromList (["A", "A"] :: [T.Text]))+                , ("x", DI.fromList ([1.0, 2.0] :: [Double]))+                ]+        cps =+            identifyCarePoints @T.Text+                "label"+                df2+                (V.fromList [0, 1])+                (Leaf "A")+                (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+                [ ("label", DI.fromList (["cat", "dog", "cat", "cat"] :: [T.Text]))+                , ("x", DI.fromList ([1.0, 2.0, 3.0, 4.0] :: [Double]))+                ]+    assertEqual+        "majority is cat (3 votes)"+        "cat"+        (majorityValueFromIndices @T.Text "label" df (V.fromList [0, 1, 2, 3]))++majorityVoteSubset :: Test+majorityVoteSubset = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("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+                [ ("label", DI.fromList (["A", "A", "B", "B"] :: [T.Text]))+                , ("x", DI.fromList ([1.0, 2.0, 3.0, 4.0] :: [Double]))+                ]+        stump = Branch splitCond (Leaf "A") (Leaf "B") :: Tree T.Text+        loss = computeTreeLoss @T.Text "label" df (V.fromList [0, 1, 2, 3]) stump+    assertEqual "perfect stump has zero loss" 0.0 loss++computeLossHalf :: Test+computeLossHalf = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("label", DI.fromList (["A", "A", "B", "B"] :: [T.Text]))+                , ("x", DI.fromList ([1.0, 2.0, 3.0, 4.0] :: [Double]))+                ]+        constTree = Leaf "A" :: Tree T.Text+        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+        leftSet = V.toList lft+        rightSet = V.toList rgt+        intersection = filter (`elem` rightSet) leftSet+    assertEqual "left and right partitions are disjoint" [] intersection++partitionUnion :: Test+partitionUnion = TestCase $ do+    let (lft, rgt) = partitionIndices splitCond fixtureDF allIndices+        combined = sort (V.toList lft ++ V.toList rgt)+    assertEqual+        "union of partitions equals the original index set"+        [0, 1, 2, 3]+        combined++------------------------------------------------------------------------+-- Unit tests: countCarePointErrors+------------------------------------------------------------------------++countErrorsAllCorrect :: Test+countErrorsAllCorrect = TestCase $ do+    let cps = [CarePoint 0 GoLeft, CarePoint 1 GoRight]+        cond = F.col @Double "x" .<= F.lit (1.5 :: Double)+        errs = countCarePointErrors cond fixtureDF cps+    assertEqual "condition routes all care points correctly" 0 errs++countErrorsAllWrong :: Test+countErrorsAllWrong = TestCase $ do+    let cps = [CarePoint 0 GoLeft, CarePoint 1 GoRight]+        cond = F.col @Double "x" .> F.lit (1.5 :: Double)+        errs = countCarePointErrors cond fixtureDF cps+    assertEqual "reversed condition misroutes all care points" 2 errs++------------------------------------------------------------------------+-- Unit tests: predictWithTree+------------------------------------------------------------------------++predictLeaf :: Test+predictLeaf =+    TestCase $+        assertEqual+            "leaf prediction ignores row index"+            "Z"+            (predictWithTree @T.Text "label" fixtureDF 0 (Leaf "Z"))++predictBranch :: Test+predictBranch = TestCase $ do+    let stump = Branch splitCond (Leaf "A") (Leaf "B") :: Tree T.Text+    assertEqual+        "idx 0 (x=1.0 <= 2.5) routes left -> A"+        "A"+        (predictWithTree @T.Text "label" fixtureDF 0 stump)+    assertEqual+        "idx 3 (x=4.0 > 2.5) routes right -> B"+        "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+            [ ("label", DI.fromList labels)+            , ("x", DI.fromList xs)+            ]++-- Candidate conditions that bracket the decision boundary+sepConds :: [Expr Bool]+sepConds =+    [ F.col @Double "x" .<= F.lit (10.5 :: Double)+    , F.col @Double "x" .> F.lit (10.5 :: Double)+    ]++testCfg :: TreeConfig+testCfg =+    defaultTreeConfig+        { taoIterations = 5+        , expressionPairs = 4+        , minLeafSize = 1+        }++-- Initial tree deliberately wrong: routes "pos" rows to the "neg" leaf+wrongStump :: Tree T.Text+wrongStump =+    Branch+        (F.col @Double "x" .> F.lit (10.5 :: Double))+        (Leaf "pos")+        (Leaf "neg")++taoNoDegradation :: Test+taoNoDegradation = TestCase $ do+    let indices = V.enumFromN 0 20+        initialLoss = computeTreeLoss @T.Text "label" sepDF indices wrongStump+        optimized =+            taoOptimize @T.Text testCfg "label" sepConds sepDF indices wrongStump+        finalLoss = computeTreeLoss @T.Text "label" sepDF indices optimized+    assertBool+        "taoOptimize must not increase loss"+        (finalLoss <= initialLoss + 1e-9)++taoMonotone :: Test+taoMonotone = TestCase $ do+    let indices = V.enumFromN 0 20+        initLoss = computeTreeLoss @T.Text "label" sepDF indices wrongStump+        stepTree = taoIteration @T.Text testCfg "label" sepConds sepDF indices+        step (tree, _) =+            let tree' = stepTree tree+             in (tree', computeTreeLoss @T.Text "label" sepDF indices tree')+        snapshots = take 6 $ iterate step (wrongStump, initLoss)+        losses = map snd snapshots+        pairs = zip losses (drop 1 losses)+    assertBool+        "loss must be non-increasing across taoIteration steps"+        (all (\(a, b) -> b <= a + 1e-9) pairs)++taoConvergesPureLabels :: Test+taoConvergesPureLabels = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("label", DI.fromList (replicate 10 ("A" :: T.Text)))+                , ("x", DI.fromList ([1.0 .. 10.0] :: [Double]))+                ]+        indices = V.enumFromN 0 10+        initTree = Leaf "A" :: Tree T.Text+        initLoss = computeTreeLoss @T.Text "label" df indices initTree+        result =+            taoOptimize @T.Text testCfg "label" sepConds df indices initTree+        finalLoss = computeTreeLoss @T.Text "label" df indices result+    assertEqual "pure-label initial loss must be zero" 0.0 initLoss+    assertEqual "pure-label final loss must still be zero" 0.0 finalLoss++taoDeadBranchNoCrash :: Test+taoDeadBranchNoCrash = TestCase $ do+    let badCond = F.col @Double "x" .<= F.lit (0.5 :: Double)+        indices = V.enumFromN 0 20+        initTree = Branch badCond (Leaf "pos") (Leaf "neg") :: Tree T.Text+        result =+            taoOptimize @T.Text testCfg "label" [badCond] sepDF indices initTree+        finalLoss = computeTreeLoss @T.Text "label" sepDF indices result+    assertBool+        "dead-branch tree must produce a valid loss in [0,1]"+        (finalLoss >= 0.0 && finalLoss <= 1.0)++------------------------------------------------------------------------+-- Shared fixtures: 4x4 grid+------------------------------------------------------------------------++gridPairs :: [(Double, Double)]+gridPairs = [(x, y) | y <- [1 .. 4], x <- [1 .. 4]]++gridBaseDF :: D.DataFrame+gridBaseDF =+    D.fromNamedColumns+        [ ("x", DI.fromList (map fst gridPairs))+        , ("y", DI.fromList (map snd gridPairs))+        ]++------------------------------------------------------------------------+-- Oblique recovery tests+------------------------------------------------------------------------++taoRecoversSingleObliqueDerived :: Test+taoRecoversSingleObliqueDerived = TestCase $ do+    let labelExpr =+            F.ifThenElse+                ((F.col @Double "x" + F.col @Double "y") .<= F.lit (4.5 :: Double))+                (F.lit ("pos" :: T.Text))+                (F.lit ("neg" :: T.Text))+        df = D.derive @T.Text "label" labelExpr gridBaseDF+        indices = V.enumFromN 0 16+        initTree =+            Branch+                (F.col @Double "x" .<= F.lit (2.5 :: Double))+                (Leaf "pos")+                (Leaf "neg") ::+                Tree T.Text+        conds =+            [ (F.col @Double "x" + F.col @Double "y") .<= F.lit (4.5 :: Double)+            , (F.col @Double "x" + F.col @Double "y") .> F.lit (4.5 :: Double)+            ]+        cfg = defaultTreeConfig{taoIterations = 5, expressionPairs = 4, minLeafSize = 1}+        result = taoOptimize @T.Text cfg "label" conds df indices initTree+        finalLoss = computeTreeLoss @T.Text "label" df indices result+    assertEqual+        "TAO recovers single oblique (x+y) split with zero loss"+        0.0+        finalLoss++taoRecoversNestedObliqueDerived :: Test+taoRecoversNestedObliqueDerived = TestCase $ do+    let labelExpr =+            F.ifThenElse+                ((F.col @Double "x" + F.col @Double "y") .<= F.lit (4.5 :: Double))+                (F.lit ("low" :: T.Text))+                ( F.ifThenElse+                    ((F.col @Double "x" - F.col @Double "y") .<= F.lit (0.5 :: Double))+                    (F.lit "mid")+                    (F.lit "high")+                )+        df = D.derive @T.Text "label" labelExpr gridBaseDF+        indices = V.enumFromN 0 16+        initTree =+            Branch+                (F.col @Double "x" .<= F.lit (1.5 :: Double))+                (Leaf "low")+                ( Branch+                    (F.col @Double "y" .<= F.lit (3.5 :: Double))+                    (Leaf "mid")+                    (Leaf "high")+                ) ::+                Tree T.Text+        conds =+            [ (F.col @Double "x" + F.col @Double "y") .<= F.lit (4.5 :: Double)+            , (F.col @Double "x" + F.col @Double "y") .> F.lit (4.5 :: Double)+            , (F.col @Double "x" - F.col @Double "y") .<= F.lit (0.5 :: Double)+            , (F.col @Double "x" - F.col @Double "y") .> F.lit (0.5 :: Double)+            ]+        cfg = defaultTreeConfig{taoIterations = 5, expressionPairs = 4, minLeafSize = 1}+        result = taoOptimize @T.Text cfg "label" conds df indices initTree+        finalLoss = computeTreeLoss @T.Text "label" df indices result+    assertEqual+        "TAO recovers nested oblique (x+y)/(x-y) tree with zero loss"+        0.0+        finalLoss++-- Shared setup for C2 (a) and (b): axis-aligned pool only, oblique label.+obliqueAxisAlignedFixture ::+    (D.DataFrame, V.Vector Int, [Expr Bool], Tree T.Text)+obliqueAxisAlignedFixture =+    let labelExpr =+            F.ifThenElse+                ((F.col @Double "x" + F.col @Double "y") .<= F.lit (4.5 :: Double))+                (F.lit ("pos" :: T.Text))+                (F.lit ("neg" :: T.Text))+        df = D.derive @T.Text "label" labelExpr gridBaseDF+        indices = V.enumFromN 0 16+        axisConds =+            [F.col @Double "x" .<= F.lit (t :: Double) | t <- [1.5, 2.5, 3.5]]+                ++ [F.col @Double "y" .<= F.lit (t :: Double) | t <- [1.5, 2.5, 3.5]]+        initTree =+            Branch+                (F.col @Double "x" .<= F.lit (2.5 :: Double))+                (Leaf "pos")+                (Leaf "neg") ::+                Tree T.Text+     in (df, indices, axisConds, initTree)++-- C2 (a): with the linear solver OFF, axis-aligned pool cannot recover the+-- oblique decision boundary. Preserves the original guarantee of the test.+taoAxisAlignedInsufficientForObliqueDiscreteOnly :: Test+taoAxisAlignedInsufficientForObliqueDiscreteOnly = TestCase $ do+    let (df, indices, axisConds, initTree) = obliqueAxisAlignedFixture+        cfg =+            defaultTreeConfig+                { taoIterations = 10+                , expressionPairs = 6+                , minLeafSize = 1+                , useLinearSolver = False+                }+        result = taoOptimize @T.Text cfg "label" axisConds df indices initTree+        finalLoss = computeTreeLoss @T.Text "label" df indices result+    assertBool+        "axis-aligned stump cannot recover oblique label without linear solver (loss > 0.1)"+        (finalLoss > 0.1)++-- C2 (b): with the linear solver ON, the L1-LR fit discovers the oblique+-- (x + y) hyperplane even though only axis-aligned conditions are in the pool.+taoLinearRecoversObliqueFromAxisAlignedPool :: Test+taoLinearRecoversObliqueFromAxisAlignedPool = TestCase $ do+    let (df, indices, axisConds, initTree) = obliqueAxisAlignedFixture+        cfg =+            defaultTreeConfig+                { taoIterations = 10+                , expressionPairs = 6+                , minLeafSize = 1+                , useLinearSolver = True+                , minCarePointsForLinear = 2+                }+        result = taoOptimize @T.Text cfg "label" axisConds df indices initTree+        finalLoss = computeTreeLoss @T.Text "label" df indices result+    assertEqual+        "linear solver recovers oblique split from axis-aligned-only pool"+        0.0+        finalLoss++------------------------------------------------------------------------+-- Nullable numeric feature tests+------------------------------------------------------------------------++-- Cleanly separable nullable column (no actual nulls): Just 1..6 -> "pos",+-- Just 7..12 -> "neg". Exercises the nullable numeric path.+nullableSepDF :: D.DataFrame+nullableSepDF =+    D.fromNamedColumns+        [ ("label", DI.fromList (replicate 6 "pos" ++ replicate 6 "neg" :: [T.Text]))+        ,+            ( "x"+            , DI.fromVector+                ( V.fromList $+                    map (Just . fromIntegral) ([1 .. 6] :: [Int])+                        ++ map (Just . fromIntegral) ([7 .. 12] :: [Int]) ::+                    V.Vector (Maybe Double)+                )+            )+        ]++-- DF with genuine nulls interspersed.+nullsMixedDF :: D.DataFrame+nullsMixedDF =+    D.fromNamedColumns+        [ ("label", DI.fromList (["pos", "pos", "pos", "neg", "neg", "neg"] :: [T.Text]))+        ,+            ( "x"+            , DI.fromVector+                ( V.fromList+                    [Just 1.0, Nothing, Just 3.0, Just 7.0, Nothing, Just 9.0] ::+                    V.Vector (Maybe Double)+                )+            )+        ]++-- numericCols picks up DI.fromVector (Maybe Double) as NMaybeDouble.+numericColsNullableDoubleTest :: Test+numericColsNullableDoubleTest = TestCase $ do+    let exprs = numericCols nullableSepDF+        hasMD = any (\case NMaybeDouble _ -> True; _ -> False) exprs+    assertBool+        "numericCols finds NMaybeDouble for DI.fromVector (Maybe Double)"+        hasMD++-- numericCols picks up DI.fromVector (Maybe Int) as NMaybeDouble (via whenPresent).+numericColsNullableIntTest :: Test+numericColsNullableIntTest = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("label", DI.fromList (["pos", "neg"] :: [T.Text]))+                ,+                    ( "n"+                    , DI.fromVector (V.fromList [Just (1 :: Int), Just 2] :: V.Vector (Maybe Int))+                    )+                ]+        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).+numericCondsNullableNonEmptyTest :: Test+numericCondsNullableNonEmptyTest =+    TestCase $+        assertBool+            "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).+nullValueRoutesFalseTest :: Test+nullValueRoutesFalseTest = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("label", DI.fromList (["A", "B"] :: [T.Text]))+                ,+                    ( "x"+                    , DI.fromVector+                        (V.fromList [Nothing, Just (5.0 :: Double)] :: V.Vector (Maybe Double))+                    )+                ]+        cond = F.fromMaybe False (F.col @(Maybe Double) "x" .<= F.lit (6.0 :: Double))+        (lft, rgt) = partitionIndices cond df (V.fromList [0, 1])+    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.+nullableFitZeroLossTest :: Test+nullableFitZeroLossTest = TestCase $ do+    let cfg = defaultTreeConfig{taoIterations = 5, expressionPairs = 4, minLeafSize = 1}+        featureDf = D.exclude ["label"] nullableSepDF+        conds = generateNumericConds cfg featureDf+        initTree = buildCartTree @T.Text cfg "label" nullableSepDF+        indices = V.enumFromN 0 12+        result = taoOptimize @T.Text cfg "label" conds nullableSepDF indices initTree+        loss = computeTreeLoss @T.Text "label" nullableSepDF indices result+    assertEqual "zero loss on cleanly separable OptionalColumn data" 0.0 loss++-- fitDecisionTree with genuine nulls: loss is a valid probability and no crash.+nullableFitWithNullsNoCrashTest :: Test+nullableFitWithNullsNoCrashTest = TestCase $ do+    let cfg = defaultTreeConfig{taoIterations = 3, expressionPairs = 4, minLeafSize = 1}+        featureDf = D.exclude ["label"] nullsMixedDF+        conds = generateNumericConds cfg featureDf+        initTree = buildCartTree @T.Text cfg "label" nullsMixedDF+        indices = V.enumFromN 0 6+        result = taoOptimize @T.Text cfg "label" conds nullsMixedDF indices initTree+        loss = computeTreeLoss @T.Text "label" nullsMixedDF indices result+    assertBool+        "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.+numericExprsWithTermsMixedTest :: Test+numericExprsWithTermsMixedTest = TestCase $ do+    let df =+            D.fromNamedColumns+                [+                    ( "x"+                    , DI.fromVector+                        (V.fromList [Just 1.0, Just 2.0, Just 3.0] :: V.Vector (Maybe Double))+                    )+                , ("y", DI.fromList ([4.0, 5.0, 6.0] :: [Double]))+                ]+        cfg = defaultSynthConfig{maxExprDepth = 2, enableArithOps = True}+        exprs = numericExprsWithTerms cfg df+    assertBool+        "more than 2 expressions: base cols + combinations"+        (length exprs > 2)+    assertBool+        "combined exprs include NMaybeDouble (nullable arithmetic)"+        (any (\case NMaybeDouble _ -> True; _ -> False) exprs)++------------------------------------------------------------------------+-- Probability tree tests+------------------------------------------------------------------------++-- probsFromIndices: counts correct on a 3-row slice+probsFromIndicesBasic :: Test+probsFromIndicesBasic = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("label", DI.fromList (["A", "A", "B"] :: [T.Text]))+                , ("x", DI.fromList ([1.0, 2.0, 3.0] :: [Double]))+                ]+        probs = probsFromIndices @T.Text "label" df (V.fromList [0, 1, 2])+    assertBool "A prob ≈ 2/3" (abs (probs M.! "A" - 2 / 3) < 1e-9)+    assertBool "B prob ≈ 1/3" (abs (probs M.! "B" - 1 / 3) < 1e-9)++-- probsFromIndices: only a subset of rows counted+probsFromIndicesSubset :: Test+probsFromIndicesSubset = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("label", DI.fromList (["A", "A", "B", "B"] :: [T.Text]))+                , ("x", DI.fromList ([1.0, 2.0, 3.0, 4.0] :: [Double]))+                ]+        probs = probsFromIndices @T.Text "label" df (V.fromList [0, 1])+    assertEqual "only rows 0,1 → A:1.0" (M.fromList [("A", 1.0)]) probs++-- probsFromIndices: single class → probability 1.0+probsFromIndicesSingleClass :: Test+probsFromIndicesSingleClass = TestCase $ do+    let probs = probsFromIndices @T.Text "label" fixtureDF (V.fromList [0, 2])+    assertEqual "rows 0,2 both A → A:1.0" (M.fromList [("A", 1.0)]) probs++-- buildProbTree: Leaf preserves distribution+buildProbTreeLeaf :: Test+buildProbTreeLeaf = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("label", DI.fromList (["A", "A", "A"] :: [T.Text]))+                , ("x", DI.fromList ([1.0, 2.0, 3.0] :: [Double]))+                ]+        pt = buildProbTree @T.Text (Leaf "A") "label" df (V.fromList [0, 1, 2])+    case pt of+        Leaf m -> assertEqual "pure-A leaf → {A:1.0}" (M.fromList [("A", 1.0)]) m+        _ -> assertFailure "expected Leaf"++-- buildProbTree: Branch distributes rows to left/right leaves correctly+buildProbTreeBranch :: Test+buildProbTreeBranch = TestCase $ do+    let stump = Branch splitCond (Leaf "A") (Leaf "B") :: Tree T.Text+        pt = buildProbTree @T.Text stump "label" fixtureDF allIndices+    case pt of+        Branch _ (Leaf lm) (Leaf rm) -> do+            assertBool "left leaf has A:0.5" (abs (M.findWithDefault 0 "A" lm - 0.5) < 1e-9)+            assertBool "left leaf has B:0.5" (abs (M.findWithDefault 0 "B" lm - 0.5) < 1e-9)+            assertBool+                "right leaf has A:0.5"+                (abs (M.findWithDefault 0 "A" rm - 0.5) < 1e-9)+            assertBool+                "right leaf has C:0.5"+                (abs (M.findWithDefault 0 "C" rm - 0.5) < 1e-9)+        _ -> assertFailure "expected Branch with two Leaves"++-- probExprs: leaf tree produces Lit values+probExprsLeaf :: Test+probExprsLeaf = TestCase $ do+    let pt = Leaf (M.fromList [("A", 0.75), ("B", 0.25)]) :: ProbTree T.Text+        pe = probExprs pt+    assertBool "A expr is Lit 0.75" (eqExpr (Lit 0.75) (pe M.! "A"))+    assertBool "B expr is Lit 0.25" (eqExpr (Lit 0.25) (pe M.! "B"))++-- probExprs: class absent from one leaf gets Lit 0.0 on that side+probExprsMissingClass :: Test+probExprsMissingClass = TestCase $ do+    let pt =+            Branch+                splitCond+                (Leaf (M.fromList [("A", 1.0)]))+                (Leaf (M.fromList [("B", 1.0)])) ::+                ProbTree T.Text+        pe = probExprs pt+    assertBool+        "A expr: If cond (Lit 1.0) (Lit 0.0)"+        (eqExpr (F.ifThenElse splitCond (Lit 1.0) (Lit 0.0)) (pe M.! "A"))+    assertBool+        "B expr: If cond (Lit 0.0) (Lit 1.0)"+        (eqExpr (F.ifThenElse splitCond (Lit 0.0) (Lit 1.0)) (pe M.! "B"))++-- probExprs: keys equal all classes that appear across any leaf+probExprsAllClasses :: Test+probExprsAllClasses = TestCase $ do+    let pt =+            Branch+                splitCond+                (Leaf (M.fromList [("A", 1.0)]))+                (Leaf (M.fromList [("B", 0.6), ("C", 0.4)])) ::+                ProbTree T.Text+        pe = probExprs pt+    assertEqual "three classes in result" (sort ["A", "B", "C"]) (sort (M.keys pe))++-- Probabilities sum to 1.0 at every row after applying probExprs+probsSumToOne :: Test+probsSumToOne = TestCase $ do+    let stump = Branch splitCond (Leaf "A") (Leaf "B") :: Tree T.Text+        pt = buildProbTree @T.Text stump "label" fixtureDF allIndices+        pe = probExprs pt+        sumExpr = foldl1 (.+) (M.elems pe)+    case interpret @Double fixtureDF sumExpr of+        Left e -> assertFailure (show e)+        Right (DI.TColumn sumCol) ->+            case DI.toVector @Double sumCol of+                Left e2 -> assertFailure (show e2)+                Right vals ->+                    mapM_+                        (\v -> assertBool ("sum ≈ 1.0, got " ++ show v) (abs (v - 1.0) < 1e-9))+                        (V.toList vals)++-- argmax of probExprs agrees with fitDecisionTree on sepDF+probArgmaxMatchesClassifier :: Test+probArgmaxMatchesClassifier = TestCase $ do+    let cfg = defaultTreeConfig{taoIterations = 5, expressionPairs = 4, minLeafSize = 1}+        hardExpr = fitDecisionTree @T.Text cfg (Col "label") sepDF+        pe = fitProbTree @T.Text cfg (Col "label") sepDF+        indices = [0 .. D.nRows sepDF - 1]+    case interpret @T.Text sepDF hardExpr of+        Left e -> assertFailure (show e)+        Right (DI.TColumn hardCol) ->+            case DI.toVector @T.Text hardCol of+                Left e2 -> assertFailure (show e2)+                Right hardVals -> do+                    probCols <-+                        mapM+                            ( \(cls, expr) -> case interpret @Double sepDF expr of+                                Left e3 -> assertFailure (show e3) >> return (cls, V.empty)+                                Right (DI.TColumn col2) -> case DI.toVector @Double col2 of+                                    Left e4 -> assertFailure (show e4) >> return (cls, V.empty)+                                    Right v -> return (cls, v)+                            )+                            (M.toList pe)+                    mapM_+                        ( \i ->+                            let argmax = fst $ maximumBy (compare `on` (V.! i) . snd) probCols+                                hard = hardVals V.! i+                             in assertEqual ("row " ++ show i) hard argmax+                        )+                        indices++------------------------------------------------------------------------+-- C4-C9 / D-series: linear solver integration tests+------------------------------------------------------------------------++-- C4: Nested oblique recovery without oblique hints; label set by two oblique+-- boundaries but only axis-aligned thresholds in the pool. The linear solver+-- should learn both splits and reach zero loss.+taoRecoversNestedObliqueWithoutHint :: Test+taoRecoversNestedObliqueWithoutHint = TestCase $ do+    let labelExpr =+            F.ifThenElse+                ((F.col @Double "x" + F.col @Double "y") .<= F.lit (4.5 :: Double))+                (F.lit ("low" :: T.Text))+                ( F.ifThenElse+                    ((F.col @Double "x" - F.col @Double "y") .<= F.lit (0.5 :: Double))+                    (F.lit "mid")+                    (F.lit "high")+                )+        df = D.derive @T.Text "label" labelExpr gridBaseDF+        indices = V.enumFromN 0 16+        initTree =+            Branch+                (F.col @Double "x" .<= F.lit (1.5 :: Double))+                (Leaf "low")+                ( Branch+                    (F.col @Double "y" .<= F.lit (3.5 :: Double))+                    (Leaf "mid")+                    (Leaf "high")+                ) ::+                Tree T.Text+        axisOnlyConds =+            [F.col @Double "x" .<= F.lit (t :: Double) | t <- [1.5, 2.5, 3.5]]+                ++ [F.col @Double "y" .<= F.lit (t :: Double) | t <- [1.5, 2.5, 3.5]]+        cfg =+            defaultTreeConfig+                { taoIterations = 20+                , expressionPairs = 6+                , minLeafSize = 1+                , useLinearSolver = True+                , minCarePointsForLinear = 2+                }+        result = taoOptimize @T.Text cfg "label" axisOnlyConds df indices initTree+        finalLoss = computeTreeLoss @T.Text "label" df indices result+    assertEqual+        "linear solver recovers nested oblique tree from axis-aligned-only pool"+        0.0+        finalLoss++-- C5: Monotone loss across iterations with the linear solver enabled.+-- Resolves Issue 1 from the prior plan (currentCond included in the+-- competition pool).+taoMonotoneWithLinear :: Test+taoMonotoneWithLinear = TestCase $ do+    let indices = V.enumFromN 0 20+        cfg = defaultTreeConfig{taoIterations = 5, expressionPairs = 4, minLeafSize = 1}+        initLoss = computeTreeLoss @T.Text "label" sepDF indices wrongStump+        stepTree = taoIteration @T.Text cfg "label" sepConds sepDF indices+        step (tree, _) =+            let tree' = stepTree tree+             in (tree', computeTreeLoss @T.Text "label" sepDF indices tree')+        snapshots = take 6 $ iterate step (wrongStump, initLoss)+        losses = map snd snapshots+        pairs = zip losses (drop 1 losses)+    assertBool+        ("loss must be non-increasing across iterations (got " ++ show losses ++ ")")+        (all (\(a, b) -> b <= a + 1e-9) pairs)++-- C6: When the discrete pool contains an exact-zero-error split (axis-aligned+-- works perfectly), the competition picks the simpler discrete candidate+-- rather than a similarly-good but more complex linear one.+taoLinearVsDiscreteCompetition :: Test+taoLinearVsDiscreteCompetition = TestCase $ do+    let indices = V.enumFromN 0 20+        cfg =+            defaultTreeConfig+                { taoIterations = 5+                , expressionPairs = 4+                , minLeafSize = 1+                , useLinearSolver = True+                , minCarePointsForLinear = 2+                }+        result = taoOptimize @T.Text cfg "label" sepConds sepDF indices wrongStump+        finalLoss = computeTreeLoss @T.Text "label" sepDF indices result+    assertEqual+        "axis-aligned separable data should fit to zero loss"+        0.0+        finalLoss++-- C8: Linear solver respects the L1 penalty and produces sparse hyperplanes+-- on data where only some features are informative.+taoLinearProducesSparsity :: Test+taoLinearProducesSparsity = TestCase $ do+    let n = 50 :: Int+        xs = [fromIntegral i / 10 - 2.5 :: Double | i <- [0 .. n - 1]]+        avals = xs+        bs = map (* 0.7) xs+        cs = [fromIntegral ((i * 7) `mod` 11) / 5 - 1 :: Double | i <- [0 .. n - 1]]+        ds = [fromIntegral ((i * 13) `mod` 7) / 3 - 1 :: Double | i <- [0 .. n - 1]]+        labels =+            [ if (avals !! i) + (bs !! i) > 0 then "pos" else "neg" :: T.Text+            | i <- [0 .. n - 1]+            ]+        df =+            D.fromNamedColumns+                [ ("label", DI.fromList labels)+                , ("a", DI.fromList avals)+                , ("b", DI.fromList bs)+                , ("c", DI.fromList cs)+                , ("d", DI.fromList ds)+                ]+        cfg =+            defaultTreeConfig+                { maxTreeDepth = 1+                , taoIterations = 10+                , minLeafSize = 1+                , useLinearSolver = True+                , minCarePointsForLinear = 2+                , linearSolverConfig =+                    (linearSolverConfig defaultTreeConfig)+                        { DataFrame.LinearSolver.scL1Lambda = 0.05+                        }+                }+        result = fitDecisionTree @T.Text cfg (Col "label") df+        rootCols = getColumns result+    assertBool+        ( "informative columns 'a' or 'b' must appear in the fitted Expr (got "+            ++ show rootCols+            ++ ")"+        )+        ("a" `elem` rootCols || "b" `elem` rootCols)++-- C9: Determinism — same training data produces an equal (eqExpr) tree.+taoLinearDeterministic :: Test+taoLinearDeterministic = TestCase $ do+    let cfg =+            defaultTreeConfig+                { taoIterations = 5+                , expressionPairs = 4+                , minLeafSize = 1+                , useLinearSolver = True+                , minCarePointsForLinear = 2+                }+        r1 = fitDecisionTree @T.Text cfg (Col "label") sepDF+        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.+taoLinearTinyCareSet :: Test+taoLinearTinyCareSet = TestCase $ do+    let cfg =+            defaultTreeConfig+                { taoIterations = 5+                , expressionPairs = 4+                , minLeafSize = 1+                , useLinearSolver = True+                , minCarePointsForLinear = 100+                }+        result = fitDecisionTree @T.Text cfg (Col "label") sepDF+        cfgOff = cfg{useLinearSolver = False}+        resultOff = fitDecisionTree @T.Text cfgOff (Col "label") sepDF+    assertBool+        "skipping linear solver yields same expression as linear-off baseline"+        (eqExpr result resultOff)++------------------------------------------------------------------------+-- Categorical-condition generator tests (Phase 1-2 of the plan)+------------------------------------------------------------------------++-- A binary-target DataFrame with a 5-level Text column whose levels have+-- monotonically-increasing positive rates. Breiman's algorithm should+-- enumerate the 4 contiguous-prefix splits in that exact rate order.+breimanBinaryDF :: D.DataFrame+breimanBinaryDF =+    let n = 100 :: Int+        mkLabel "a" = "neg"+        mkLabel "b" = "neg"+        mkLabel "c" = "pos"+        mkLabel "d" = "pos"+        mkLabel "e" = "pos"+        mkLabel _ = "neg"+        levels = cycle ["a", "b", "c", "d", "e"]+        feats = take n levels+        labs = map mkLabel feats+     in D.fromUnnamedColumns+            [ DI.fromList (map T.pack feats :: [T.Text])+            , DI.fromList (map T.pack labs :: [T.Text])+            ]+            |> D.rename "0" "feat"+            |> D.rename "1" "label"++testCategoricalBreimanBinary :: Test+testCategoricalBreimanBinary = TestCase $ do+    let ti = requireTargetInfo "label" breimanBinaryDF+        conds =+            discreteConditions @T.Text+                ti+                defaultTreeConfig+                (D.exclude ["label"] breimanBinaryDF)+        feat = "feat"+        feats = filter (\c -> feat `elem` getColumns c) conds+    assertEqual "Breiman emits k-1 prefixes" 4 (length feats)++testCategoricalSubsetsMulticlassLowCard :: Test+testCategoricalSubsetsMulticlassLowCard = TestCase $ do+    let n = 30 :: Int+        feats = take n (cycle ["x", "y", "z"])+        labs = take n (cycle ["A", "B", "C"])+        df =+            D.fromUnnamedColumns+                [ DI.fromList (map T.pack feats :: [T.Text])+                , DI.fromList (map T.pack labs :: [T.Text])+                ]+                |> D.rename "0" "feat"+                |> D.rename "1" "label"+        ti = requireTargetInfo "label" df+        conds = discreteConditions @T.Text ti defaultTreeConfig (D.exclude ["label"] df)+        feat = "feat"+        feats' = filter (\c -> feat `elem` getColumns c) conds+    assertEqual "subsets at low cardinality" 6 (length feats')++testCategoricalSingletonsMulticlassHighCard :: Test+testCategoricalSingletonsMulticlassHighCard = TestCase $ do+    let n = 60 :: Int+        feats = take n (cycle ["a", "b", "c", "d", "e", "f"])+        labs = take n (cycle ["A", "B", "C"])+        df =+            D.fromUnnamedColumns+                [ DI.fromList (map T.pack feats :: [T.Text])+                , DI.fromList (map T.pack labs :: [T.Text])+                ]+                |> D.rename "0" "feat"+                |> D.rename "1" "label"+        ti = requireTargetInfo "label" df+        conds = discreteConditions @T.Text ti defaultTreeConfig (D.exclude ["label"] df)+        feat = "feat"+        feats' = filter (\c -> feat `elem` getColumns c) conds+    assertEqual "singletons at high cardinality" 6 (length feats')++testCategoricalCardZero :: Test+testCategoricalCardZero = TestCase $ do+    let df =+            D.fromUnnamedColumns+                [ DI.fromList ([] :: [T.Text])+                , DI.fromList ([] :: [T.Text])+                ]+                |> D.rename "0" "feat"+                |> D.rename "1" "label"+        ti = requireTargetInfo "label" df+        conds = discreteConditions @T.Text ti defaultTreeConfig (D.exclude ["label"] df)+        feat = "feat"+        feats' = filter (\c -> feat `elem` getColumns c) conds+    assertEqual "no candidates on empty column" 0 (length feats')++testCategoricalNullableBinary :: Test+testCategoricalNullableBinary = TestCase $ do+    let feats =+            [ Just "a"+            , Just "b"+            , Just "c"+            , Nothing+            , Just "a"+            , Just "b"+            , Just "c"+            , Nothing+            , Just "a"+            , Just "b"+            , Just "c"+            , Just "a"+            , Just "b"+            , Just "c"+            , Just "a"+            , Just "b"+            ]+        labs =+            [ "neg"+            , "neg"+            , "pos"+            , "neg"+            , "neg"+            , "neg"+            , "pos"+            , "neg"+            , "neg"+            , "neg"+            , "pos"+            , "neg"+            , "neg"+            , "pos"+            , "neg"+            , "pos"+            ]+        df =+            D.fromUnnamedColumns+                [ DI.fromList (feats :: [Maybe T.Text])+                , DI.fromList (map T.pack labs :: [T.Text])+                ]+                |> D.rename "0" "feat"+                |> D.rename "1" "label"+        ti = requireTargetInfo "label" df+        conds = discreteConditions @T.Text ti defaultTreeConfig (D.exclude ["label"] df)+        feat = "feat" :: T.Text+        feats' = filter (\c -> feat `elem` getColumns c) conds+    assertEqual "Breiman prefixes on nullable column ignore nulls" 2 (length feats')++------------------------------------------------------------------------+-- PR 2 extended: threshold-consolidation rewrite in combineAndVec /+-- combineOrVec. Positive cases, negative cases, semantic-preservation check.+------------------------------------------------------------------------++-- A small synthetic DataFrame to materialize CondVecs against.+threshFixtureDF :: D.DataFrame+threshFixtureDF =+    D.fromNamedColumns+        [ ("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]))+        ]++materializeOrFail :: Expr Bool -> CondVec+materializeOrFail e = case materializeCondVec threshFixtureDF e of+    Just cv -> cv+    Nothing -> error "materializeOrFail: condition could not be materialized"++-- | Helper: assert that two `Expr Bool`s agree by 'eqExpr'.+assertEqExpr :: String -> Expr Bool -> Expr Bool -> Assertion+assertEqExpr msg expected actual =+    assertBool+        (msg ++ "\n  expected: " ++ show expected ++ "\n  actual:   " ++ show actual)+        (eqExpr expected actual)++-- Eight positive cases.++threshAndLeq :: Test+threshAndLeq = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .<=. F.lit (3.0 :: Double))+        b = materializeOrFail (F.col @Double "x" .<=. F.lit (1.0 :: Double))+        r = combineAndVec a b+    assertEqExpr+        "AND of x≤3 and x≤1 collapses to x≤1"+        (F.col @Double "x" .<=. F.lit (1.0 :: Double))+        (cvExpr r)++threshOrLeq :: Test+threshOrLeq = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .<=. F.lit (3.0 :: Double))+        b = materializeOrFail (F.col @Double "x" .<=. F.lit (1.0 :: Double))+        r = combineOrVec a b+    assertEqExpr+        "OR of x≤3 and x≤1 collapses to x≤3"+        (F.col @Double "x" .<=. F.lit (3.0 :: Double))+        (cvExpr r)++threshAndLt :: Test+threshAndLt = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .<. F.lit (3.0 :: Double))+        b = materializeOrFail (F.col @Double "x" .<. F.lit (1.0 :: Double))+        r = combineAndVec a b+    assertEqExpr+        "AND of x<3 and x<1 collapses to x<1"+        (F.col @Double "x" .<. F.lit (1.0 :: Double))+        (cvExpr r)++threshOrLt :: Test+threshOrLt = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .<. F.lit (3.0 :: Double))+        b = materializeOrFail (F.col @Double "x" .<. F.lit (1.0 :: Double))+        r = combineOrVec a b+    assertEqExpr+        "OR of x<3 and x<1 collapses to x<3"+        (F.col @Double "x" .<. F.lit (3.0 :: Double))+        (cvExpr r)++threshAndGeq :: Test+threshAndGeq = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .>=. F.lit (1.0 :: Double))+        b = materializeOrFail (F.col @Double "x" .>=. F.lit (3.0 :: Double))+        r = combineAndVec a b+    assertEqExpr+        "AND of x≥1 and x≥3 collapses to x≥3"+        (F.col @Double "x" .>=. F.lit (3.0 :: Double))+        (cvExpr r)++threshOrGeq :: Test+threshOrGeq = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .>=. F.lit (1.0 :: Double))+        b = materializeOrFail (F.col @Double "x" .>=. F.lit (3.0 :: Double))+        r = combineOrVec a b+    assertEqExpr+        "OR of x≥1 and x≥3 collapses to x≥1"+        (F.col @Double "x" .>=. F.lit (1.0 :: Double))+        (cvExpr r)++threshAndGt :: Test+threshAndGt = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .>. F.lit (1.0 :: Double))+        b = materializeOrFail (F.col @Double "x" .>. F.lit (3.0 :: Double))+        r = combineAndVec a b+    assertEqExpr+        "AND of x>1 and x>3 collapses to x>3"+        (F.col @Double "x" .>. F.lit (3.0 :: Double))+        (cvExpr r)++threshOrGt :: Test+threshOrGt = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .>. F.lit (1.0 :: Double))+        b = materializeOrFail (F.col @Double "x" .>. F.lit (3.0 :: Double))+        r = combineOrVec a b+    assertEqExpr+        "OR of x>1 and x>3 collapses to x>1"+        (F.col @Double "x" .>. F.lit (1.0 :: Double))+        (cvExpr r)++-- Six negative cases: rewrite must NOT fire.++threshNegMixedDirection :: Test+threshNegMixedDirection = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .<. F.lit (3.0 :: Double))+        b = materializeOrFail (F.col @Double "x" .>=. F.lit (1.0 :: Double))+        r = combineAndVec a b+    assertEqExpr+        "mixed-direction AND keeps generic F.and form"+        (F.and (cvExpr a) (cvExpr b))+        (cvExpr r)++threshNegCrossColumn :: Test+threshNegCrossColumn = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .>. F.lit (1.0 :: Double))+        b = materializeOrFail (F.col @Double "y" .>. F.lit (3.0 :: Double))+        r = combineAndVec a b+    assertEqExpr+        "cross-column AND keeps generic F.and form"+        (F.and (cvExpr a) (cvExpr b))+        (cvExpr r)++threshNegMixedOpFamily :: Test+threshNegMixedOpFamily = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .>. F.lit (1.0 :: Double))+        b = materializeOrFail (F.col @Double "x" .<. F.lit (4.0 :: Double))+        r = combineAndVec a b+    assertEqExpr+        "different-op-family AND keeps generic F.and form"+        (F.and (cvExpr a) (cvExpr b))+        (cvExpr r)++threshNegEqualityOp :: Test+threshNegEqualityOp = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .==. F.lit (3.0 :: Double))+        b = materializeOrFail (F.col @Double "x" .==. F.lit (1.0 :: Double))+        r = combineOrVec a b+    assertEqExpr+        "equality OR keeps generic F.or form"+        (F.or (cvExpr a) (cvExpr b))+        (cvExpr r)++threshNegLitOnLeft :: Test+threshNegLitOnLeft = TestCase $ do+    let a = materializeOrFail (F.lit (1.0 :: Double) .<. F.col @Double "x")+        b = materializeOrFail (F.lit (3.0 :: Double) .<. F.col @Double "x")+        r = combineAndVec a b+    assertEqExpr+        "Lit-on-left AND keeps generic F.and form"+        (F.and (cvExpr a) (cvExpr b))+        (cvExpr r)++threshNegNonLiteralRhs :: Test+threshNegNonLiteralRhs = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .>. F.col @Double "y")+        b = materializeOrFail (F.col @Double "x" .>. F.lit (3.0 :: Double))+        r = combineAndVec a b+    assertEqExpr+        "non-literal RHS AND keeps generic F.and form"+        (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.+threshSemanticPreservation :: Test+threshSemanticPreservation = TestCase $ do+    let a = materializeOrFail (F.col @Double "x" .>. F.lit (1.0 :: Double))+        b = materializeOrFail (F.col @Double "x" .>. F.lit (3.0 :: Double))+        rAnd = combineAndVec a b+        rOr = combineOrVec a b+        expectedAnd = VU.zipWith (&&) (cvVec a) (cvVec b)+        expectedOr = VU.zipWith (||) (cvVec a) (cvVec b)+    assertEqual+        "consolidated AND vec matches elementwise &&"+        expectedAnd+        (cvVec rAnd)+    assertEqual+        "consolidated OR vec matches elementwise ||"+        expectedOr+        (cvVec rOr)++------------------------------------------------------------------------+-- Test list+------------------------------------------------------------------------++tests :: [Test]+tests =+    [ TestLabel "carePointsBothWrong" carePointsBothWrong+    , TestLabel "carePointsLeftCorrect" carePointsLeftCorrect+    , TestLabel "carePointsRightCorrect" carePointsRightCorrect+    , TestLabel "carePointsMixed" carePointsMixed+    , TestLabel "carePointsBothCorrect" carePointsBothCorrect+    , TestLabel "majorityVoteTest" majorityVoteTest+    , TestLabel "majorityVoteSubset" majorityVoteSubset+    , TestLabel "computeLossZero" computeLossZero+    , TestLabel "computeLossHalf" computeLossHalf+    , TestLabel "partitionDisjoint" partitionDisjoint+    , TestLabel "partitionUnion" partitionUnion+    , TestLabel "countErrorsAllCorrect" countErrorsAllCorrect+    , TestLabel "countErrorsAllWrong" countErrorsAllWrong+    , TestLabel "predictLeaf" predictLeaf+    , TestLabel "predictBranch" predictBranch+    , TestLabel "taoNoDegradation" taoNoDegradation+    , TestLabel "taoMonotone" taoMonotone+    , TestLabel "taoConvergesPureLabels" taoConvergesPureLabels+    , TestLabel "taoDeadBranchNoCrash" taoDeadBranchNoCrash+    , TestLabel "taoRecoversSingleObliqueDerived" taoRecoversSingleObliqueDerived+    , TestLabel "taoRecoversNestedObliqueDerived" taoRecoversNestedObliqueDerived+    , TestLabel+        "C2a taoAxisAlignedInsufficientForObliqueDiscreteOnly"+        taoAxisAlignedInsufficientForObliqueDiscreteOnly+    , TestLabel+        "C2b taoLinearRecoversObliqueFromAxisAlignedPool"+        taoLinearRecoversObliqueFromAxisAlignedPool+    , TestLabel "numericColsNullableDouble" numericColsNullableDoubleTest+    , TestLabel "numericColsNullableInt" numericColsNullableIntTest+    , TestLabel "numericCondsNullableNonEmpty" numericCondsNullableNonEmptyTest+    , TestLabel "nullValueRoutesFalse" nullValueRoutesFalseTest+    , TestLabel "nullableFitZeroLoss" nullableFitZeroLossTest+    , TestLabel "nullableFitWithNullsNoCrash" nullableFitWithNullsNoCrashTest+    , TestLabel "numericExprsWithTermsMixed" numericExprsWithTermsMixedTest+    , TestLabel "probsFromIndicesBasic" probsFromIndicesBasic+    , TestLabel "probsFromIndicesSubset" probsFromIndicesSubset+    , TestLabel "probsFromIndicesSingleClass" probsFromIndicesSingleClass+    , TestLabel "buildProbTreeLeaf" buildProbTreeLeaf+    , TestLabel "buildProbTreeBranch" buildProbTreeBranch+    , TestLabel "probExprsLeaf" probExprsLeaf+    , TestLabel "probExprsMissingClass" probExprsMissingClass+    , TestLabel "probExprsAllClasses" probExprsAllClasses+    , TestLabel "probsSumToOne" probsSumToOne+    , TestLabel "probArgmaxMatchesClassifier" probArgmaxMatchesClassifier+    , TestLabel+        "C4 taoRecoversNestedObliqueWithoutHint"+        taoRecoversNestedObliqueWithoutHint+    , TestLabel "C5 taoMonotoneWithLinear" taoMonotoneWithLinear+    , TestLabel "C6 taoLinearVsDiscreteCompetition" taoLinearVsDiscreteCompetition+    , TestLabel "C8 taoLinearProducesSparsity" taoLinearProducesSparsity+    , TestLabel "C9 taoLinearDeterministic" taoLinearDeterministic+    , TestLabel "D1 taoLinearTinyCareSet" taoLinearTinyCareSet+    , TestLabel "E1 categoricalBreimanBinary" testCategoricalBreimanBinary+    , TestLabel+        "E2 categoricalSubsetsMulticlassLowCard"+        testCategoricalSubsetsMulticlassLowCard+    , TestLabel+        "E3 categoricalSingletonsMulticlassHighCard"+        testCategoricalSingletonsMulticlassHighCard+    , TestLabel "E4 categoricalCardZero" testCategoricalCardZero+    , TestLabel "E5 categoricalNullableBinary" testCategoricalNullableBinary+    , -- PR 2 extended: threshold-consolidation rewrite (positive cases).+      TestLabel "F1 threshAndLeq" threshAndLeq+    , TestLabel "F2 threshOrLeq" threshOrLeq+    , TestLabel "F3 threshAndLt" threshAndLt+    , TestLabel "F4 threshOrLt" threshOrLt+    , TestLabel "F5 threshAndGeq" threshAndGeq+    , TestLabel "F6 threshOrGeq" threshOrGeq+    , TestLabel "F7 threshAndGt" threshAndGt+    , TestLabel "F8 threshOrGt" threshOrGt+    , -- PR 2 extended: negative cases (rewrite must NOT fire).+      TestLabel "F9 threshNegMixedDirection" threshNegMixedDirection+    , TestLabel "F10 threshNegCrossColumn" threshNegCrossColumn+    , TestLabel "F11 threshNegMixedOpFamily" threshNegMixedOpFamily+    , TestLabel "F12 threshNegEqualityOp" threshNegEqualityOp+    , TestLabel "F13 threshNegLitOnLeft" threshNegLitOnLeft+    , TestLabel "F14 threshNegNonLiteralRhs" threshNegNonLiteralRhs+    , TestLabel "F15 threshSemanticPreservation" threshSemanticPreservation+    ]
+ tests-internal/Learn/EdgeCases.hs view
@@ -0,0 +1,441 @@+{-# LANGUAGE OverloadedStrings #-}+{-# 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".+-}+module Learn.EdgeCases (tests) where++import qualified Data.Map.Strict as M+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU++import qualified DataFrame.Functions as F+import DataFrame.Internal.Column (TypedColumn (..), toVector)+import qualified DataFrame.Internal.Column as DI+import DataFrame.Internal.Expression (Expr)+import DataFrame.Internal.Interpreter (interpret)+import qualified DataFrameApi as D++import DataFrame.GMM+import DataFrame.KMeans+import DataFrame.LinearAlgebra (logSumExp)+import DataFrame.LinearAlgebra.Eigen (jacobiEigenSym)+import DataFrame.LinearAlgebra.Solve (choleskySolve)+import DataFrame.LinearModel+import DataFrame.LinearSolver (sigmoid)+import DataFrame.PCA++import DataFrame.Internal.Statistics (correlation', 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)+    Left err -> error (show err)++interpI :: D.DataFrame -> Expr Int -> [Int]+interpI df e = case interpret @Int df e of+    Right (TColumn c) -> either (const []) VU.toList (toVector @Int @VU.Vector c)+    Left err -> error (show err)++mat :: [[Double]] -> V.Vector (VU.Vector Double)+mat = V.fromList . map VU.fromList++close :: Double -> Double -> Double -> Bool+close tol a b = abs (a - b) <= tol++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. -}+testLogSumExpLargePositive :: Test+testLogSumExpLargePositive = TestCase $ do+    let lse = logSumExp (VU.fromList [1000, 1001, 1002])+        expected = 1002 + log (exp (-2) + exp (-1) + 1)+    assertBool "lse large-positive is finite" (finite lse)+    assertBool+        "lse [1000,1001,1002] == 1002 + log(e^-2+e^-1+1)"+        (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. -}+testLogSumExpLargeNegative :: Test+testLogSumExpLargeNegative = TestCase $ do+    let lse = logSumExp (VU.fromList [-1000, -1001, -1002])+        expected = -1000 + log (1 + exp (-1) + exp (-2))+    assertBool "lse large-negative is finite" (finite lse)+    assertBool+        "lse [-1000,-1001,-1002] == -1000 + log(1+e^-1+e^-2)"+        (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). -}+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. -}+testSigmoidExtreme :: Test+testSigmoidExtreme = TestCase $ do+    let sp = sigmoid 1000+        sn = sigmoid (-1000)+    assertBool "sigmoid(1000) finite" (finite sp)+    assertBool "sigmoid(-1000) finite" (finite sn)+    assertBool "sigmoid(1000) in [0,1]" (sp >= 0 && sp <= 1)+    assertBool "sigmoid(-1000) in [0,1]" (sn >= 0 && sn <= 1)+    assertBool "sigmoid(1000) == 1" (close 1e-12 sp 1)+    assertBool "sigmoid(-1000) == 0" (close 1e-12 sn 0)+    assertBool "sigmoid(0) == 0.5" (close 1e-15 (sigmoid 0) 0.5)+    assertBool+        "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. -}+testVarianceCatastrophicCancellation :: Test+testVarianceCatastrophicCancellation = TestCase $ do+    let xs = VU.fromList [1e8 + 1, 1e8 + 2, 1e8 + 3] :: VU.Vector Double+        v = variance' xs+    assertBool "variance finite" (finite v)+    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). -}+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. -}+testVarianceSingleton :: Test+testVarianceSingleton = TestCase $ do+    assertEqual+        "variance of one sample is 0"+        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. -}+testCorrelationPerfect :: Test+testCorrelationPerfect = TestCase $ do+    let xs = VU.fromList [1, 2, 3, 4, 5] :: VU.Vector Double+        ys = VU.map (\x -> 2 * x + 1) xs+        yneg = VU.map (\x -> -(2 * x) + 1) xs+    case correlation' xs ys of+        Just r -> assertBool "corr(x, 2x+1) == 1" (close 1e-9 r 1)+        Nothing -> assertFailure "expected a correlation"+    case correlation' xs yneg of+        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. -}+testCorrelationConstantColumnIsNaN :: Test+testCorrelationConstantColumnIsNaN = TestCase $ do+    let xs = VU.fromList [1, 2, 3, 4, 5] :: VU.Vector Double+        ys = VU.replicate 5 (9.0 :: Double)+    case correlation' xs ys of+        Just r ->+            assertBool+                "corr with a zero-variance column is NaN (degenerate denom)"+                (isNaN r)+        Nothing -> assertFailure "correlation' returned Nothing (contract changed)"++{- correlation' on n<2 returns Nothing (guarded), not a NaN. -}+testCorrelationTooFew :: Test+testCorrelationTooFew = TestCase $ do+    assertEqual+        "correlation of one point is Nothing"+        Nothing+        (correlation' (VU.fromList [1]) (VU.fromList [2]))++-- ===========================================================================+-- Category 8: stability inside the model expr layer+-- ===========================================================================++{- 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. -}+testLogisticProbsExtremeFeatures :: Test+testLogisticProbsExtremeFeatures = TestCase $ do+    let trainDf =+            D.fromNamedColumns+                [ ("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+                [("x", DI.fromList ([-1e6, -1, 0, 1, 1e6] :: [Double]))]+    assertEqual "two one-vs-rest probability exprs" 2 (M.size probs)+    let allProbVals =+            concat [interpD extremeDf e | e <- M.elems probs]+    assertBool "all logistic probabilities finite" (all finite allProbVals)+    assertBool+        "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. -}+testOLSOneRow :: Test+testOLSOneRow = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("x", DI.fromList ([2] :: [Double]))+                , ("y", DI.fromList ([7] :: [Double]))+                ]+        m = fit defaultLinearConfig (F.col @Double "y") df+        preds = interpD df (predict m)+    assertBool "single OLS prediction finite" (all finite preds)+    assertBool+        "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). -}+testLogisticSingleClass :: Test+testLogisticSingleClass = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("x", DI.fromList ([-2, -1, 0, 1, 2] :: [Double]))+                , ("label", DI.fromList ([1, 1, 1, 1, 1] :: [Int]))+                ]+        m = fit defaultLogisticConfig (F.col @Int "label") df+        preds = interpI df (predict m)+    assertEqual+        "single-class logistic predicts that class everywhere"+        [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. -}+testLogisticConstantFeatureIgnored :: Test+testLogisticConstantFeatureIgnored = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("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]))+                ]+        m = fit defaultLogisticConfig (F.col @Int "label") df+        preds = interpI df (predict m)+    assertEqual+        "logistic ignores constant column and separates"+        [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. -}+testLinearConstantFeatureZeroWeight :: Test+testLinearConstantFeatureZeroWeight = TestCase $ do+    let xs = [1, 2, 3, 4, 5, 6] :: [Double]+        df =+            D.fromNamedColumns+                [ ("x", DI.fromList xs)+                , ("const", DI.fromList (replicate 6 (5.0 :: Double)))+                , ("y", DI.fromList [3 * x + 4 | x <- xs])+                ]+        m = fit defaultLinearConfig (F.col @Double "y") df+        coefs = VU.toList (regCoef m)+        names = V.toList (regFeatureNames m)+        byName = zip names coefs+    case lookup "const" byName of+        Just w -> assertBool "constant feature gets ~zero weight" (close 1e-6 w 0)+        Nothing -> assertFailure "const column missing from feature names"+    let preds = interpD df (predict m)+        truth = [3 * x + 4 | x <- xs]+    assertBool+        "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. -}+testKMeansKGreaterThanN :: Test+testKMeansKGreaterThanN = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("a", DI.fromList ([0, 5, 10] :: [Double]))+                , ("b", DI.fromList ([0, 5, 10] :: [Double]))+                ]+        cfg = defaultKMeansConfig{kmK = 10, kmNInit = 3, kmSeed = 1}+        m = fit cfg [F.col @Double "a", F.col @Double "b"] df+    assertEqual "k clamped to n=3 centres" 3 (V.length (kmCenters m))+    let labels = VU.toList (kmLabels m)+    assertBool "all labels in [0,2]" (all (\l -> l >= 0 && l < 3) labels)+    assertBool+        "all centre coordinates finite"+        (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). -}+testKMeansAllIdentical :: Test+testKMeansAllIdentical = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("a", DI.fromList (replicate 6 (4.0 :: Double)))+                , ("b", DI.fromList (replicate 6 (4.0 :: Double)))+                ]+        cfg = defaultKMeansConfig{kmK = 2, kmNInit = 3, kmSeed = 1}+        m = fit cfg [F.col @Double "a", F.col @Double "b"] df+    assertBool+        "centres finite on degenerate identical data"+        (all (VU.all finite) (V.toList (kmCenters m)))+    assertBool "inertia is 0 for identical points" (close 1e-12 (kmInertia m) 0)+    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. -}+testGMMKGreaterThanN :: Test+testGMMKGreaterThanN = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("a", DI.fromList ([0, 10] :: [Double]))+                , ("b", DI.fromList ([0, 10] :: [Double]))+                ]+        cfg = defaultGMMConfig{gmmK = 5, gmmSeed = 1}+        m = fit cfg [F.col @Double "a", F.col @Double "b"] df+    assertEqual "GMM k clamped to n=2" 2 (VU.length (gmmWeights m))+    assertBool "GMM weights finite" (VU.all finite (gmmWeights m))+    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. -}+testPCAConstantColumn :: Test+testPCAConstantColumn = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("x", DI.fromList ([-2, -1, 0, 1, 2] :: [Double]))+                , ("const", DI.fromList (replicate 5 (3.0 :: Double)))+                ]+        m =+            fit+                (PCAConfig (NComp 2) False)+                [F.col @Double "x", F.col @Double "const"]+                df+        ratio = VU.toList (pcaExplainedVarianceRatio m)+    assertBool "explained ratios finite" (all finite ratio)+    assertBool "explained ratios sum to ~1" (close 1e-9 (sum ratio) 1)+    r0 <- case ratio of+        (r : _) -> pure r+        [] -> assertFailure "expected explained-variance ratios"+    assertBool "first PC explains ~all variance" (close 1e-9 r0 1)+    let es = map snd (pcaExprs m)+    assertBool+        "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. -}+testPCAExtremeScale :: Test+testPCAExtremeScale = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("x", DI.fromList ([1e8 + 1, 1e8 + 2, 1e8 + 3, 1e8 + 4] :: [Double]))+                , ("y", DI.fromList ([1e8 + 1, 1e8 + 2, 1e8 + 3, 1e8 + 4] :: [Double]))+                ]+        m =+            fit+                (PCAConfig (NComp 1) False)+                [F.col @Double "x", F.col @Double "y"]+                df+        comp0 = pcaComponents m V.! 0+        nrm = sqrt (VU.sum (VU.map (^ (2 :: Int)) comp0))+    assertBool "component finite at 1e8 scale" (VU.all finite comp0)+    assertBool "component unit length at 1e8 scale" (close 1e-6 nrm 1)+    assertBool+        "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. -}+testCholeskyNonPD :: Test+testCholeskyNonPD = TestCase $ do+    assertEqual+        "cholesky of singular zero matrix is Nothing"+        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). -}+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). -}+testJacobiIdentity :: Test+testJacobiIdentity = TestCase $ do+    let (ev, vecs) = jacobiEigenSym (mat [[1, 0], [0, 1]])+    assertBool "identity eigenvalues both 1" (VU.all (close 1e-12 1) ev)+    assertBool "identity eigenvectors finite" (all (VU.all finite) (V.toList vecs))++tests :: [Test]+tests =+    [ testLogSumExpLargePositive+    , testLogSumExpLargeNegative+    , testLogSumExpSingleton+    , testSigmoidExtreme+    , testVarianceCatastrophicCancellation+    , testVarianceConstant+    , testVarianceSingleton+    , testCorrelationPerfect+    , testCorrelationConstantColumnIsNaN+    , testCorrelationTooFew+    , testLogisticProbsExtremeFeatures+    , testOLSOneRow+    , testLogisticSingleClass+    , testLogisticConstantFeatureIgnored+    , testLinearConstantFeatureZeroWeight+    , testKMeansKGreaterThanN+    , testKMeansAllIdentical+    , testGMMKGreaterThanN+    , testPCAConstantColumn+    , testPCAExtremeScale+    , testCholeskyNonPD+    , testJacobi1x1+    , testJacobiIdentity+    ]
+ tests-internal/Learn/NumericalRigor.hs view
@@ -0,0 +1,408 @@+{-# LANGUAGE OverloadedStrings #-}+{-# 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.+-}+module Learn.NumericalRigor (tests) where++import qualified DataFrame.Functions as F+import qualified DataFrame.Internal.Column as DI+import qualified DataFrameApi as D++import DataFrame.GMM+import DataFrame.KMeans+import DataFrame.LinearSolver.Loss (+    SmoothLoss (..),+    logisticLoss,+    sigmoid,+    sqHingeLoss,+    squaredLoss,+ )+import DataFrame.Random+import DataFrame.SVM.RFF++import qualified Data.Vector.Unboxed as VU+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)++-- | @log (1 + exp (-y z))@  (logisticLoss). softplus form, numerically stable.+logisticValue :: Double -> Double -> Double+logisticValue y z =+    let m = negate (y * z)+     in if m >= 0 then m + log1pExp (negate m) else log1pExp m+  where+    log1pExp x = log (1 + exp x)++-- | @(max 0 (1 - y z))²@  (sqHingeLoss).+sqHingeValue :: Double -> Double -> Double+sqHingeValue y z = let m = 1 - y * z in if m > 0 then m * m else 0++-- | Central finite difference of @f@ in @z@ at @(y, z)@.+centralDiff ::+    (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@.+-}+gradCheck ::+    String ->+    SmoothLoss ->+    (Double -> Double -> Double) ->+    Double ->+    Test+gradCheck nm loss valueFn tol =+    TestCase $+        mapM_ check [(y, z) | y <- [-1, 1], z <- zs]+  where+    zs = [-3.7, -2.1, -1.25, -0.6, -0.15, 0.22, 0.55, 1.4, 1.9, 2.8, 4.3]+    check (y, z) = do+        let analytic = slGradZ loss y z+            fd = centralDiff valueFn 1e-6 y z+            ok = abs (analytic - fd) <= tol * (1 + abs fd)+        assertBool+            ( nm+                ++ " grad mismatch at (y="+                ++ show y+                ++ ", z="+                ++ show z+                ++ "): analytic="+                ++ show analytic+                ++ " finite-diff="+                ++ show fd+            )+            ok++-- | The squared-loss gradient @z - y@ checked against ½(z-y)².+testSquaredGradient :: Test+testSquaredGradient = gradCheck "squared" squaredLoss squaredValue 1e-5++-- | The logistic gradient @-y·σ(-y z)@ checked against log(1+exp(-yz)).+testLogisticGradient :: Test+testLogisticGradient = gradCheck "logistic" logisticLoss logisticValue 1e-5++-- | The squared-hinge gradient @-2y·max(0,1-yz)@ checked against (max 0 (1-yz))².+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.+-}+testGradientSigns :: Test+testGradientSigns = TestCase $ do+    assertBool+        "squared: grad>0 when z>y (loss rising)"+        (slGradZ squaredLoss 1.0 3.0 > 0)+    assertBool+        "squared: grad<0 when z<y (loss falling)"+        (slGradZ squaredLoss 1.0 (-2.0) < 0)+    assertBool+        "logistic y=+1: grad<0 (more positive margin lowers loss)"+        (slGradZ logisticLoss 1.0 0.5 < 0)+    assertBool+        "logistic y=-1: grad>0"+        (slGradZ logisticLoss (-1.0) 0.5 > 0)+    assertBool+        "sqHinge active margin y=+1: grad<0"+        (slGradZ sqHingeLoss 1.0 0.0 < 0)+    assertBool+        "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 []+  where+    go 0 _ acc = acc+    go k g acc = let (x, g') = nextDouble g in go (k - 1) g' (x : acc)++mean :: [Double] -> Double+mean xs = sum xs / fromIntegral (length xs)++variance :: [Double] -> Double+variance xs =+    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.+-}+testUniformDistribution :: Test+testUniformDistribution = TestCase $ do+    let n = 100000 :: Int+        xs = drawUniforms n (mkGen 20240613)+        m = mean xs+        v = variance xs+        seMean = (1 / sqrt 12) / sqrt (fromIntegral n)+    assertBool "all uniforms in [0,1)" (all (\x -> x >= 0 && x < 1) xs)+    assertBool+        ("uniform mean ~0.5, got " ++ show m)+        (abs (m - 0.5) <= 6 * seMean)+    assertBool+        ("uniform variance ~1/12, got " ++ show v)+        (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.+-}+testGaussianMoments :: Test+testGaussianMoments = TestCase $ do+    let n = 100000 :: Int+        (vec, _) = gaussianVector n (mkGen 777)+        xs = VU.toList vec+        m = mean xs+        v = variance xs+        seMean = 1 / sqrt (fromIntegral n)+    assertBool "gaussianVector length" (VU.length vec == n)+    assertBool+        "gaussian samples finite"+        (all (\x -> not (isNaN x) && not (isInfinite x)) xs)+    assertBool+        ("gaussian mean ~0, got " ++ show m)+        (abs m <= 6 * seMean)+    assertBool+        ("gaussian variance ~1, got " ++ show v)+        (abs (v - 1) <= 0.03)+    let tail2 = fromIntegral (length (filter (\x -> abs x > 2) xs)) / fromIntegral n+    assertBool+        ("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.+-}+testSplitProportions :: Test+testSplitProportions = TestCase $ do+    let nRows = 4000+        frac = 0.7 :: Double+        df = D.fromNamedColumns [("x", DI.fromList [1 .. nRows :: Int])]+        seeds = [1 .. 25] :: [Int]+        seFrac = sqrt (frac * (1 - frac) / fromIntegral nRows)+    mapM_+        ( \s -> do+            let (tr, te) = D.randomSplit (mkStdGen s) frac df+                nTr = fst (D.dimensions tr)+                nTe = fst (D.dimensions te)+                realized = fromIntegral nTr / fromIntegral nRows+            assertEqual+                ("split preserves rows (seed " ++ show s ++ ")")+                nRows+                (nTr + nTe)+            assertBool+                ( "split fraction ~"+                    ++ show frac+                    ++ " (seed "+                    ++ show s+                    ++ "), got "+                    ++ show realized+                )+                (abs (realized - frac) <= 5 * seFrac)+        )+        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.+-}+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)]+        fitSeed s =+            kmInertia $+                fit+                    defaultKMeansConfig{kmK = 2, kmNInit = 1, kmSeed = s}+                    [F.col @Double "a", F.col @Double "b"]+                    df+        inertias = map fitSeed [0 .. 19]+        blob1 = take 5 (zip as bs)+        blob2 = zip (drop 5 as) (drop 5 bs)+        ssOf pts =+            let mx = sum (map fst pts) / 5+                my = sum (map snd pts) / 5+             in sum [(x - mx) ^ (2 :: Int) + (y - my) ^ (2 :: Int) | (x, y) <- pts]+        optimum = ssOf blob1 + ssOf blob2+        best = minimum inertias+        worst = maximum inertias+    assertBool+        "k-means inertia finite"+        (all (\i -> not (isNaN i) && not (isInfinite i)) inertias)+    assertBool+        ( "k-means inertia never below optimum ("+            ++ show optimum+            ++ "), best="+            ++ show best+        )+        (best >= optimum - 1e-9)+    assertBool+        ( "k-means inertia stable across seeds, best="+            ++ show best+            ++ " worst="+            ++ show worst+        )+        (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+        [+            ( "a"+            , DI.fromList ([0, 0.2, -0.1, 0.1, 8, 8.1, 7.9, 8.2, 0.05, 8.05] :: [Double])+            )+        ,+            ( "b"+            , DI.fromList ([0, -0.1, 0.2, 0.0, 5, 5.2, 4.9, 5.1, 0.1, 5.05] :: [Double])+            )+        ]++-- | Many-cluster frame so k-means++ seeding genuinely depends on the seed.+spreadDF :: D.DataFrame+spreadDF =+    D.fromNamedColumns+        [ ("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.+-}+testKMeansReproducible :: Test+testKMeansReproducible = TestCase $ do+    let cfg s = defaultKMeansConfig{kmK = 4, kmNInit = 1, kmMaxIter = 1, kmSeed = s}+        run s = fit (cfg s) [F.col @Double "a", F.col @Double "b"]+        a = run 1 spreadDF+        b = run 1 spreadDF+    assertEqual "k-means same seed identical model" a b+    let centersFor s = kmCenters (run s spreadDF)+        base = centersFor 1+        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).+-}+testGMMReproducible :: Test+testGMMReproducible = TestCase $ do+    let cfg s = defaultGMMConfig{gmmK = 2, gmmMaxIter = 1, gmmSeed = s}+        run s = fit (cfg s) [F.col @Double "a", F.col @Double "b"] blobsDF+        a = run 1+        b = run 1+    assertEqual "GMM same seed identical model" a b+    let meansFor s = gmmMeans (run s)+        base = meansFor 1+        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.+-}+testRFFReproducible :: Test+testRFFReproducible = TestCase $ do+    let clsDF =+            D.fromNamedColumns+                [ ("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]))+                ]+        cfg s = defaultRFFConfig{rffD = 40, rffGamma = 0.2, rffSeed = s}+        run s = fit (cfg s) (F.col @Int "label") clsDF+        a = run 5+        b = run 5+    assertEqual "RFF same seed: same projection B" (rffB a) (rffB b)+    assertEqual "RFF same seed: same coefficients" (rffCoef a) (rffCoef b)+    assertEqual "RFF same seed: same intercept" (rffIntercept a) (rffIntercept b)+    let projFor s = rffB (run s)+        base = projFor 5+        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])]+        (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)+    assertBool "randomSplit same seed: same test" (te1 == te2)+    assertEqual+        "randomSplit preserves row count"+        200+        (fst (D.dimensions tr1) + fst (D.dimensions te1))+    let trainFor s = fst (D.randomSplit (mkStdGen s) 0.6 df)+        base = trainFor 42+        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+        "squaredValue at (y=2,z=5) == 4.5"+        (abs (squaredValue 2 5 - 4.5) < 1e-12)+    assertBool+        "logisticValue at (y=1,z=0) == log 2"+        (abs (logisticValue 1 0 - log 2) < 1e-12)+    assertBool "sqHingeValue at (y=1,z=0) == 1" (abs (sqHingeValue 1 0 - 1) < 1e-12)+    assertBool "sigmoid 0 == 0.5" (abs (sigmoid 0 - 0.5) < 1e-12)++tests :: [Test]+tests =+    [ testReferenceValueSanity+    , testSquaredGradient+    , testLogisticGradient+    , testSqHingeGradient+    , testGradientSigns+    , testUniformDistribution+    , testGaussianMoments+    , testSplitProportions+    , testKMeansInertiaStable+    , testKMeansReproducible+    , testGMMReproducible+    , testRFFReproducible+    , testSplitReproducible+    ]
+ tests-internal/Learn/Numerics.hs view
@@ -0,0 +1,99 @@+{-# LANGUAGE ScopedTypeVariables #-}++module Learn.Numerics (tests) where++import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import DataFrame.Model (fit, predict)+import Test.HUnit++import DataFrame.LinearAlgebra+import DataFrame.LinearAlgebra.Eigen+import DataFrame.LinearAlgebra.Solve+import DataFrame.Random++mat :: [[Double]] -> Matrix+mat = V.fromList . map VU.fromList++approx :: Double -> Double -> Double -> Bool+approx tol a b = abs (a - b) <= tol++vApprox :: Double -> VU.Vector Double -> VU.Vector Double -> Bool+vApprox tol a b =+    VU.length a == VU.length b && VU.and (VU.zipWith (approx tol) a b)++testQR :: Test+testQR = TestCase $ do+    let a = mat [[1, 1], [1, 2], [1, 3], [1, 4]]+        b = VU.fromList [3, 5, 7, 9]+    case qrLeastSquares a b of+        Right x ->+            assertBool "QR recovers [1,2]" (vApprox 1e-9 x (VU.fromList [1, 2]))+        Left cols -> assertFailure ("unexpected rank deficiency: " ++ show cols)++testQRRankDeficient :: Test+testQRRankDeficient = TestCase $ do+    let a = mat [[1, 2], [2, 4], [3, 6]]+    case qrLeastSquares a (VU.fromList [1, 2, 3]) of+        Left _ -> pure ()+        Right _ -> assertFailure "expected rank deficiency on collinear columns"++testCholesky :: Test+testCholesky = TestCase $ do+    let a = mat [[4, 2], [2, 3]]+    case choleskySolve a (VU.fromList [2, 1]) of+        Just x ->+            assertBool "cholesky solves" (vApprox 1e-9 x (VU.fromList [0.5, 0]))+        Nothing -> assertFailure "expected PD"+    assertEqual "non-PD rejected" Nothing (cholesky (mat [[1, 2], [2, 1]]))++testJacobi :: Test+testJacobi = TestCase $ do+    let (ev, vecs) = jacobiEigenSym (mat [[2, 1], [1, 2]])+    assertBool "eigenvalues [3,1]" (vApprox 1e-9 ev (VU.fromList [3, 1]))+    let v0 = vecs V.! 0+        recon = matVec (mat [[2, 1], [1, 2]]) v0+    assertBool+        "A v = lambda v"+        (vApprox 1e-9 recon (scaleV (ev VU.! 0) v0))++testPowerIter :: Test+testPowerIter = TestCase $ do+    let (lam, _) = powerIterTop 200 (mat [[2, 1], [1, 2]])+    assertBool "dominant eigenvalue ~3" (approx 1e-6 lam 3)++testLogSumExp :: Test+testLogSumExp = TestCase $ do+    let xs = VU.fromList [1000, 1001, 1002]+        lse = logSumExp xs+    assertBool "logSumExp stable, finite" (not (isNaN lse) && not (isInfinite lse))+    assertBool "logSumExp >= max" (lse >= 1002)++testRngDeterminism :: Test+testRngDeterminism = TestCase $ do+    let (a, _) = shuffleInts 50 (mkGen 11)+        (b, _) = shuffleInts 50 (mkGen 11)+    assertEqual "same seed same shuffle" a b+    assertBool+        "is a permutation"+        (VU.toList (VU.modify (const (pure ())) a) /= [] && all (`VU.elem` a) [0 .. 49])++testRngRange :: Test+testRngRange = TestCase $ do+    let go 0 _ acc = acc+        go k g acc =+            let (x, g') = nextIntR (3, 7) g in go (k - 1 :: Int) g' (x : acc)+        xs = go 1000 (mkGen 5) []+    assertBool "ints within range" (all (\x -> x >= 3 && x <= 7) xs)++tests :: [Test]+tests =+    [ testQR+    , testQRRankDeficient+    , testCholesky+    , testJacobi+    , testPowerIter+    , testLogSumExp+    , testRngDeterminism+    , testRngRange+    ]
+ tests-internal/Learn/Symbolic.hs view
@@ -0,0 +1,138 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++module Learn.Symbolic (tests) where++import qualified DataFrame.Functions as F+import DataFrame.Internal.Column (TypedColumn (..), toVector)+import qualified DataFrame.Internal.Column as DI+import DataFrame.Internal.Expression (Expr)+import DataFrame.Internal.Interpreter (interpret)+import qualified DataFrameApi as D++import DataFrame.PCA.Kernel+import DataFrame.SVM.RFF+import DataFrame.SymbolicRegression+import DataFrame.SymbolicRegression.Expr+import DataFrame.SymbolicRegression.Optimize (meanSquaredError)+import DataFrame.SymbolicRegression.Simplify (simplify)++import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import Test.HUnit++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)+    Left err -> error (show err)++interpI :: D.DataFrame -> Expr Int -> [Int]+interpI df e = case interpret @Int df e of+    Right (TColumn c) -> either (const []) VU.toList (toVector @Int @VU.Vector c)+    Left err -> error (show err)++testSRRecovers :: Test+testSRRecovers = TestCase $ do+    let xs = [-3, -2, -1, 0, 1, 2, 3, 4, 5, 6] :: [Double]+        df =+            D.fromNamedColumns+                [ ("x", DI.fromList xs)+                , ("y", DI.fromList [x * x + x | x <- xs])+                ]+        m =+            fit+                defaultSRConfig+                    { srSeed = 3+                    , srGenerations = 60+                    , srPopSize = 300+                    , srUnaryOps = []+                    }+                (F.col @Double "y")+                df+        preds = interpD df (srBest m)+        truth = interpD df (F.col @Double "y")+        err = sum (zipWith (\p t -> (p - t) ^ (2 :: Int)) preds truth) / 10+    assertBool "SR recovers x*x+x to low error" (err < 1e-6)+    assertBool "Pareto front non-empty" (not (null (srPareto m)))++testSRDeterminism :: Test+testSRDeterminism = TestCase $ do+    let xs = [1 .. 8] :: [Double]+        df =+            D.fromNamedColumns+                [ ("x", DI.fromList xs)+                , ("y", DI.fromList (map (\x -> 2 * x + 1) xs))+                ]+        run =+            fit+                defaultSRConfig{srSeed = 9, srGenerations = 20}+                (F.col @Double "y")+                df+    assertEqual "SR deterministic best MSE" (srBestMSE run) (srBestMSE (rerun df))+  where+    rerun =+        fit+            defaultSRConfig{srSeed = 9, srGenerations = 20}+            (F.col @Double "y")++testSimplifyPreservesEval :: Test+testSimplifyPreservesEval = TestCase $ do+    let feats = V.singleton (VU.fromList [1, 2, 3, 4 :: Double])+        n = 4+        e = SBin SAdd (SBin SMul (SVar 0) (SConst 1)) (SConst 0)+        target = VU.fromList [1, 2, 3, 4]+    assertBool+        "simplify preserves evaluation"+        ( abs+            (meanSquaredError feats n target e - meanSquaredError feats n target (simplify e))+            < 1e-12+        )+    assertBool "simplify is size non-increasing" (srSize (simplify e) <= srSize e)+    assertEqual "simplify idempotent" (simplify e) (simplify (simplify e))++testKernelPCA :: Test+testKernelPCA = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("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]))+                ]+        m =+            fit+                defaultKernelPCAConfig{kpcaNComponents = 2, kpcaNLandmarks = 8, kpcaSeed = 1}+                [F.col @Double "a", F.col @Double "b"]+                df+        pc1 = case kernelPCAExprs m of+            ((_, e) : _) -> interpD df e+            [] -> error "testKernelPCA: no kPCA components"+    assertBool "kPCA finite" (not (any isNaN pc1))+    pc1First <- case pc1 of+        (p : _) -> pure p+        [] -> assertFailure "kPCA produced no projection"+    assertBool+        "kPCA first component separates blobs"+        (signum pc1First /= signum (last pc1))++testRFFSVM :: Test+testRFFSVM = TestCase $ do+    let df =+            D.fromNamedColumns+                [ ("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+                defaultRFFConfig{rffD = 80, rffGamma = 0.2, rffSeed = 2}+                (F.col @Int "label")+                df+        preds = interpI df (predict m)+    assertEqual "RFF SVM separates" [0, 0, 0, 0, 1, 1, 1, 1] preds++tests :: [Test]+tests =+    [ testSRRecovers+    , testSRDeterminism+    , testSimplifyPreservesEval+    , testKernelPCA+    , testRFFSVM+    ]
+ tests-internal/LinearSolver.hs view
@@ -0,0 +1,782 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++module LinearSolver where++import qualified DataFrame.Internal.Column as DI+import DataFrame.Internal.Expression (getColumns)+import DataFrame.Internal.Interpreter (interpret)+import DataFrame.LinearSolver+import qualified DataFrameApi as D++import Data.List (sort)+import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import System.Random (mkStdGen, randomR)+import Test.HUnit++------------------------------------------------------------------------+-- Test fixtures and helpers+------------------------------------------------------------------------++-- Generate n points with d features, each value 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]+     in V.fromList (take n rows)+  where+    step _ (acc, g) =+        let (row, g') = genRow d g+         in (row : acc, g')+    genRow k g0 = go k g0 []+      where+        go 0 g xs = (VU.fromList (reverse xs), g)+        go i g xs =+            let (v, g') = randomR (-1.0 :: Double, 1.0) g+             in go (i - 1) g' (v : xs)++-- Label each row by sign(w . x + b); +1 if score > 0, else -1.+labelsForHyperplane ::+    V.Vector (VU.Vector Double) ->+    VU.Vector Double ->+    Double ->+    VU.Vector Double+labelsForHyperplane rows w b =+    VU.generate+        (V.length rows)+        ( \i ->+            let score = dotProduct w (rows V.! i) + b+             in if score > 0 then 1 else -1+        )++-- Cosine similarity between two non-zero vectors.+cosineSim :: VU.Vector Double -> VU.Vector Double -> Double+cosineSim u v =+    let nu = sqrt (dotProduct u u)+        nv = sqrt (dotProduct v v)+     in if nu == 0 || nv == 0 then 0 else dotProduct u v / (nu * nv)++-- Predict +1 or -1 from a fitted LinearModel.+predict :: LinearModel -> VU.Vector Double -> Double+predict m x =+    let score = dotProduct (lmWeights m) x + lmIntercept m+     in if score > 0 then 1 else -1++-- Predict directly on standardized features (skipping de-standardization).+predictStandardized :: VU.Vector Double -> Double -> VU.Vector Double -> Double+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.+logisticLoss ::+    V.Vector (VU.Vector Double) ->+    VU.Vector Double ->+    VU.Vector Double ->+    Double ->+    Double+logisticLoss features labels w b =+    let n = V.length features+        loss i =+            let yi = labels VU.! i+                row = features V.! i+                margin = yi * (dotProduct w row + b)+             in if margin >= 0+                    then log (1 + exp (-margin))+                    else (-margin) + log (1 + exp margin)+     in sum [loss i | i <- [0 .. n - 1]] / fromIntegral n++------------------------------------------------------------------------+-- A1: Recover known hyperplane with no L1+------------------------------------------------------------------------++testA1RecoverHyperplane :: Test+testA1RecoverHyperplane = TestCase $ do+    let groundTruth = VU.fromList [0.7, -0.5]+        groundBias = 0.3+        rows = syntheticPoints 1 200 2+        labels = labelsForHyperplane rows groundTruth groundBias+        cfg =+            defaultSolverConfig+                { scL1Lambda = 0+                , scL2Lambda = 0+                , scMaxIter = 500+                , scTol = 1e-6+                }+        model = fitL1Logistic cfg rows labels (V.fromList ["x", "y"])+        cosSim = cosineSim (lmWeights model) groundTruth+        sameSignAll =+            all+                (\i -> predict model (rows V.! i) == labels VU.! i)+                [0 .. V.length rows - 1]+    assertBool+        ("recovered weights should align with ground truth (cos = " ++ show cosSim ++ ")")+        (cosSim > 0.99)+    assertBool "all training points predicted correctly" sameSignAll++------------------------------------------------------------------------+-- A2: L1 produces sparse weights+------------------------------------------------------------------------++testA2L1Sparsity :: Test+testA2L1Sparsity = TestCase $ do+    let groundTruth = VU.fromList [0, 1.2, 0, 0, -1.5, 0, 0, 0, 0, 0]+        groundBias = 0+        rows = syntheticPoints 7 500 10+        labels = labelsForHyperplane rows groundTruth groundBias+        cfg =+            defaultSolverConfig+                { scL1Lambda = 0.1+                , scL2Lambda = 0+                , scMaxIter = 500+                , scTol = 1e-6+                }+        names = V.fromList [T.pack ("f" ++ show i) | i <- [0 .. 9 :: Int]]+        model = fitL1Logistic cfg rows labels names+        ws = VU.toList (lmWeights model)+        nonZeroIdxs = [i | (i, w) <- zip [0 :: Int ..] ws, w /= 0]+        zeroIdxs = [i | (i, w) <- zip [0 :: Int ..] ws, w == 0]+    assertBool+        ( "informative feature 1 should have non-zero weight (got "+            ++ show (ws !! 1)+            ++ ")"+        )+        (ws !! 1 /= 0)+    assertBool+        ( "informative feature 4 should have non-zero weight (got "+            ++ show (ws !! 4)+            ++ ")"+        )+        (ws !! 4 /= 0)+    let noiseFeatures = [0, 2, 3, 5, 6, 7, 8, 9] :: [Int]+        noiseZero = length [i | i <- noiseFeatures, i `elem` zeroIdxs]+    assertBool+        ( "at least 6 noise features zeroed (got "+            ++ show noiseZero+            ++ "; non-zero idxs = "+            ++ show nonZeroIdxs+            ++ ")"+        )+        (noiseZero >= 6)++------------------------------------------------------------------------+-- A3: Convergence on well-conditioned input+------------------------------------------------------------------------++testA3Convergence :: Test+testA3Convergence = TestCase $ do+    let groundTruth = VU.fromList [1.0, -0.5, 0.7]+        rows = syntheticPoints 2 300 3+        labels = labelsForHyperplane rows groundTruth 0+        cfg =+            defaultSolverConfig+                { scL1Lambda = 0.01+                , scL2Lambda = 0+                , scMaxIter = 1000+                , scTol = 1e-5+                }+        model = fitL1Logistic cfg rows labels (V.fromList ["a", "b", "c"])+        (rowsStd, _, _, _) = standardize rows+        ws = lmWeights model+        b = lmIntercept model+        loss0 = logisticLoss rowsStd labels (VU.replicate 3 0) 0+        lossFit = logisticLoss rows labels ws b+    assertBool+        ( "loss decreased from initial (initial="+            ++ show loss0+            ++ ", final="+            ++ show lossFit+            ++ ")"+        )+        (lossFit < loss0)++------------------------------------------------------------------------+-- A4: Final loss <= initial loss (monotone or near-monotone in FISTA)+------------------------------------------------------------------------++testA4LossNotIncreasing :: Test+testA4LossNotIncreasing = TestCase $ do+    let groundTruth = VU.fromList [0.8, 0.4]+        rows = syntheticPoints 3 100 2+        labels = labelsForHyperplane rows groundTruth 0+        cfg = defaultSolverConfig{scL1Lambda = 0.05, scL2Lambda = 0, scMaxIter = 100}+        model = fitL1Logistic cfg rows labels (V.fromList ["x", "y"])+        loss0 = logisticLoss rows labels (VU.replicate 2 0) 0+        lossFit = logisticLoss rows labels (lmWeights model) (lmIntercept model)+    assertBool+        ( "final loss must be <= initial loss (l0="+            ++ show loss0+            ++ ", lf="+            ++ show lossFit+            ++ ")"+        )+        (lossFit <= loss0 + 1e-9)++------------------------------------------------------------------------+-- A5: Degenerate input — all labels +1+------------------------------------------------------------------------++testA5AllSameDirection :: Test+testA5AllSameDirection = TestCase $ do+    let rows = syntheticPoints 4 50 3+        labels = VU.replicate 50 1.0+        cfg = defaultSolverConfig{scL1Lambda = 0.01, scL2Lambda = 0, scMaxIter = 100}+        model = fitL1Logistic cfg rows labels (V.fromList ["a", "b", "c"])+        ws = VU.toList (lmWeights model)+        b = lmIntercept model+        anyNaN = any isNaN ws || isNaN b+        anyInf = any isInfinite ws || isInfinite b+        allPositive = all (\i -> predict model (rows V.! i) == 1) [0 .. V.length rows - 1]+    assertBool "no NaN in weights/intercept" (not anyNaN)+    assertBool "no Inf in weights/intercept" (not anyInf)+    assertBool+        "all-same labels should produce a positive-predicting model"+        allPositive++------------------------------------------------------------------------+-- A6: Degenerate — empty input+------------------------------------------------------------------------++testA6Empty :: Test+testA6Empty = TestCase $ do+    let cfg = defaultSolverConfig+        emptyRows = V.empty :: V.Vector (VU.Vector Double)+        emptyLabels = VU.empty :: VU.Vector Double+        names = V.fromList ["a", "b"]+        model = fitL1Logistic cfg emptyRows emptyLabels names+    assertEqual+        "empty input -> 2 zero weights"+        (VU.fromList [0, 0])+        (lmWeights model)+    assertEqual "empty input -> zero intercept" 0 (lmIntercept model)++------------------------------------------------------------------------+-- A7: Degenerate — constant feature+------------------------------------------------------------------------++testA7ConstantFeature :: Test+testA7ConstantFeature = TestCase $ do+    let baseRows = syntheticPoints 5 100 1+        rows =+            V.map+                (\row -> VU.fromList (0.5 : VU.toList row))+                baseRows+        groundTruth = VU.fromList [0.0, 1.0]+        labels = labelsForHyperplane rows groundTruth 0+        cfg =+            defaultSolverConfig+                { scL1Lambda = 0.01+                , scL2Lambda = 0+                , scMaxIter = 300+                , scTol = 1e-6+                }+        model = fitL1Logistic cfg rows labels (V.fromList ["constant", "signal"])+        ws = VU.toList (lmWeights model)+        anyBad = any (\x -> isNaN x || isInfinite x) ws+    w0 <- case ws of+        (w : _) -> pure w+        [] -> assertFailure "expected at least one weight"+    assertBool+        ("constant feature weight ~ 0 (got " ++ show w0 ++ ")")+        (abs w0 < 1e-6)+    assertBool+        ("signal feature non-zero (got " ++ show (ws !! 1) ++ ")")+        (ws !! 1 /= 0)+    assertBool "no NaN/Inf" (not anyBad)++------------------------------------------------------------------------+-- A8: Numerical stability with large feature values+------------------------------------------------------------------------++testA8LargeValues :: Test+testA8LargeValues = TestCase $ do+    let scale = 1000.0 :: Double+        baseRows = syntheticPoints 6 100 2+        rows = V.map (VU.map (* scale)) baseRows+        groundTruth = VU.fromList [0.5, -0.7]+        labels = labelsForHyperplane rows groundTruth 0+        cfg = defaultSolverConfig{scL1Lambda = 0.01, scL2Lambda = 0, scMaxIter = 300}+        model = fitL1Logistic cfg rows labels (V.fromList ["x", "y"])+        ws = VU.toList (lmWeights model)+        b = lmIntercept model+        anyBad = any (\x -> isNaN x || isInfinite x) (b : ws)+        sameSigns =+            length+                [ () | i <- [0 .. V.length rows - 1], predict model (rows V.! i) == labels VU.! i+                ]+    assertBool "no NaN/Inf with scaled features" (not anyBad)+    assertBool+        ( "should correctly classify the vast majority of rows ("+            ++ show sameSigns+            ++ "/100)"+        )+        (sameSigns >= 90)++------------------------------------------------------------------------+-- A9: Standardization round-trip — recovered weights point in the true+-- direction even when raw-feature scales differ by orders of magnitude.+------------------------------------------------------------------------++testA9StandardizationRoundTrip :: Test+testA9StandardizationRoundTrip = TestCase $ do+    let nRows = 80 :: Int+        col0 = [fromIntegral i * 5 :: Double | i <- [0 .. nRows - 1]]+        col1 = [fromIntegral i * 0.0025 :: Double | i <- [0 .. nRows - 1]]+        rows = V.fromList [VU.fromList [c0, c1] | (c0, c1) <- zip col0 col1]+        labels =+            VU.fromList+                [ if (c0 - 200) + 1000 * (c1 - 0.1) > 0 then 1.0 else -1.0+                | (c0, c1) <- zip col0 col1+                ]+        cfg =+            defaultSolverConfig+                { scL1Lambda = 1.0e-4+                , scL2Lambda = 0+                , scMaxIter = 2000+                , scTol = 1.0e-7+                }+        model = fitL1Logistic cfg rows labels (V.fromList ["x", "y"])+        truthDir = VU.fromList [1.0, 1000.0]+        cs = cosineSim (lmWeights model) truthDir+        trainPreds =+            [predict model (rows V.! i) | i <- [0 .. nRows - 1]]+        trainLabs =+            [labels VU.! i | i <- [0 .. nRows - 1]]+        correct =+            length+                [() | (p, l) <- zip trainPreds trainLabs, p == l]+    assertEqual "all training points correctly classified" nRows correct+    assertBool+        ( "recovered raw weights align with ground-truth direction across "+            ++ "vastly different feature scales (cos = "+            ++ show cs+            ++ ")"+        )+        (cs > 0.95)++------------------------------------------------------------------------+-- A10: Determinism — same input -> same output+------------------------------------------------------------------------++testA10Determinism :: Test+testA10Determinism = TestCase $ do+    let groundTruth = VU.fromList [0.6, 0.4]+        rows = syntheticPoints 9 60 2+        labels = labelsForHyperplane rows groundTruth 0+        cfg = defaultSolverConfig{scL1Lambda = 0.05, scL2Lambda = 0, scMaxIter = 200}+        m1 = fitL1Logistic cfg rows labels (V.fromList ["x", "y"])+        m2 = fitL1Logistic cfg rows labels (V.fromList ["x", "y"])+    assertEqual "same input -> same weights" (lmWeights m1) (lmWeights m2)+    assertEqual "same input -> same intercept" (lmIntercept m1) (lmIntercept m2)++------------------------------------------------------------------------+-- A11: Two-feature ground truth recovery (w_2/w_1 ratio)+------------------------------------------------------------------------++testA11GroundTruthRatio :: Test+testA11GroundTruthRatio = TestCase $ do+    let groundTruth = VU.fromList [1.0, 2.0]+        groundBias = -3.0+        n = 500+        baseRows = syntheticPoints 10 n 2+        rows = V.map (VU.map (* 3)) baseRows+        labels = labelsForHyperplane rows groundTruth groundBias+        cfg =+            defaultSolverConfig+                { scL1Lambda = 0.001+                , scL2Lambda = 0+                , scMaxIter = 1000+                , scTol = 1e-7+                }+        model = fitL1Logistic cfg rows labels (V.fromList ["x", "y"])+        ws = lmWeights model+        b = lmIntercept model+        ratio = (ws VU.! 1) / (ws VU.! 0)+        biasRatio = b / (ws VU.! 0)+    assertBool+        ("w2/w1 should approximate 2.0 (got " ++ show ratio ++ ")")+        (ratio > 1.7 && ratio < 2.3)+    assertBool+        ("b/w1 should approximate -3.0 (got " ++ show biasRatio ++ ")")+        (biasRatio > -3.4 && biasRatio < -2.6)++------------------------------------------------------------------------+-- B1: modelToExpr produces a well-typed Expr Bool+------------------------------------------------------------------------++testB1ExprWellTyped :: Test+testB1ExprWellTyped = TestCase $ do+    let model =+            LinearModel+                { lmWeights = VU.fromList [1.0, -2.0]+                , lmIntercept = 0.5+                , lmFeatureNames = V.fromList ["x", "y"]+                }+        expr = modelToExpr model+        df =+            D.fromNamedColumns+                [ ("x", DI.fromList ([0.0, 1.0, 2.0] :: [Double]))+                , ("y", DI.fromList ([0.0, 0.0, 5.0] :: [Double]))+                ]+        manual =+            [ (1.0 * 0.0 - 2.0 * 0.0 + 0.5) > 0+            , (1.0 * 1.0 - 2.0 * 0.0 + 0.5) > 0+            , (1.0 * 2.0 - 2.0 * 5.0 + 0.5) > 0+            ]+    case interpret @Bool df expr of+        Left e -> assertFailure ("interpret failed: " ++ show e)+        Right (DI.TColumn col) -> case DI.toVector @Bool col of+            Left e -> assertFailure ("toVector failed: " ++ show e)+            Right vals ->+                assertEqual "Expr matches manual evaluation" manual (V.toList vals)++------------------------------------------------------------------------+-- B2: Zero weights are dropped from the resulting Expr+------------------------------------------------------------------------++testB2ZeroWeightsPruned :: Test+testB2ZeroWeightsPruned = TestCase $ do+    let model =+            LinearModel+                { lmWeights = VU.fromList [0.0, 1.5, 0.0]+                , lmIntercept = 0.0+                , lmFeatureNames = V.fromList ["a", "b", "c"]+                }+        expr = modelToExpr model+        cols = sort (getColumns expr)+    assertEqual "only column b appears in the Expr" ["b"] cols++------------------------------------------------------------------------+-- A14: Constant feature at large raw value — weight must be exactly 0+-- and no NaN/Inf leaks into the rest of the fit.+------------------------------------------------------------------------++testA14ConstantHugeValue :: Test+testA14ConstantHugeValue = TestCase $ do+    let baseRows = syntheticPoints 14 100 1+        rows =+            V.map+                (\row -> VU.fromList (1.0e8 : VU.toList row))+                baseRows+        labels = labelsForHyperplane rows (VU.fromList [0.0, 1.0]) 0+        cfg = defaultSolverConfig{scL1Lambda = 0.01, scL2Lambda = 0, scMaxIter = 300}+        model = fitL1Logistic cfg rows labels (V.fromList ["constant", "signal"])+        ws = VU.toList (lmWeights model)+        b = lmIntercept model+        anyBad = any (\v -> isNaN v || isInfinite v) (b : ws)+    assertBool "no NaN/Inf with constant-at-1e8 feature" (not anyBad)+    w0 <- case ws of+        (w : _) -> pure w+        [] -> assertFailure "expected at least one weight"+    assertEqual+        "constant feature is dropped — weight is exactly zero"+        0+        w0+    assertBool+        ("signal feature has non-zero weight (got " ++ show (ws !! 1) ++ ")")+        (ws !! 1 /= 0)++------------------------------------------------------------------------+-- A15: Variance exactly zero (all rows identical for that column).+------------------------------------------------------------------------++testA15AllZeroFeature :: Test+testA15AllZeroFeature = TestCase $ do+    let baseRows = syntheticPoints 15 80 1+        rows =+            V.map+                (\row -> VU.fromList (0.0 : VU.toList row))+                baseRows+        labels = labelsForHyperplane rows (VU.fromList [0.0, 1.0]) 0+        cfg = defaultSolverConfig{scL1Lambda = 0.01, scL2Lambda = 0, scMaxIter = 300}+        model = fitL1Logistic cfg rows labels (V.fromList ["zero", "signal"])+        ws = VU.toList (lmWeights model)+    w0 <- case ws of+        (w : _) -> pure w+        [] -> assertFailure "expected at least one weight"+    assertEqual "zero-variance column has weight zero" 0 w0+    assertBool ("signal weight non-zero (" ++ show (ws !! 1) ++ ")") (ws !! 1 /= 0)++------------------------------------------------------------------------+-- A16: Severely imbalanced labels (99:1) — should not collapse to a+-- constant predictor on the majority class without some learning.+------------------------------------------------------------------------++testA16ImbalancedLabels :: Test+testA16ImbalancedLabels = TestCase $ do+    let nPos = 99+        nNeg = 1+        n = nPos + nNeg+        rows = syntheticPoints 16 n 2+        labels =+            VU.fromList+                (replicate nPos 1.0 ++ replicate nNeg (-1.0))+        cfg = defaultSolverConfig{scL1Lambda = 0.01, scL2Lambda = 0, scMaxIter = 500}+        model = fitL1Logistic cfg rows labels (V.fromList ["x", "y"])+        ws = VU.toList (lmWeights model)+        b = lmIntercept model+        anyBad = any (\v -> isNaN v || isInfinite v) (b : ws)+    assertBool "no NaN/Inf with 99:1 imbalance" (not anyBad)+    assertBool ("intercept favors majority class (got b=" ++ show b ++ ")") (b > 0)++------------------------------------------------------------------------+-- A17: Mixed per-feature raw scales — should not diverge.+------------------------------------------------------------------------++testA17ImbalancedRawScales :: Test+testA17ImbalancedRawScales = TestCase $ do+    let baseRows = syntheticPoints 17 100 3+        rows =+            V.map+                ( \row ->+                    let v0 = row VU.! 0+                        v1 = row VU.! 1+                        v2 = row VU.! 2+                     in VU.fromList [1.0e-6 * v0, v1, 1.0e6 * v2]+                )+                baseRows+        labels = labelsForHyperplane baseRows (VU.fromList [1.0, -0.5, 0.7]) 0+        cfg = defaultSolverConfig{scL1Lambda = 1.0e-4, scL2Lambda = 0, scMaxIter = 500}+        model = fitL1Logistic cfg rows labels (V.fromList ["tiny", "unit", "huge"])+        ws = VU.toList (lmWeights model)+        b = lmIntercept model+        anyBad = any (\v -> isNaN v || isInfinite v) (b : ws)+    assertBool ("no NaN/Inf with mixed scales (ws=" ++ show ws ++ ")") (not anyBad)+    let preds = [predict model (rows V.! i) | i <- [0 .. V.length rows - 1]]+        lbls = [labels VU.! i | i <- [0 .. VU.length labels - 1]]+        correct = length [() | (p, l) <- zip preds lbls, p == l]+    assertBool+        ("non-divergent under wild scales (got " ++ show correct ++ "/100)")+        (correct >= 65)++------------------------------------------------------------------------+-- A12: maxIter = 0 returns the initial point unchanged+------------------------------------------------------------------------++testA12MaxIterZero :: Test+testA12MaxIterZero = TestCase $ do+    let rows = syntheticPoints 20 50 2+        labels = labelsForHyperplane rows (VU.fromList [1.0, -0.5]) 0+        cfg = defaultSolverConfig{scMaxIter = 0}+        model = fitL1Logistic cfg rows labels (V.fromList ["x", "y"])+    assertEqual+        "maxIter=0 returns zero weights"+        (VU.fromList [0, 0])+        (lmWeights model)+    assertEqual "maxIter=0 returns zero intercept" 0 (lmIntercept model)++------------------------------------------------------------------------+-- A13: maxIter = 1 takes exactly one prox step (results differ from+-- the initial zero point but may not be near the optimum).+------------------------------------------------------------------------++testA13MaxIterOne :: Test+testA13MaxIterOne = TestCase $ do+    let rows = syntheticPoints 21 80 2+        labels = labelsForHyperplane rows (VU.fromList [1.0, -0.5]) 0+        cfg = defaultSolverConfig{scMaxIter = 1, scL1Lambda = 0.001, scL2Lambda = 0}+        cfg0 = cfg{scMaxIter = 0}+        m1 = fitL1Logistic cfg rows labels (V.fromList ["x", "y"])+        m0 = fitL1Logistic cfg0 rows labels (V.fromList ["x", "y"])+        anyNonZero v = not (VU.all (== 0) v)+    assertEqual "baseline m0 weights are zero" (VU.fromList [0, 0]) (lmWeights m0)+    assertBool+        ("maxIter=1 must change at least one weight (got " ++ show (lmWeights m1) ++ ")")+        (anyNonZero (lmWeights m1) || lmIntercept m1 /= 0)+    let badW = VU.any (\x -> isNaN x || isInfinite x) (lmWeights m1)+        badB = isNaN (lmIntercept m1) || isInfinite (lmIntercept m1)+    assertBool "no NaN/Inf after one iteration" (not (badW || badB))++------------------------------------------------------------------------+-- PR 3: Elastic Net recovery on correlated-feature pairs. Pure L1 picks one+-- of two correlated informative features; Elastic Net keeps both non-zero+-- (Zou & Hastie 2005 grouping effect). Cases: ρ ≈ 0.97 and ρ ≈ 0.7.+------------------------------------------------------------------------++-- Generate two correlated features f0, f1 with correlation ρ, plus+-- noise features f2..f7. Truth is sign(f0 + f1).+correlatedPairData ::+    Int -> Double -> (V.Vector (VU.Vector Double), VU.Vector Double)+correlatedPairData seed rho =+    let n = 400 :: Int+        d = 8 :: Int+        g0 = mkStdGen seed+        drawUnit = randomR (-1.0 :: Double, 1.0)+        drawRow !gIn =+            let (z0, g1) = drawUnit gIn+                (epsRaw, g2) = drawUnit g1+                eps = epsRaw * sqrt (max 0 (1 - rho * rho))+                f0 = z0+                f1 = rho * z0 + eps+                drawNoise k g+                    | k >= d - 2 = ([], g)+                    | otherwise =+                        let (x, g') = drawUnit g+                            (xs, g'') = drawNoise (k + 1) g'+                         in (x : xs, g'')+                (noise, g3) = drawNoise 0 g2+                row = f0 : f1 : noise+             in (VU.fromList row, g3)+        go 0 _ acc = reverse acc+        go k g acc =+            let (r, g') = drawRow g+             in go (k - 1) g' (r : acc)+        rows = V.fromList (go n g0 [])+        labels =+            VU.generate n $ \i ->+                let r = rows V.! i+                    s = VU.unsafeIndex r 0 + VU.unsafeIndex r 1+                 in if s > 0 then 1.0 else -1.0+     in (rows, labels)++testA19ElasticNetRecoveryHigh :: Test+testA19ElasticNetRecoveryHigh = TestCase $ do+    let (rows, labels) = correlatedPairData 31 0.97+        names = V.fromList ["f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7"]+        cfgEN = defaultSolverConfig{scL1Lambda = 0.05, scL2Lambda = 0.05, scMaxIter = 1000}+        men = fitL1Logistic cfgEN rows labels names+        wEN = VU.toList (lmWeights men)+        nzCount xs = length (filter (/= 0) xs)+        (aEN, bEN) = case wEN of+            (a : b : _) -> (a, b)+            _ -> error "elastic-net test: expected at least two weights"+    assertBool+        ("ρ=0.97 EN keeps f0 non-zero; wEN[:2] = " ++ show (take 2 wEN))+        (aEN /= 0)+    assertBool+        ("ρ=0.97 EN keeps f1 non-zero; wEN[:2] = " ++ show (take 2 wEN))+        (bEN /= 0)+    let ratio = abs aEN / max (abs bEN) 1e-9+    assertBool+        ("ρ=0.97 EN grouping: |w0/w1| ∈ [0.33, 3.0]; got ratio=" ++ show ratio)+        (ratio >= 0.33 && ratio <= 3.0)+    assertBool+        ("ρ=0.97 EN sparsity: total non-zero ≤ 5; got " ++ show (nzCount wEN))+        (nzCount wEN <= 5)++testA19ElasticNetRecoveryMid :: Test+testA19ElasticNetRecoveryMid = TestCase $ do+    let (rows, labels) = correlatedPairData 37 0.7+        names = V.fromList ["f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7"]+        cfgEN = defaultSolverConfig{scL1Lambda = 0.05, scL2Lambda = 0.05, scMaxIter = 1000}+        men = fitL1Logistic cfgEN rows labels names+        wEN = VU.toList (lmWeights men)+        (aEN, bEN) = case wEN of+            (a : b : _) -> (a, b)+            _ -> error "elastic-net test: expected at least two weights"+    assertBool+        ("ρ=0.7 EN keeps f0 non-zero; wEN[:2] = " ++ show (take 2 wEN))+        (aEN /= 0)+    assertBool+        ("ρ=0.7 EN keeps f1 non-zero; wEN[:2] = " ++ show (take 2 wEN))+        (bEN /= 0)+    let ratio = abs aEN / max (abs bEN) 1e-9+    assertBool+        ("ρ=0.7 EN grouping: |w0/w1| ∈ [0.33, 3.0]; got ratio=" ++ show ratio)+        (ratio >= 0.33 && ratio <= 3.0)++------------------------------------------------------------------------+-- PR 3: A20 — class-balanced fit on 95/5 imbalance. Unweighted, the intercept+-- polarises toward logit(0.95) ≈ 2.94; with class-balanced weights it sits+-- near 0 and predictions become roughly balanced on a symmetric test set.+------------------------------------------------------------------------++testA20ClassBalancedFit :: Test+testA20ClassBalancedFit = TestCase $ do+    let n = 200 :: Int+        nPos = 190 :: Int+        g0 = mkStdGen 41+        drawN = randomR (-1.0 :: Double, 1.0)+        drawRowAt mu g =+            let (z, g') = drawN g+                x = mu + 0.6 * z+             in (VU.singleton x, g')+        rowsAndLabels =+            let go _ 0 _ acc = reverse acc+                go !pCnt k g acc =+                    let !mu = if pCnt > 0 then 0.15 else -0.15+                        (row, g') = drawRowAt mu g+                        !y = if pCnt > 0 then 1.0 else -1.0+                     in go (pCnt - 1) (k - 1) g' ((row, y) : acc)+             in go nPos n g0 []+        rows = V.fromList (map fst rowsAndLabels)+        labels = VU.fromList (map snd rowsAndLabels)+        names = V.fromList ["x"]+        cfgUnbal =+            defaultSolverConfig+                { scL1Lambda = 0.001+                , scL2Lambda = 0+                , scMaxIter = 2000+                , scTol = 1e-7+                , scSampleWeights = Nothing+                }+        nNeg = n - nPos+        balanced =+            VU.generate n $ \i ->+                let !y = VU.unsafeIndex labels i+                 in if y > 0+                        then fromIntegral n / (2 * fromIntegral nPos)+                        else fromIntegral n / (2 * fromIntegral nNeg)+        cfgBal = cfgUnbal{scSampleWeights = Just balanced}+        mUnbal = fitL1Logistic cfgUnbal rows labels names+        mBal = fitL1Logistic cfgBal rows labels names+        bUnbal = lmIntercept mUnbal+        bBal = lmIntercept mBal+        testRows =+            V.fromList+                ( replicate 100 (VU.singleton 0.15)+                    ++ replicate 100 (VU.singleton (-0.15))+                )+        predFracPos m =+            let preds = V.map (predict m) testRows+                ps = V.length (V.filter (> 0) preds)+             in fromIntegral ps / fromIntegral (V.length testRows) :: Double+        fracUnbal = predFracPos mUnbal+        fracBal = predFracPos mBal+    assertBool+        ("unbalanced |b| > 2.0; got " ++ show bUnbal)+        (abs bUnbal > 2.0)+    assertBool+        ("balanced |b| < 0.3; got " ++ show bBal)+        (abs bBal < 0.3)+    assertBool+        ("unbalanced fraction-positive on balanced test ≥ 0.90; got " ++ show fracUnbal)+        (fracUnbal >= 0.90)+    assertBool+        ( "balanced fraction-positive on balanced test ∈ [0.40, 0.60]; got "+            ++ show fracBal+        )+        (fracBal >= 0.40 && fracBal <= 0.60)++------------------------------------------------------------------------+-- Test list+------------------------------------------------------------------------++tests :: [Test]+tests =+    [ TestLabel "A1 recover known hyperplane" testA1RecoverHyperplane+    , TestLabel "A2 L1 sparsity" testA2L1Sparsity+    , TestLabel "A3 convergence" testA3Convergence+    , TestLabel "A4 loss not increasing" testA4LossNotIncreasing+    , TestLabel "A5 all same direction" testA5AllSameDirection+    , TestLabel "A6 empty input" testA6Empty+    , TestLabel "A7 constant feature" testA7ConstantFeature+    , TestLabel "A8 large feature values" testA8LargeValues+    , TestLabel "A9 standardization round-trip" testA9StandardizationRoundTrip+    , TestLabel "A10 determinism" testA10Determinism+    , TestLabel "A11 ground truth ratio" testA11GroundTruthRatio+    , TestLabel "A12 maxIter zero" testA12MaxIterZero+    , TestLabel "A13 maxIter one" testA13MaxIterOne+    , TestLabel "A14 constant huge value" testA14ConstantHugeValue+    , TestLabel "A15 all-zero feature" testA15AllZeroFeature+    , TestLabel "A16 imbalanced 99:1 labels" testA16ImbalancedLabels+    , TestLabel "A17 imbalanced raw scales" testA17ImbalancedRawScales+    , TestLabel "B1 Expr well-typed" testB1ExprWellTyped+    , TestLabel "B2 zero weights pruned" testB2ZeroWeightsPruned+    , -- PR 3: Elastic Net + class-balanced weights.+      TestLabel "A19 Elastic Net grouping ρ=0.97" testA19ElasticNetRecoveryHigh+    , TestLabel "A19 Elastic Net grouping ρ=0.7" testA19ElasticNetRecoveryMid+    , TestLabel "A20 class-balanced fit on 95/5" testA20ClassBalancedFit+    ]
+ tests-internal/Main.hs view
@@ -0,0 +1,48 @@+{-# LANGUAGE ScopedTypeVariables #-}++module Main where++import qualified System.Exit as Exit++import Test.HUnit+import Test.QuickCheck++import qualified Cart+import qualified DecisionTree+import qualified Learn.EdgeCases+import qualified Learn.NumericalRigor+import qualified Learn.Numerics+import qualified Learn.Symbolic+import qualified LinearSolver+import qualified Properties.Simplify+import qualified TreePruning+import qualified Worklist++tests :: Test+tests =+    TestList $+        Cart.tests+            ++ DecisionTree.tests+            ++ LinearSolver.tests+            ++ TreePruning.tests+            ++ Worklist.tests+            ++ Learn.Numerics.tests+            ++ Learn.Symbolic.tests+            ++ Learn.EdgeCases.tests+            ++ Learn.NumericalRigor.tests++isSuccessful :: Result -> Bool+isSuccessful (Success{}) = True+isSuccessful _ = False++main :: IO ()+main = do+    result <- runTestTT tests+    if failures result > 0 || errors result > 0+        then Exit.exitFailure+        else do+            simpRes <- mapM (quickCheckWithResult stdArgs) Properties.Simplify.tests+            wlRes <- mapM (quickCheckWithResult stdArgs) Worklist.props+            if not (all isSuccessful simpRes) || not (all isSuccessful wlRes)+                then Exit.exitFailure+                else Exit.exitSuccess
+ tests-internal/Properties/Simplify.hs view
@@ -0,0 +1,169 @@+{-# LANGUAGE OverloadedStrings #-}+{-# 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.+-}+module Properties.Simplify (tests) where++import DataFrame.DecisionTree.Predict (predictWithTree)+import DataFrame.DecisionTree.Prune (pruneDead)+import DataFrame.DecisionTree.Types (Tree (..))+import qualified DataFrame.Functions as F+import DataFrame.Internal.Column (TypedColumn (TColumn), toVector)+import qualified DataFrame.Internal.Column as DI+import DataFrame.Internal.Expression (Expr, eqExpr)+import DataFrame.Internal.Interpreter (interpret)+import DataFrame.Internal.Simplify (simplify)+import DataFrame.Operators+import qualified DataFrameApi as D++import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Data.Vector.Unboxed as VU+import Test.QuickCheck++-- A fixture spanning the interesting rows: exact thresholds, gaps, NaN, null.+fixtureDF :: D.DataFrame+fixtureDF =+    D.fromNamedColumns+        [+            ( "x"+            , DI.fromList ([10, 20, 25, 30, 35, 40, 50, 0 / 0, -(1 / 0), 1 / 0] :: [Double])+            )+        , ("n", DI.fromList ([10, 20, 25, 30, 35, 40, 50, 0, 100, -5] :: [Int]))+        ,+            ( "m"+            , DI.fromList+                ( [ Just 10+                  , Nothing+                  , Just 30+                  , Just 35+                  , Nothing+                  , Just 50+                  , Just 0+                  , Just 30+                  , Nothing+                  , Just 40+                  ] ::+                    [Maybe Double]+                )+            )+        ]++thresholds :: [Double]+thresholds = [20, 25, 30, 35, 40]++-- ---- generators ----++-- strict-Bool comparison atoms over the Double column "x" and Int column "n"+genAtomBool :: Gen (Expr Bool)+genAtomBool = do+    t <- elements thresholds+    oneof+        [ elements+            [ F.col @Double "x" .< F.lit t+            , F.col @Double "x" .<= F.lit t+            , F.col @Double "x" .> F.lit t+            , F.col @Double "x" .>= F.lit t+            , F.col @Double "x" .== F.lit t+            , F.col @Double "x" ./= F.lit t+            ]+        , elements+            [ F.toDouble (F.col @Int "n") .< F.lit t+            , F.toDouble (F.col @Int "n") .<= F.lit t+            , F.toDouble (F.col @Int "n") .> F.lit t+            , F.toDouble (F.col @Int "n") .>= F.lit t+            ]+        ]++genBoolExpr :: Int -> Gen (Expr Bool)+genBoolExpr d+    | d <= 0 = genAtomBool+    | otherwise =+        oneof+            [ genAtomBool+            , F.and <$> genBoolExpr (d - 1) <*> genBoolExpr (d - 1)+            , F.or <$> genBoolExpr (d - 1) <*> genBoolExpr (d - 1)+            , F.not <$> genBoolExpr (d - 1)+            ]++-- nullable comparison atoms over the Maybe Double column "m"+genAtomMaybe :: Gen (Expr (Maybe Bool))+genAtomMaybe = do+    t <- elements thresholds+    elements+        [ F.col @(Maybe Double) "m" .< F.lit t+        , F.col @(Maybe Double) "m" .<= F.lit t+        , F.col @(Maybe Double) "m" .> F.lit t+        , F.col @(Maybe Double) "m" .>= F.lit t+        , F.col @(Maybe Double) "m" .== F.lit t+        , F.col @(Maybe Double) "m" ./= F.lit t+        ]++genMaybeExpr :: Int -> Gen (Expr (Maybe Bool))+genMaybeExpr d+    | d <= 0 = genAtomMaybe+    | otherwise =+        oneof+            [ genAtomMaybe+            , (.&&) <$> genMaybeExpr (d - 1) <*> genMaybeExpr (d - 1)+            , (.||) <$> genMaybeExpr (d - 1) <*> genMaybeExpr (d - 1)+            ]++genTree :: Int -> Gen (Tree T.Text)+genTree d+    | d <= 0 = Leaf <$> elements ["A", "B", "C"]+    | otherwise =+        oneof+            [ Leaf <$> elements ["A", "B", "C"]+            , do+                cond <- genAtomBool+                Branch cond <$> genTree (d - 1) <*> genTree (d - 1)+            ]++-- ---- evaluation helpers ----++evalBool :: D.DataFrame -> Expr Bool -> Maybe (VU.Vector Bool)+evalBool df e = case interpret @Bool df e of+    Right (TColumn tcol) -> either (const Nothing) Just (toVector @Bool @VU.Vector tcol)+    Left _ -> Nothing++evalMaybe :: D.DataFrame -> Expr (Maybe Bool) -> Maybe (V.Vector (Maybe Bool))+evalMaybe df e = case interpret @(Maybe Bool) df e of+    Right (TColumn tcol) -> either (const Nothing) Just (toVector @(Maybe Bool) @V.Vector tcol)+    Left _ -> Nothing++-- ---- properties ----++prop_simplifyPreservesBool :: Property+prop_simplifyPreservesBool =+    forAll (genBoolExpr 4) $ \e ->+        evalBool fixtureDF e === evalBool fixtureDF (simplify e)++prop_simplifyPreservesMaybe :: Property+prop_simplifyPreservesMaybe =+    forAll (genMaybeExpr 3) $ \e ->+        evalMaybe fixtureDF e === evalMaybe fixtureDF (simplify e)++prop_simplifyIdempotent :: Property+prop_simplifyIdempotent =+    forAll (genBoolExpr 4) $ \e ->+        let s = simplify e in property (eqExpr (simplify s) s)++prop_pruneDeadPreserves :: Property+prop_pruneDeadPreserves =+    forAll (genTree 4) $ \t ->+        let n = D.nRows fixtureDF+            predAll tr = [predictWithTree @T.Text "x" fixtureDF i tr | i <- [0 .. n - 1]]+         in predAll (pruneDead t) === predAll t++tests :: [Property]+tests =+    [ prop_simplifyPreservesBool+    , prop_simplifyPreservesMaybe+    , prop_simplifyIdempotent+    , prop_pruneDeadPreserves+    ]
+ tests-internal/TreePruning.hs view
@@ -0,0 +1,115 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++{- | Specification for the fitted-tree pruning pass ('pruneDead'): path-condition+entailment, the false-edge NaN gate, and same-branch collapse.+-}+module TreePruning (tests) where++import DataFrame.DecisionTree.Prune (pruneDead)+import DataFrame.DecisionTree.Types (Tree (..))+import qualified DataFrame.Functions as F+import DataFrame.Internal.Expression (eqExpr)+import DataFrame.Operators++import qualified Data.Text as T+import Test.HUnit++treeEq :: (Eq a) => Tree a -> Tree a -> Bool+treeEq (Leaf x) (Leaf y) = x == y+treeEq (Branch c1 l1 r1) (Branch c2 l2 r2) = eqExpr c1 c2 && treeEq l1 l2 && treeEq r1 r2+treeEq _ _ = False++prunesTo :: String -> Tree T.Text -> Tree T.Text -> Test+prunesTo label input want =+    TestLabel label . TestCase $+        assertBool+            (label ++ ": got " ++ show (pruneDead input) ++ " want " ++ show want)+            (treeEq (pruneDead input) want)++preserved :: String -> Tree T.Text -> Test+preserved label t = prunesTo label t t++pathEntailment :: [Test]+pathEntailment =+    [ prunesTo+        "ancestor entails child keeps true subtree"+        ( Branch+            (F.col @Double "age" .> F.lit (50 :: Double))+            (Branch (F.col @Double "age" .> F.lit (30 :: Double)) (Leaf "a") (Leaf "b"))+            (Leaf "c")+        )+        (Branch (F.col @Double "age" .> F.lit (50 :: Double)) (Leaf "a") (Leaf "c"))+    , prunesTo+        "ancestor refutes child keeps false subtree"+        ( Branch+            (F.col @Double "age" .> F.lit (50 :: Double))+            (Branch (F.col @Double "age" .< F.lit (40 :: Double)) (Leaf "a") (Leaf "b"))+            (Leaf "c")+        )+        (Branch (F.col @Double "age" .> F.lit (50 :: Double)) (Leaf "b") (Leaf "c"))+    ]++falseEdgeGate :: [Test]+falseEdgeGate =+    [ prunesTo+        "integral false edge entails child"+        ( Branch+            (F.toDouble (F.col @Int "ai") .> F.lit (50 :: Double))+            (Leaf "c")+            ( Branch+                (F.toDouble (F.col @Int "ai") .< F.lit (60 :: Double))+                (Leaf "a")+                (Leaf "b")+            )+        )+        ( Branch+            (F.toDouble (F.col @Int "ai") .> F.lit (50 :: Double))+            (Leaf "c")+            (Leaf "a")+        )+    ]++sameBranchCollapse :: [Test]+sameBranchCollapse =+    [ prunesTo+        "equal leaves collapse the branch"+        (Branch (F.col @Double "age" .> F.lit (50 :: Double)) (Leaf "a") (Leaf "a"))+        (Leaf "a")+    , prunesTo+        "collapse cascades upward"+        ( Branch+            (F.col @Double "age" .> F.lit (50 :: Double))+            (Branch (F.col @Double "hours" .> F.lit (40 :: Double)) (Leaf "a") (Leaf "a"))+            (Leaf "a")+        )+        (Leaf "a")+    ]++preservedTrees :: [Test]+preservedTrees =+    [ preserved+        "child not tight enough is kept"+        ( Branch+            (F.col @Double "age" .> F.lit (50 :: Double))+            (Branch (F.col @Double "age" .> F.lit (60 :: Double)) (Leaf "a") (Leaf "b"))+            (Leaf "c")+        )+    , preserved+        "double false edge is kept (NaN)"+        ( Branch+            (F.col @Double "weight" .> F.lit (50 :: Double))+            (Leaf "c")+            (Branch (F.col @Double "weight" .< F.lit (60 :: Double)) (Leaf "a") (Leaf "b"))+        )+    , preserved+        "cross-column descendant is kept"+        ( Branch+            (F.col @Double "age" .> F.lit (50 :: Double))+            (Branch (F.col @Double "income" .> F.lit (30000 :: Double)) (Leaf "a") (Leaf "b"))+            (Leaf "c")+        )+    ]++tests :: [Test]+tests = concat [pathEntailment, falseEdgeGate, sameBranchCollapse, preservedTrees]
+ tests-internal/Worklist.hs view
@@ -0,0 +1,421 @@+{-# 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.+-}+module Worklist (tests, props) where++import DataFrame.DecisionTree.CondVec (+    CondVec (..),+    combineAndVec,+    combineOrVec,+    materializeCondVec,+ )+import DataFrame.DecisionTree.Pool (+    DedupMode (Structural, TruthVector),+    boolExprsVec,+    saturateCandidates,+ )+import qualified DataFrame.Functions as F+import qualified DataFrame.Internal.Column as DI+import DataFrame.Internal.Expression (+    Expr,+    compareExpr,+    eSize,+    eqExpr,+    normalize,+ )+import DataFrame.Operators+import qualified DataFrameApi as D++import Data.Function (on)+import Data.List (minimumBy, nubBy)+import qualified Data.Maybe+import qualified Data.Set as Set+import qualified Data.Vector.Unboxed as VU+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.+fixtureDF :: D.DataFrame+fixtureDF =+    D.fromNamedColumns+        [ ("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]))+        ]++mat :: Expr Bool -> CondVec+mat e =+    Data.Maybe.fromMaybe+        (error "Worklist.mat: could not materialize")+        (materializeCondVec fixtureDF e)++xGt, xLt, yGt, yLt, zGt, zLt :: Double -> CondVec+xGt n = mat (F.col @Double "x" .>. F.lit n)+xLt n = mat (F.col @Double "x" .<. F.lit n)+yGt n = mat (F.col @Double "y" .>. F.lit n)+yLt n = mat (F.col @Double "y" .<. F.lit n)+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.+notLe2 :: CondVec+notLe2 = mat (F.not (F.col @Double "x" .<=. F.lit 2))++litTrue :: CondVec+litTrue = mat (F.lit True)++keyOf :: CondVec -> String+keyOf = show . normalize . cvExpr++keySet :: [CondVec] -> Set.Set String+keySet = Set.fromList . map keyOf++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.+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.+argminKey :: [Bool] -> [CondVec] -> String+argminKey lbls = keyOf . minimumBy (compare `on` penBy lbls)++-- Oracle: the current depth-bounded generate-all.+ref :: Int -> [CondVec] -> [CondVec]+ref d base = boolExprsVec base base 0 d++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.+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.+wideDF :: D.DataFrame+wideDF =+    D.fromNamedColumns+        [ ("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]))+        ]++matW :: Expr Bool -> CondVec+matW e =+    Data.Maybe.fromMaybe+        (error "Worklist.matW: could not materialize")+        (materializeCondVec wideDF e)++wideBase :: [CondVec]+wideBase =+    [ matW (F.col @Double "a" .>. F.lit 3)+    , matW (F.col @Double "b" .<. F.lit 5)+    , matW (F.col @Double "c" .>=. F.lit 3)+    ]++------------------------------------------------------------------------+-- HUnit cases+------------------------------------------------------------------------++tests :: [Test]+tests =+    [ TestLabel "structural: same distinct set as oracle" . TestCase $+        assertEqual+            "keySet"+            (keySet (ref 2 base3))+            (keySet (saturateCandidates Structural 2 base3))+    , TestLabel "structural: output deduped (length == distinct keys)" . TestCase $+        let out = saturateCandidates Structural 2 base3+         in assertEqual "no eqExpr duplicates" (Set.size (keySet out)) (length out)+    , TestLabel "structural: base atoms all present" . TestCase $+        assertBool "base subset of output" $+            keySet base3 `Set.isSubsetOf` keySet (saturateCandidates Structural 1 base3)+    , TestLabel "structural: deterministic" . TestCase $+        assertEqual+            "two runs identical"+            (map keyOf (saturateCandidates Structural 2 base3))+            (map keyOf (saturateCandidates Structural 2 base3))+    , TestLabel "structural: consolidation flows through the worklist" . TestCase $+        assertEqual+            "consolidated set"+            (keySet [xGt 2, xGt 4])+            (keySet (saturateCandidates Structural 2 [xGt 2, xGt 4]))+    , TestLabel "truth-vector: all output truth vectors distinct" . TestCase $+        let out = saturateCandidates TruthVector 2 [xGt 2, yLt 3]+         in assertEqual "distinct cvVecs" (Set.size (truthSet out)) (length out)+    , TestLabel "truth-vector: collapses same-truth atoms (x>2, y<3)" . TestCase $+        assertEqual+            "collapsed to one"+            1+            (length (saturateCandidates TruthVector 1 [xGt 2, yLt 3]))+    , TestLabel "truth-vector: reaches the semantic floor (no split dropped)"+        . TestCase+        $ assertEqual+            "same distinct truth vectors as oracle"+            (truthSet (ref 2 base3))+            (truthSet (saturateCandidates TruthVector 2 base3))+    , TestLabel "truth-vector: strictly fewer candidates when a collision exists"+        . TestCase+        $ assertBool "|truth| < |structural|"+        $ length (saturateCandidates TruthVector 2 collBase)+            < length (saturateCandidates Structural 2 collBase)+    , TestLabel "truth-vector: keeps the minimum-eSize representative" . TestCase $+        let out = saturateCandidates TruthVector 1 [xGt 2, notLe2]+         in do+                assertEqual "min eSize survivor" [3] (map (eSize . cvExpr) out)+                assertEqual "survivor is x>2" [keyOf (xGt 2)] (map keyOf out)+    , TestLabel "truth-vector: tie-break independent of input order" . TestCase $+        assertEqual+            "order-independent survivor"+            (map keyOf (saturateCandidates TruthVector 1 [xGt 2, yLt 3]))+            (map keyOf (saturateCandidates TruthVector 1 [yLt 3, xGt 2]))+    , TestLabel "edge: empty base" . TestCase $+        assertEqual+            "empty in, empty out"+            Set.empty+            (keySet (saturateCandidates Structural 2 []))+    , TestLabel "edge: singleton base" . TestCase $+        assertEqual+            "same as oracle"+            (keySet (ref 2 [xGt 2]))+            (keySet (saturateCandidates Structural 2 [xGt 2]))+    , TestLabel "edge: maxDepth 0 is the base only" . TestCase $+        assertEqual+            "no expansion"+            (keySet base3)+            (keySet (saturateCandidates Structural 0 base3))+    , TestLabel "edge: duplicate-seeded base" . TestCase $+        let base = [xGt 2, xGt 2, yGt 2]+         in assertEqual+                "dedups seed, same as oracle"+                (keySet (ref 2 base))+                (keySet (saturateCandidates Structural 2 base))+    , TestLabel "edge: literal operand" . TestCase $+        let base = [xGt 2, litTrue]+         in assertEqual+                "same as oracle"+                (keySet (ref 2 base))+                (keySet (saturateCandidates Structural 2 base))+    , TestLabel "law: cvVec is a homomorphism over AND" . TestCase $+        assertEqual+            "cvVec(a∧b) == cvVec a && cvVec b"+            (VU.toList (VU.zipWith (&&) (cvVec (xGt 2)) (cvVec (yGt 2))))+            (VU.toList (cvVec (combineAndVec (xGt 2) (yGt 2))))+    , TestLabel "law: cvVec is a homomorphism over OR" . TestCase $+        assertEqual+            "cvVec(a∨b) == cvVec a || cvVec b"+            (VU.toList (VU.zipWith (||) (cvVec (xGt 2)) (cvVec (yGt 2))))+            (VU.toList (cvVec (combineOrVec (xGt 2) (yGt 2))))+    , TestLabel "law: consolidated expr re-interprets to its cached vector" . TestCase $+        let c = combineAndVec (xGt 2) (xGt 4)+         in assertEqual+                "cached == re-interpreted"+                (VU.toList (cvVec c))+                (VU.toList (cvVec (mat (cvExpr c))))+    , TestLabel "structural: output order matches the deduped oracle" . TestCase $+        assertEqual+            "deduped-oracle order"+            (map keyOf (nubBy ((==) `on` keyOf) (ref 2 base3)))+            (map keyOf (saturateCandidates Structural 2 base3))+    , TestLabel+        "structural: matches oracle set+order at depth 3+4 (non-consolidating)"+        . TestCase+        $ let b = [xGt 2, yGt 2, zGt 1]+              deduped d = map keyOf (nubBy ((==) `on` keyOf) (ref d b))+              out d = map keyOf (saturateCandidates Structural d b)+           in do+                assertEqual "set d3" (Set.fromList (deduped 3)) (Set.fromList (out 3))+                assertEqual "order d3" (deduped 3) (out 3)+                assertEqual "set d4" (Set.fromList (deduped 4)) (Set.fromList (out 4))+                assertEqual "order d4" (deduped 4) (out 4)+    , TestLabel "structural: stabilizes at fixpoint (depth cap is a no-op past it)"+        . TestCase+        $ assertEqual+            "depth 2 == depth 5"+            (keySet (saturateCandidates Structural 2 [xGt 1, xGt 2, xGt 3, xGt 4]))+            (keySet (saturateCandidates Structural 5 [xGt 1, xGt 2, xGt 3, xGt 4]))+    , TestLabel "truth-vector: reaches the floor on a wider fixture" . TestCase $+        assertEqual+            "same distinct truth vectors as oracle"+            (truthSet (ref 2 wideBase))+            (truthSet (saturateCandidates TruthVector 2 wideBase))+    , TestLabel "selection: surfaces the oracle's winning combination" . TestCase $+        let lbls = [False, False, False, True, True, False]+            base = [xGt 2, xLt 5, yGt 2]+         in assertEqual+                "same argmin as oracle"+                (argminKey lbls (ref 2 base))+                (argminKey lbls (saturateCandidates Structural 2 base))+    , TestLabel "selection: tie-winner tracks input order, matching the oracle"+        . TestCase+        $ let lbls = [False, False, False, True, True, True]+           in do+                assertEqual+                    "x>2-first order"+                    (argminKey lbls (ref 2 [xGt 2, yLt 3]))+                    (argminKey lbls (saturateCandidates Structural 2 [xGt 2, yLt 3]))+                assertEqual+                    "y<3-first order"+                    (argminKey lbls (ref 2 [yLt 3, xGt 2]))+                    (argminKey lbls (saturateCandidates Structural 2 [yLt 3, xGt 2]))+    , TestLabel+        "bounded: output is the distinct closure, below the oracle's materialized count"+        . TestCase+        $ let base = [xGt 1, xGt 2, xGt 3, xGt 4]+              gen = ref 3 base+           in do+                assertEqual+                    "output bounded to the distinct closure"+                    (Set.size (keySet gen))+                    (length (saturateCandidates Structural 3 base))+                assertBool+                    "oracle materializes more than the closure (the explosion the worklist avoids)"+                    (Set.size (keySet gen) < length gen)+    , TestLabel "structural: maxDepth 1 is base-only (no combination round)"+        . TestCase+        $ assertEqual+            "no combination at depth 1"+            (keySet (ref 1 [xGt 2, yGt 2]))+            (keySet (saturateCandidates Structural 1 [xGt 2, yGt 2]))+    , TestLabel "structural: base atoms survive the combination round" . TestCase $+        assertBool "base subset of output at depth 2" $+            keySet base3 `Set.isSubsetOf` keySet (saturateCandidates Structural 2 base3)+    , TestLabel+        "structural: re-saturating a closed base is stable (fixpoint idempotence)"+        . TestCase+        $ let b = [xGt 1, xGt 2, xGt 3, xGt 4]+           in assertEqual+                "saturate ∘ saturate == saturate"+                (keySet (saturateCandidates Structural 2 b))+                (keySet (saturateCandidates Structural 2 (saturateCandidates Structural 2 b)))+    , TestLabel "law: combiner key is order-independent (congruence basis)" . TestCase $+        do+            assertEqual+                "AND consolidation commutes at the key"+                (keyOf (combineAndVec (xGt 2) (xGt 4)))+                (keyOf (combineAndVec (xGt 4) (xGt 2)))+            assertEqual+                "OR consolidation commutes at the key"+                (keyOf (combineOrVec (xGt 2) (xGt 4)))+                (keyOf (combineOrVec (xGt 4) (xGt 2)))+            assertEqual+                "cross-column AND commutes at the key"+                (keyOf (combineAndVec (xGt 2) (yGt 2)))+                (keyOf (combineAndVec (yGt 2) (xGt 2)))+    , TestLabel+        "truth-vector: section is the (eSize, compareExpr)-minimum of the fiber"+        . TestCase+        $ let fiber = [xGt 2, yLt 3]+              cmp a b =+                compare (eSize (cvExpr a)) (eSize (cvExpr b))+                    <> compareExpr (cvExpr a) (cvExpr b)+              want = keyOf (minimumBy cmp fiber)+           in assertEqual+                "min-section survivor"+                [want]+                (map keyOf (saturateCandidates TruthVector 1 fiber))+    ]++------------------------------------------------------------------------+-- QuickCheck properties (over generated base pools and depths)+------------------------------------------------------------------------++genAtom :: Gen CondVec+genAtom =+    elements+        [xGt 1, xGt 2, xGt 3, xLt 2, xLt 4, yGt 1, yGt 3, yLt 3, zGt 1, zGt 3, zLt 4]++genBase :: Gen [CondVec]+genBase = choose (2, 5) >>= \k -> vectorOf k genAtom++-- Random label vector of the fixture's length (6 rows), for selection-preservation.+genLabels :: Gen [Bool]+genLabels = vectorOf 6 (elements [False, True])++prop_structuralSameSet :: Property+prop_structuralSameSet =+    forAllBlind genBase $ \base ->+        forAll (choose (1, 3)) $ \d ->+            counterexample (show (map keyOf base, d)) $+                keySet (saturateCandidates Structural d base) === keySet (ref d base)++prop_truthVectorFloor :: Property+prop_truthVectorFloor =+    forAllBlind genBase $ \base ->+        forAll (choose (1, 3)) $ \d ->+            counterexample (show (map keyOf base, d)) $+                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 ->+        forAllBlind (shuffle base) $ \base' ->+            forAll (choose (1, 3)) $ \d ->+                counterexample (show (map keyOf base, map keyOf base', d)) $+                    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 ->+        forAllBlind genLabels $ \lbls ->+            forAll (choose (2, 3)) $ \d ->+                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 ->+        forAll (choose (2, 3)) $ \d ->+            counterexample (show (map keyOf base, d)) $+                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.+genCand :: Gen CondVec+genCand =+    oneof+        [ genAtom+        , combineAndVec <$> genAtom <*> genAtom+        , combineOrVec <$> genAtom <*> genAtom+        ]++prop_keyFaithful :: Property+prop_keyFaithful =+    forAllBlind genCand $ \a ->+        forAllBlind genCand $ \b ->+            counterexample (keyOf a ++ "  vs  " ++ keyOf b) $+                (keyOf a == keyOf b) === eqExpr (cvExpr a) (cvExpr b)++props :: [Property]+props =+    [ prop_structuralSameSet+    , prop_truthVectorFloor+    , prop_orderInvariant+    , prop_selectionPreserved+    , prop_orderMatchesOracle+    , prop_keyFaithful+    ]