srtree-db-0.1.1.0: app/FitData.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module FitData
( FitDataOpts(..)
, fitdataParser
, runFitData
) where
import Control.Exception (bracket, SomeException, catch, displayException)
import Control.Monad.State.Strict (runStateT)
import Data.IORef
import Data.List (maximumBy)
import Data.Ord (comparing)
import qualified Data.Text as T
import qualified Data.Vector.Unboxed as VU
import Options.Applicative hiding (Const)
import System.Random (randomRIO)
import Data.SRTree (Fix(..), SRTree(..), Op(..), relabelParams, countParamsUniq, countNodes)
import Data.SRTree.Print (showExpr)
import Data.SRTree.Eval (Target)
import Data.SRTree.Datasets (loadDataset)
import Algorithm.SRTree.NonlinearOpt (minimizeNLL')
import Algorithm.SRTree.Likelihoods (Loss(..), Distribution(..), readLoss)
import Algorithm.SRTree.AD (ADBackEnd(..))
import Numeric.Optimization.NLOPT (LocalAlgorithm(..))
import Algorithm.EqSat.Egraph (EGraph(..), EClassId, getBestExpr, canonical)
import Algorithm.EqSat.Storage.Backend (SqlBackend(..), SqlValue(..), sqlToInt)
import Algorithm.EqSat.Storage.SQLite (loadGraphLazy, saveGraph, flushStore)
import Algorithm.EqSat.Storage.Query (getOrCreateDataset, writeDatasetFit)
import Algorithm.EqSat.Storage.Types (serializeTheta)
import Database.SQLite3 (Database, open, close)
-- | CLI options for the fitdata sub-command.
data FitDataOpts = FitDataOpts
{ fitdataDb :: String
, fitdataDataset :: String
, fitdataData :: String
, fitdataLoss :: Loss
, fitdataHasHeader :: Bool
, fitdataNRep :: Int
, fitdataNIter :: Int
, fitdataBatchSize :: Int
} deriving (Show)
fitdataParser :: Parser FitDataOpts
fitdataParser = FitDataOpts
<$> strOption
( long "db"
<> metavar "FILE"
<> help "SQLite database file path" )
<*> 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.)" )
<*> switch
( long "has-header"
<> help "CSV has header row (default: True)" )
<*> option auto
( long "n-rep"
<> value 1
<> metavar "N"
<> help "Number of random restarts per expression" )
<*> option auto
( long "n-iter"
<> value 100
<> metavar "N"
<> help "Max NLopt iterations" )
<*> option auto
( long "batch-size"
<> value 100
<> metavar "N"
<> help "Fit N expressions per commit batch" )
-- | Run the fitdata sub-command.
runFitData :: FitDataOpts -> IO ()
runFitData opts = do
let FitDataOpts{..} = opts
-- 1. Load dataset
putStrLn $ "Loading dataset: " ++ fitdataData
((xTrain, yTrain, _xVal, _yVal), (mYErr, _), _varnames, _target) <-
loadDataset fitdataData fitdataHasHeader
let nNoiseParams = case fitdataLoss of
NLL Gaussian -> 1
NLL ROXY -> 3
_ -> 0
-- 2. Load paged graph and query for unfitted e-classes
putStrLn $ "Loading paged graph from " ++ fitdataDb ++ "..."
withSQLite fitdataDb $ \db -> do
dsid <- getOrCreateDataset db fitdataDataset
er <- loadGraphLazy db dsid
case er of
Left err -> putStrLn $ "loadGraphLazy failed: " ++ err
Right eg -> do
-- Query for unfitted e-classes
unfitted <- queryUnfitted db dsid
let total = length unfitted
putStrLn $ "Found " ++ show total ++ " unfitted e-classes"
if total == 0
then putStrLn "Nothing to fit."
else do
-- Process in batches
counter <- newIORef (0 :: Int)
let processBatch [] = pure ()
processBatch batch = do
mapM_ (fitOne db dsid eg xTrain yTrain mYErr fitdataLoss fitdataNIter fitdataNRep nNoiseParams counter) batch
-- Commit checkpoint
flushStore eg
putStrLn $ " [checkpoint] committed batch"
let batches = chunk fitdataBatchSize unfitted
mapM_ processBatch batches
fitted <- readIORef counter
putStrLn $ "Fitted " ++ show fitted ++ "/" ++ show total
++ " expressions"
-- | Fit a single e-class and write the result.
fitOne :: SqlBackend db
=> db -> Int -> EGraph
-> [VU.Vector Double] -> VU.Vector Double -> Maybe (VU.Vector Double)
-> Loss -> Int -> Int -> Int -> IORef Int -> EClassId -> IO ()
fitOne db dsid eg xTrain yTrain mYErr loss nIter nRep nNoiseParams counter eid = do
n <- readIORef counter
let label = "[" ++ show (n+1) ++ "]"
-- Extract best expression from the e-class
mTree <- runStateT (getBestExpr eid) eg >>= pure . Just . fst
case mTree of
Nothing -> do
putStrLn $ label ++ " eclass " ++ show eid ++ ": could not extract expression (skipped)"
modifyIORef' counter (+1)
Just tree -> do
let tree' = relabelParams tree
np = countParamsUniq tree' + nNoiseParams
sz = countNodes tree'
-- Skip expressions with no parameters (pure constants/variables)
if np == 0
then do
putStrLn $ label ++ " eclass " ++ show eid ++ ": no parameters (skipped)"
modifyIORef' counter (+1)
else do
-- Try multiple random restarts, pick the best
results <- mapM (\_ -> fitOneRandom xTrain yTrain mYErr loss nIter tree' np) [1..nRep]
let (bestFitness, bestTheta) = maximumBy (comparing fst) results
-- Write result to DB
writeDatasetFit db dsid eid (Just bestFitness) Nothing
(T.pack (serializeTheta [bestTheta])) sz
putStrLn $ label ++ " eclass " ++ show eid ++ " (" ++ takeExpr tree' ++ "): fitness=" ++ showFit bestFitness
modifyIORef' counter (+1)
-- | Fit an expression with a random initial theta.
fitOneRandom :: [VU.Vector Double] -> VU.Vector Double -> Maybe (VU.Vector Double)
-> Loss -> Int -> Fix SRTree -> Int -> IO (Double, VU.Vector Double)
fitOneRandom xTrain yTrain mYErr loss nIter tree np = do
theta0 <- VU.replicateM np (randomRIO (-1, 1))
let (theta, lossVal, _) = minimizeNLL' VAR1 SingleThread loss mYErr nIter xTrain yTrain tree theta0
fitness = negate lossVal
pure (fitness, theta)
-- | Query for e-class IDs that are not yet fitted for a dataset.
queryUnfitted :: SqlBackend db => db -> Int -> IO [EClassId]
queryUnfitted db dsid = do
rows <- queryDb db
"SELECT e.eid FROM eclass e \
\LEFT JOIN dataset_fit df ON df.eid = e.eid AND df.dataset_id = ? \
\WHERE df.fitted IS NULL OR df.fitted = 0"
[SqlInteger (fromIntegral dsid)]
pure [ sqlToInt eid | [eid] <- rows ]
-- | Chunk a list into sub-lists of the given size.
chunk :: Int -> [a] -> [[a]]
chunk _ [] = []
chunk n xs = let (h, t) = splitAt n xs in h : chunk n t
-- | Take first few chars of an expression for display.
takeExpr :: Fix SRTree -> String
takeExpr t
| length s > 40 = take 40 s ++ "..."
| otherwise = s
where s = showExpr t
-- | Format fitness for display.
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)
-- | Open a SQLite database, run an action, and close it.
withSQLite :: String -> (Database -> IO a) -> IO a
withSQLite path = bracket (open (T.pack path)) close