srtree-db-0.1.2.0: app/FitData.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE ScopedTypeVariables #-}
module FitData
( FitDataOpts(..)
, fitdataParser
, runFitData
, runRefit
) where
import Control.Concurrent (getNumCapabilities, threadDelay)
import Control.Concurrent.Async (mapConcurrently_, mapConcurrently)
import Control.Monad (replicateM, when, unless, void, forM_)
import Control.Exception (bracket, SomeException, catch, SomeAsyncException(..))
import Data.IORef
import Data.List (maximumBy, foldl', sortBy)
import Data.Maybe (catMaybes)
import Data.Ord (comparing)
import qualified Data.IntMap.Strict as IntMap
import qualified Data.IntSet as IntSet
import qualified Data.Text as T
import qualified Data.Vector.Unboxed as VU
import Options.Applicative hiding (Const)
import System.IO (hPutStrLn, hFlush, stdout, stderr)
import System.Random (randomRIO)
import Data.SRTree (Fix(..), SRTree(..), Op(..), relabelParams, countParamsUniq, countNodes)
import Data.SRTree.Print (showExpr)
import Data.SRTree.Eval (Target, compile)
import Data.SRTree.Datasets (loadDataset)
import Algorithm.SRTree.NonlinearOpt (compileLossAndGrad, minimizeNLLWith)
import Algorithm.SRTree.Likelihoods (Loss(..), Distribution(..))
import Algorithm.SRTree.AD (ADBackEnd(..))
import Algorithm.SRTree.AD.Unboxed (setMTPopParallel)
import Numeric.Optimization.NLOPT (LocalAlgorithm(..))
import Algorithm.EqSat.Egraph (EClassId, EClass(..))
import Algorithm.EqSat.Storage.Backend (SqlBackend(..), SqlValue(..), sqlToInt)
import Algorithm.EqSat.Storage.Extract (reconstructFromCache, expandTreeIds)
import Algorithm.EqSat.Storage.SQLite (loadPagesBulk)
import Algorithm.EqSat.Storage.Query (getOrCreateDataset, writeDatasetFit)
import Algorithm.EqSat.Storage.Schema (createSchemaFit)
import Algorithm.EqSat.Storage.Types (serializeTheta)
import Database.SQLite3 (Database, open, close, exec)
-- | CLI options for the fitdata sub-command.
data FitDataOpts = FitDataOpts
{ fitdataEgraph :: String
, fitdataFitdb :: String
, fitdataDataset :: String
, fitdataData :: String
, fitdataLoss :: Loss
, fitdataHasHeader :: Bool
, fitdataNRep :: Int
, fitdataNIter :: Int
, fitdataBatchSize :: Int
, fitdataQuiet :: Bool
} deriving (Show)
fitdataParser :: Parser FitDataOpts
fitdataParser = FitDataOpts
<$> strOption
( long "egraph"
<> metavar "FILE"
<> help "Path to e-graph database" )
<*> strOption
( long "fitdb"
<> metavar "FILE"
<> help "Path to fit database" )
<*> strOption
( long "dataset"
<> metavar "NAME"
<> help "Dataset name" )
<*> strOption
( long "data"
<> metavar "SPEC"
<> help "Dataset CSV spec: file:start:end:target:features:yerr" )
<*> option auto
( long "loss"
<> value (NLL Gaussian)
<> metavar "LOSS"
<> help "Loss function (MSE, NLL Gaussian, etc.)" )
<*> flag True False
( long "no-header"
<> help "CSV has no header row (default: has header)" )
<*> option auto
( long "n-rep"
<> value 1
<> metavar "N"
<> help "Number of random restarts per expression" )
<*> option auto
( long "n-iter"
<> value 30
<> metavar "N"
<> help "Max NLopt iterations" )
<*> option auto
( long "batch-size"
<> value 10000
<> metavar "N"
<> help "Fit N expressions per batch" )
<*> switch
( long "quiet"
<> short 'q'
<> help "Suppress per-expression output; print progress every 10k expressions" )
-- | Run the fitdata sub-command.
runFitData :: FitDataOpts -> IO ()
runFitData opts = do
let FitDataOpts{..} = opts
putStrLn $ "Loading dataset: " ++ fitdataData
hFlush stdout
((xTrain, yTrain, _xVal, _yVal), (mYErr, _), _varnames, _target) <-
loadDataset fitdataData fitdataHasHeader
let nNoiseParams = case fitdataLoss of
NLL Gaussian -> 1
NLL ROXY -> 3
_ -> 0
putStrLn $ "Opening egraph: " ++ fitdataEgraph ++ "..."
putStrLn $ "Opening fitdb: " ++ fitdataFitdb ++ "..."
hFlush stdout
withSQLite fitdataFitdb $ \fitDb -> do
createSchemaFit fitDb
withSQLite fitdataEgraph $ \egDb -> do
dsid <- getOrCreateDataset fitDb fitdataDataset
total <- countUnfitted egDb fitDb dsid
putStrLn $ "Found " ++ show total ++ " unfitted e-classes"
hFlush stdout
if total == 0
then putStrLn "Nothing to fit."
else do
nCaps <- getNumCapabilities
counter <- newIORef (0 :: Int)
nanSet <- newIORef IntSet.empty
nanCount <- newIORef (0 :: Int)
-- Phase 0-1: load pages + expand (reads from egraph DB)
-- Accumulates invalid/analytical fits in an IORef (no DB writes)
let loadPhase pendingRef batch = do
let batchIds = IntSet.fromList batch
batchPages <- loadPagesBulk egDb (IntSet.toList batchIds)
let !cache0 = batchPages
let expandLoop !cache = do
let needed = foldl' (\s eid -> s `IntSet.union` expandTreeIds cache eid) IntSet.empty batch
missing = IntSet.toList (IntSet.difference needed (IntSet.fromList (IntMap.keys cache)))
if null missing
then pure (cache, needed)
else do
putStrLn $ " Loading " ++ show (length missing) ++ " sub-expression pages..."
hFlush stdout
newPages <- loadPagesBulk egDb missing
expandLoop (cache `IntMap.union` newPages)
(cache1, needed) <- expandLoop cache0
-- Phase 2: build jobs (reconstruct, handle cache misses)
let toFit = IntSet.toList needed
mjobs <- mapM (buildJobNoWrite cache1 nNoiseParams counter pendingRef) toFit
let jobs = sortBy (comparing jobSize) (catMaybes mjobs)
-- Phase 3: classify bottom-up, pruning NaN, collecting survivors
survivorRef <- newIORef ([] :: [FitJob])
beforeNan <- readIORef nanCount
mapM_ (classifyNoWrite fitdataQuiet nanSet counter nanCount survivorRef pendingRef dsid cache1 xTrain yTrain mYErr fitdataLoss nNoiseParams) jobs
survivors <- readIORef survivorRef
afterNan <- readIORef nanCount
when (afterNan > beforeNan) $
unless fitdataQuiet $ do
putStrLn $ " +" ++ show (afterNan - beforeNan)
++ " eclasses inserted with NaN this batch (total " ++ show afterNan ++ ")"
hFlush stdout
pure survivors
-- Phase 4-5: parallel NLopt + batch write (uses fitDb)
let fitPhase pendingRef survivors = do
let chunks = chunk nCaps survivors
setMTPopParallel False
results <- fmap concat $ mapConcurrently (mapM (fitOneNLopt fitdataQuiet xTrain yTrain mYErr fitdataLoss fitdataNIter fitdataNRep counter)) chunks
setMTPopParallel True
-- Batch write all pending fits (invalid + analytical + NLopt)
pending <- atomicModifyIORef' pendingRef (\ps -> ([], ps))
execDb fitDb "BEGIN"
forM_ pending $ \(FitPending eid fit theta sz) ->
case fit of
Nothing -> writeInvalidFit fitDb dsid eid -- invalid: fitted=1, fitness=NULL
Just f -> writeDatasetFit fitDb dsid eid (Just f) Nothing theta sz
forM_ results $ \(FitResult eid fit theta sz) ->
writeDatasetFit fitDb dsid eid (Just fit) Nothing theta sz
execDb fitDb "COMMIT"
unless fitdataQuiet $ putStrLn " [checkpoint] committed batch"
-- Stream IDs in batches, process sequentially (O(1) memory for IDs)
pendingRef <- newIORef ([] :: [FitPending])
let processBatch batch = do
survivors <- loadPhase pendingRef batch
fitPhase pendingRef survivors
processStreamingBatches egDb fitDb dsid fitdataBatchSize processBatch
fitted <- readIORef counter
putStrLn $ "Fitted " ++ show fitted ++ "/" ++ show total
++ " expressions"
-- | A unit of NLopt work: a reconstructed, relabeled expression with its
-- parameter count and node size precomputed.
data FitJob = FitJob
{ jobEid :: !EClassId
, jobTree :: !(Fix SRTree)
, jobFree :: !Bool -- ^ structurally parameter-free (no Param nodes)
, jobNp :: !Int -- ^ total free params incl. loss noise params
, jobSize :: !Int
}
-- | Result of fitting one expression (pure data, no DB side effects).
data FitResult = FitResult
{ frEid :: !EClassId
, frFitness :: !Double
, frTheta :: !T.Text
, frSize :: !Int
}
-- | A pending DB write (accumulated during loadPhase, written in fitPhase).
data FitPending = FitPending
{ fpEid :: !EClassId
, fpFitness :: !(Maybe Double)
, fpTheta :: !T.Text
, fpSize :: !Int
}
-- | Reconstruct a job, accumulating invalid writes instead of writing immediately.
buildJobNoWrite :: IntMap.IntMap EClass -> Int -> IORef Int -> IORef [FitPending]
-> EClassId -> IO (Maybe FitJob)
buildJobNoWrite cache nNoiseParams counter pendingRef eid = do
case reconstructFromCache cache eid of
Nothing -> do
atomicModifyIORef' pendingRef (\ps -> (FitPending eid Nothing (T.pack (serializeTheta [])) 0 : ps, ()))
atomicModifyIORef' counter (\n -> let !n' = n + 1 in (n', ()))
pure Nothing
Just tree -> do
let !tree' = relabelParams tree
!nup = countParamsUniq tree'
!np = nup + nNoiseParams
!free = nup == 0
!sz = countNodes tree'
pure (Just (FitJob eid tree' free np sz))
-- | Classify without writing to DB. Accumulates pending writes.
classifyNoWrite :: Bool -> IORef IntSet.IntSet -> IORef Int -> IORef Int
-> IORef [FitJob] -> IORef [FitPending]
-> Int -> IntMap.IntMap EClass
-> [VU.Vector Double] -> VU.Vector Double -> Maybe (VU.Vector Double)
-> Loss -> Int -> FitJob -> IO ()
classifyNoWrite quiet nanSet counter nanCount survivorRef pendingRef dsid cache xTrain yTrain mYErr loss nNoiseParams (FitJob eid tree' free np sz) = do
let desc = expandTreeIds cache eid
nan <- readIORef nanSet
if not (IntSet.null (IntSet.intersection desc nan))
then pruneNoWrite
else if not free
then survivor
else do
let fitness = analyticalFit loss xTrain yTrain tree'
if isInvalid fitness
then pruneNoWrite
else if np == 0
then do
atomicModifyIORef' pendingRef (\ps -> (FitPending eid (Just fitness) (T.pack (serializeTheta [])) sz : ps, ()))
atomicModifyIORef' counter (\n -> let !n' = n + 1 in (n', ()))
unless quiet $ putStrLn $ " eclass " ++ show eid ++ " (" ++ takeExpr tree' ++ "): fitness=" ++ showFit fitness ++ " [analytical]"
else survivor
where
pruneNoWrite = do
atomicModifyIORef' pendingRef (\ps -> (FitPending eid Nothing (T.pack (serializeTheta [])) 0 : ps, ()))
atomicModifyIORef' nanSet (\s -> (IntSet.insert eid s, ()))
atomicModifyIORef' nanCount (\n -> let !n' = n + 1 in (n', ()))
atomicModifyIORef' counter (\n -> let !n' = n + 1 in (n', ()))
survivor = do
atomicModifyIORef' survivorRef (\xs -> (FitJob eid tree' free np sz : xs, ()))
-- | Classify a single eclass (run in bottom-up order). If the expression
-- contains any known-NaN subexpression, or is a parameter-less expression that
-- evaluates to NaN/infinity, it is pruned (written as a NULL-fitness fitted row)
-- and recorded in @nanSet@ so its ancestors propagate. Otherwise it is queued as
-- an NLopt survivor.
classify :: SqlBackend db
=> Bool -> IORef IntSet.IntSet -> IORef Int -> IORef Int -> IORef [FitJob]
-> db -> Int -> IntMap.IntMap EClass
-> [VU.Vector Double] -> VU.Vector Double -> Maybe (VU.Vector Double)
-> Loss -> Int -> FitJob -> IO ()
classify quiet nanSet counter nanCount survivorRef db dsid cache xTrain yTrain mYErr loss nNoiseParams (FitJob eid tree' free np sz) = do
let desc = expandTreeIds cache eid
nan <- readIORef nanSet
if not (IntSet.null (IntSet.intersection desc nan))
then prune
else if not free
then survivor
else do
-- Structurally parameter-free: evaluate analytically to decide NaN.
-- Handles the noise-param loss case (e.g. NLL Gaussian, np = nNoiseParams).
let fitness = analyticalFit loss xTrain yTrain tree'
if isInvalid fitness
then prune
else if np == 0
then do
-- Fully parameter-less: analytic fit is exact.
writeDatasetFit db dsid eid (Just fitness) Nothing
(T.pack (serializeTheta [])) sz
atomicModifyIORef' counter (\n -> let !n' = n + 1 in (n', ()))
unless quiet $ putStrLn $ " eclass " ++ show eid ++ " (" ++ takeExpr tree' ++ "): fitness=" ++ showFit fitness ++ " [analytical]"
else survivor
where
-- An eclass doomed to NaN (contains a known-NaN subexpr, or is itself NaN):
-- write a NULL-fitness fitted row, record it, and count it.
prune = do
writeInvalidFit db dsid eid
atomicModifyIORef' nanSet (\s -> (IntSet.insert eid s, ()))
atomicModifyIORef' nanCount (\n -> let !n' = n + 1 in (n', ()))
atomicModifyIORef' counter (\n -> let !n' = n + 1 in (n', ()))
survivor = do
atomicModifyIORef' survivorRef (\xs -> (FitJob eid tree' free np sz : xs, ()))
-- | Fit one expression via NLopt. Only called on survivors that are not
-- statically NaN. Runs @nRep@ restarts and keeps the best.
-- Writes result to DB immediately.
fitOne :: SqlBackend db
=> Bool -> db -> Int
-> [VU.Vector Double] -> VU.Vector Double -> Maybe (VU.Vector Double)
-> Loss -> Int -> Int -> IORef Int -> FitJob -> IO ()
fitOne quiet db dsid xTrain yTrain mYErr loss nIter nRep counter job = do
fr <- fitOneNLopt quiet xTrain yTrain mYErr loss nIter nRep counter job
writeDatasetFit db dsid (frEid fr) (Just (frFitness fr)) Nothing (frTheta fr) (frSize fr)
-- | Pure NLopt fit (no DB side effects). Returns a FitResult.
fitOneNLopt :: Bool
-> [VU.Vector Double] -> VU.Vector Double -> Maybe (VU.Vector Double)
-> Loss -> Int -> Int -> IORef Int -> FitJob -> IO FitResult
fitOneNLopt quiet xTrain yTrain mYErr loss nIter nRep counter (FitJob eid tree' _ np sz) = do
let funAndGrad = compileLossAndGrad MultiThread loss mYErr xTrain yTrain tree'
runRestart = do
theta0 <- VU.replicateM np (randomRIO (-1, 1))
let (theta, lossVal, _) = minimizeNLLWith funAndGrad VAR1 nIter theta0
pure (negate lossVal, theta)
results <- replicateM nRep runRestart
let (bestFitness, bestTheta) = maximumBy (comparing fst) results
!thetaText = T.pack (serializeTheta [bestTheta])
atomicModifyIORef' counter (\n -> let !n' = n + 1 in (n', ()))
unless quiet $ putStrLn $ " eclass " ++ show eid ++ " (" ++ takeExpr tree' ++ "): fitness=" ++ showFit bestFitness
pure (FitResult eid bestFitness thetaText sz)
-- | Whether a fitness value is unusable (NaN or +/-Infinity), so it can be
-- pruned and propagated to ancestors.
isInvalid :: Double -> Bool
isInvalid f = isNaN f || isInfinite f
-- | Write a fitted row with NULL fitness (used for pruned / cache-miss eclasses).
-- Marks @fitted = 1@ so the eclass is removed from the unfitted queue but stays
-- distinguishable from rows with a real fitness.
writeInvalidFit :: SqlBackend db => db -> Int -> EClassId -> IO ()
writeInvalidFit db dsid eid =
runDb db
"INSERT INTO dataset_fit (dataset_id, eid, fitness, dl, theta, size, evaluated, fitted) \
\VALUES (?, ?, NULL, NULL, '', 0, 0, 1) \
\ON CONFLICT (dataset_id, eid) DO UPDATE SET \
\fitness = NULL, dl = NULL, theta = '', size = 0, evaluated = 0, fitted = 1"
[SqlInteger (fromIntegral dsid), SqlInteger (fromIntegral eid)]
-- | Analytical fitness for parameter-free expressions.
-- No NLopt needed — compute loss directly.
analyticalFit :: Loss -> [VU.Vector Double] -> VU.Vector Double -> Fix SRTree -> Double
analyticalFit loss xTrain yTrain tree =
let preds = compile xTrain tree VU.empty -- evaluate with empty theta
m = fromIntegral (VU.length yTrain) :: Double
residuals = VU.zipWith (-) preds yTrain
in case loss of
NLL Gaussian ->
let mse = VU.sum (VU.map (\r -> r * r) residuals) / m
sigma2 = mse
nll = negate (m / 2 * log (2 * pi * sigma2) + m / 2)
in if isNaN nll || isInfinite nll then -(1/0) else nll
MSE ->
let mse = VU.sum (VU.map (\r -> r * r) residuals) / m
in negate mse
_ ->
let mse = VU.sum (VU.map (\r -> r * r) residuals) / m
in negate mse -- fallback: use MSE
-- | Count unfitted e-classes for a dataset (cross-DB: egraph + fit).
countUnfitted :: (SqlBackend db1, SqlBackend db2) => db1 -> db2 -> Int -> IO Int
countUnfitted egDb fitDb dsid = do
-- Total eclasses in egraph
totalRows <- queryDb egDb "SELECT COUNT(*) FROM eclass" []
let totalEclasses = case totalRows of { [[cnt]] -> sqlToInt cnt; _ -> 0 }
-- Fitted eclasses in fit DB
fittedRows <- queryDb fitDb
"SELECT COUNT(*) FROM dataset_fit WHERE dataset_id = ? AND fitted = 1"
[SqlInteger (fromIntegral dsid)]
let totalFitted = case fittedRows of { [[cnt]] -> sqlToInt cnt; _ -> 0 }
pure (totalEclasses - totalFitted)
-- | Load all fitted eclass IDs for a dataset into an IntSet.
loadFittedSet :: SqlBackend db => db -> Int -> IO IntSet.IntSet
loadFittedSet fitDb dsid = do
rows <- queryDb fitDb
"SELECT eid FROM dataset_fit WHERE dataset_id = ? AND fitted = 1"
[SqlInteger (fromIntegral dsid)]
pure $ IntSet.fromList [ sqlToInt eid | [eid] <- rows ]
-- | Stream unfitted e-class IDs in batches, processing each batch
-- without materializing the full ID list in memory.
-- Cross-DB: streams from egraph, filters against fit DB.
processStreamingBatches :: (SqlBackend db1, SqlBackend db2)
=> db1 -> db2 -> Int -> Int -> ([EClassId] -> IO ()) -> IO ()
processStreamingBatches egDb fitDb dsid batchSize processBatch = do
fittedSet <- loadFittedSet fitDb dsid
batchRef <- newIORef ([] :: [EClassId])
countRef <- newIORef (0 :: Int)
foldQueryDb egDb
"SELECT eid FROM eclass ORDER BY eid"
[]
()
(\() cols -> case cols of
[eidCol] -> do
let !eid = sqlToInt eidCol
if IntSet.member eid fittedSet
then pure ()
else do
batch <- readIORef batchRef
let !batch' = eid : batch
n <- readIORef countRef
let !n' = n + 1
writeIORef countRef n'
if n' >= batchSize
then do
processBatch (reverse batch')
writeIORef batchRef []
writeIORef countRef 0
else writeIORef batchRef batch'
pure ()
_ -> pure ())
remaining <- readIORef batchRef
when (not (null remaining)) $ processBatch (reverse remaining)
chunk :: Int -> [a] -> [[a]]
chunk _ [] = []
chunk n xs = let (h, t) = splitAt n xs in h : chunk n t
takeExpr :: Fix SRTree -> String
takeExpr t
| length s > 40 = take 40 s ++ "..."
| otherwise = s
where s = showExpr t
showFit :: Double -> String
showFit f
| f == (-1/0) = "-Infinity"
| f == (1/0) = "Infinity"
| isNaN f = "NaN"
| otherwise = show (fromIntegral (round (f * 1000) :: Int) / 1000 :: Double)
withSQLite :: String -> (Database -> IO a) -> IO a
withSQLite path f = bracket openDb close f
where
openDb = do
db <- open (T.pack path)
exec db "PRAGMA journal_mode=DELETE"
exec db "PRAGMA busy_timeout = 30000"
pure db
-- | Run refit: clear all fitted data for a dataset, then re-fit everything.
runRefit :: FitDataOpts -> IO ()
runRefit opts = do
let FitDataOpts{..} = opts
putStrLn $ "Refitting dataset: " ++ fitdataDataset
putStrLn $ "Clearing previous fit data..."
hFlush stdout
withSQLite fitdataFitdb $ \fitDb -> do
createSchemaFit fitDb
dsid <- getOrCreateDataset fitDb fitdataDataset
runDb fitDb "DELETE FROM dataset_fit WHERE dataset_id = ?" [SqlInteger (fromIntegral dsid)]
putStrLn $ "Cleared fit data for dataset " ++ show dsid ++ "."
hFlush stdout
-- Now run the normal fitdata flow
runFitData opts