packages feed

concraft-hr-0.1.0.2: tools/concraft-hr.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE RecordWildCards #-}


import           Control.Applicative ((<$>))
import           Control.Monad (unless, forM_)

import           Data.Double.Conversion.Text (toShortest)

import           System.Console.CmdArgs
import           System.IO (hFlush, stdout)

import qualified Network as N
import qualified Numeric.SGD as SGD

import qualified Data.Text.Lazy as L
import qualified Data.Text.Lazy.IO as L
import qualified Data.Text      as T
import qualified Data.Text.IO      as T
import qualified Data.List.Split as Split
import qualified Data.Char as Char
import           Data.Tagset.Positional (parseTagset)
import qualified Data.Tagset.Positional as P
import           Data.Maybe (fromMaybe)
import qualified Data.Map as M

import qualified NLP.Concraft.Morphosyntax.Accuracy as Acc
import qualified NLP.Concraft.Guess as Guess

import qualified NLP.Concraft.Croatian as C
import           NLP.Concraft (tagset)
import qualified NLP.Concraft.Croatian.Request as R
import qualified NLP.Concraft.Croatian.Server as S
import qualified NLP.Concraft.Croatian.Morphosyntax as X
import qualified NLP.Concraft.Morphosyntax as X hiding (Word, orth)

import           Paths_concraft_hr (version, getDataFileName)
import           Data.Version (showVersion)

import qualified NLP.Morphosyntax.Analyzer as A

-- | Default port number.
portDefault :: Int
portDefault = 43982


---------------------------------------
-- Command line options
---------------------------------------

-- | A description of the Concraft-hr tool.
concraftDesc :: String
concraftDesc = "Concraft-hr " ++ showVersion version


data Concraft
  =
    -- | Training mode. Requires a path to the training file and output file, all
    -- other arguments are optional. It is recommended that one at least tries to
    -- find a proper regularization value, otherwise it is possible that the model
    -- fits the data a lot. On Croatian dataset the fit with the default regularization
    -- value is close to 100% (not good if you want to tag something other than your
    -- training set).
    Train
    { trainPath	    :: FilePath
    , evalPath      :: Maybe FilePath
    , tagsetPath    :: Maybe FilePath
    , noAna         :: Bool
    , inAnalyzer    :: Maybe FilePath
    , gIterNum      :: Double
    , dIterNum      :: Double   -- Double?
    , gBatchSize    :: Int
    , dBatchSize    :: Int
    , gRegVar       :: Double
    , dRegVar       :: Double
    , gGain0        :: Double
    , dGain0        :: Double
    , gTau          :: Double
    , dTau          :: Double
    , disk          :: Bool
    , outModel      :: FilePath
    , guessNum      :: Int
    , r0            :: Guess.R0T }
    -- | Tagging mode. This one requires a model and all other parameters are optional.
    -- It is recommended to precompute the analysis (set of possible tags) for all words
    -- if testing/evaluating the models, otherwise the analyzer is necessary for the
    -- tagging to work.
  | Tag
    { inModel       :: FilePath
    , inAnalyzer    :: Maybe FilePath
    , noAna         :: Bool
    , marginals     :: Bool }
    -- , guessNum      :: Int }
    -- | Server mode starts the server and requires a path to the model and analyzer.
    -- It is recommended to provide a path to the analyzer as it might
    -- happen that 'Client' started requires additional analysis. If not the analyzer
    -- won't be used but currently it's necessary to provide a path.
  | Server
    { inModel       :: FilePath
    , inAnalyzer    :: Maybe FilePath
    , port          :: Int }
    -- | Client mode starts a client which communicates with the 'Server' in a type-safe
    -- manner. The results are produced to standard output on the client side.
  | Client
    { noAna         :: Bool
    , tagsetPath    :: Maybe FilePath
    , marginals     :: Bool
    , host          :: String
    , port          :: Int }
    -- | Compare mode can be used to produce accuracy results for the two provided
    -- tagged sentences in files.
  | Compare
    { tagsetPath    :: Maybe FilePath
    , refPath       :: FilePath
    , otherPath     :: FilePath
    }
    -- | Prune mode given a path to the model, output model, and threshold value removes
    -- the features from the tagging model which have weights below the threshold (in log-domain).
  | Prune
    { inModel       :: FilePath
    , outModel      :: FilePath
    , threshold     :: Double }
--   | ReAna
--     { format	    :: Format }
  deriving (Data, Typeable, Show)


trainMode :: Concraft
trainMode = Train
    { trainPath = def &= argPos 1 &= typ "TRAIN-FILE"
    , evalPath = def &= typFile &= help "Evaluation file"
    , tagsetPath = def &= typFile &= help "Tagset definition file"
    , noAna = False &= help "Do not perform reanalysis - (False)"
    , inAnalyzer= def &= typFile &= help "Morphosyntactic analyzer - needed if noana is false"
    -- , discardHidden = False &= help "Discard hidden features"
    , gIterNum = 20 &= help "Number of SGD iterations of guessing model - (20)"
    , dIterNum = 20 &= help "Number of SGD iterations of disambiguation model - (20)"
    , gBatchSize = 50 &= help "Batch size for SGD of guessing model - (50)"
    , dBatchSize = 50 &= help "Batch size for SGD of disambiguation model - (50)"
    , gRegVar = 10.0 &= help "Regularization variance of guessing model - (10.0)"
    , dRegVar = 10.0 &= help "Regularization variance of disambiguation model - (10.0)"
    , gGain0 = 1.0 &= help "Initial gain parameter of guessing model - (1.0)"
    , dGain0 = 1.0 &= help "Initial gain parameter of disambiguation model - (1.0)"
    , gTau = 5.0 &= help "Initial tau parameter of guessing model - (5.0)"
    , dTau = 5.0 &= help "Initial tau parameter for disambiguation model - (5.0)"
    , disk = False &= help "Store SGD dataset on disk - (False)"
    , outModel = def &= typFile &= help "Output Model file"
    , guessNum = 10 &= help "Number of guessed tags for each unknown word - (10)"
    , r0 = Guess.OovChosen &= help "R0 construction method (AnyInterps, AnyChosen, OovChosen) - (OovChosen)" }
     &= help "Training mode - needs training data - outputs a CRF model to a given filepath.\
                \ Values or modes shown between the brackets are default values."


tagMode :: Concraft
tagMode = Tag
    { inModel   = def &= argPos 0 &= typ "MODEL-FILE"
    , inAnalyzer= def &= typFile &= help "Morphosyntactic analyzer - needed if noana is false"
    , noAna     = False &= help "Do not analyse input text - (False)"
    , marginals = False &= help "Tag with marginal probabilities - (False)" }
    -- , guessNum = 10 &= help "Number of guessed tags for each unknown word" }
    &= help "Given tokenized input data it outputs the same tokenized form with tags next to each word.\
                \ Values shown between the brackets are default values."

serverMode :: Concraft
serverMode = Server
    { inModel = def &= argPos 0 &= typ "MODEL-FILE" &= help "Concraft model file"
    , inAnalyzer= def &= typFile &= help "Morphosyntactic analyzer file"
    , port    = portDefault &= help "Port number" }
    &= help "Starts a server which can process requests of tokenized sentences - tagging and analysis."


clientMode :: Concraft
clientMode = Client
    { noAna   = False &= help "Do not perform reanalysis"
    , tagsetPath = def &= typFile &= help "Tagset definition file"
    , port    = portDefault &= help "Port number - (43982)"
    , host    = "localhost" &= help "Server host name"
    , marginals = False &= help "Tag with marginal probabilities" }
    &= help "Starts a client which sends requests of tokenized sentences, received from stdin, to server."


compareMode :: Concraft
compareMode = Compare
    { refPath   = def &= argPos 1 &= typ "REFERENCE-FILE"
    , otherPath = def &= argPos 2 &= typ "OTHER-FILE"
    , tagsetPath = def &= typFile &= help "Tagset definition file"
    }
    &= help "Compares the two given files -- meant to be used for accuracy checking of the model."


pruneMode :: Concraft
pruneMode = Prune
    { inModel   = def &= argPos 0 &= typ "INPUT-MODEL"
    , outModel  = def &= argPos 1 &= typ "OUTPUT-MODEL"
    , threshold = 0.05 &=
        help "Remove disambiguation features below the threshold - (0.05)" }
    &= help "Prunes (removes) the features of the model (in log-domain) with weights below the given threshold."

argModes :: Mode (CmdArgs Concraft)
argModes = cmdArgsMode $ modes
    [trainMode, tagMode, serverMode, clientMode, compareMode, pruneMode]
    &= summary concraftDesc
    &= program "concraft-hr"


---------------------------------------
-- Main
---------------------------------------


main :: IO ()
main = exec =<< cmdArgsRun argModes


exec :: Concraft -> IO ()


exec Train{..} = do
    tagsetPath' <- case tagsetPath of
        Nothing -> getDataFileName "config/tagset.cfg"
        Just x  -> return x
    tset <- parseTagset tagsetPath' <$> readFile tagsetPath'
    let train0 = parseTrain tset trainPath
    let eval0  = case evalPath of
            Nothing -> return []
            Just x  -> parseTrain tset x
    let alyzer = fmap A.load inAnalyzer
    let ean    = A.create tset A.emptyConf []  -- dirty `undefined` bugfix
    analyzer <- fromMaybe (if noAna then return ean else error errMsg) alyzer
    let af = analyze $ A.getTags analyzer -- analysis function
    let train1 = if noAna then train0 else map af <$> train0
    let eval1  = if noAna then eval0  else map af <$> eval0

    concraft <- C.train (trainConf tset) train1 eval1
    unless (null outModel) $ do
        putStrLn $ "\nSaving model in " ++ outModel ++ "..."
        C.saveModel outModel concraft
  where
    analyze af sent = X.addAnalysis sent (map (af . X.orth . X.word) sent)
    gsgdArgs = SGD.SgdArgs
        { SGD.batchSize = gBatchSize
        , SGD.regVar = gRegVar
        , SGD.iterNum = gIterNum
        , SGD.gain0 = gGain0
        , SGD.tau = gTau }
    dsgdArgs = SGD.SgdArgs
        { SGD.batchSize = dBatchSize
        , SGD.regVar = dRegVar
        , SGD.iterNum = dIterNum
        , SGD.gain0 = dGain0
        , SGD.tau = dTau }
    trainConf tagset = C.TrainConf
        { tagset    = tagset
        , gSgdArgs  = gsgdArgs
        , dSgdArgs  = dsgdArgs
        , reana     = not noAna
        , onDisk    = disk
        , guessNum  = guessNum
        , r0        = r0 }
    errMsg = "Please define the path to analyzer or set --noana!\n\
             \Without the analyzer it isn't known whether the words \
             \are out-of-dictionary or not, so automatically all are\
             \ marked as out-of-dictionary. This can impact the performance."

exec Tag{..} = do
    cft    <- C.loadModel inModel
    let alyzer = fmap A.load inAnalyzer
    let ean    = A.create (tagset cft) A.emptyConf [] -- dirty `undefined` bugfix
    analyzer <- fromMaybe (if noAna then return ean else error errMsg) alyzer
    let tset = tagset cft
    inp <- parseContent tset <$> L.getContents
    out <- R.short analyzer cft $ rq $ buildReq inp
    showData tset $ fmap (addTags inp) out
  where
    -- | The @x@ is sentence of words and possible interpretations.
    -- If the chosen tagging mod isn't marginals, it is expected that
    -- the result of the tagging is a list of tuples @(a,b)@ the
    -- set of possible tags (which were guessed by the guessing model or
    -- as a result of analysis from the 'Analyzer') @a@, and the disambiguated
    -- tag @b@. The 'X.Seg' is then zipped with a tag to prepare the results
    -- for the output.
    addTags x = zip x . map (map snd)
    -- | Builds a request and adds the body which will be sent to the server.
    rq x = R.Request { R.rqBody = x }
    errMsg = "Please define the path to analyzer or set --noana!\n\
             \Without the analyzer it isn't known whether the words \
             \are out-of-dictionary or not, so automatically all are\
             \ marked as out-of-dictionary. This can impact the performance."
    -- | Depending on the no-analysis and marginals boolean values a proper
    -- constructor is chosen. Constructors having an A suffix obviously are
    -- used to indicate that additional analysis is needed.
    buildReq =
        case (noAna, marginals) of
            (True,True)  -> R.Marginal
            (True,False) -> R.PlainTag
            (False,True) -> R.MarginalA
            (False,False)-> R.PlainTagA

exec Server{..} = do
    putStr "Loading model..." >> hFlush stdout
    concraft <- C.loadModel inModel
    putStr "Loading analyzer..." >> hFlush stdout
    let alyzer = fmap A.load inAnalyzer
    analyzer <- fromMaybe (error errMsg) alyzer
    putStrLn " done"
    let portNum = N.PortNumber $ fromIntegral port
    putStrLn $ "Listening on port " ++ show port
    S.runConcraftServer analyzer concraft portNum
    where
        errMsg = "Please define the path to analyzer!\n"

exec Client{..} = do
    tagsetPath' <- case tagsetPath of
        Nothing -> getDataFileName "config/tagset.cfg"
        Just x  -> return x
    tset <- parseTagset tagsetPath' <$> readFile tagsetPath'
    let portNum = N.PortNumber $ fromIntegral port
    inp <- parseContent tset <$> L.getContents
    out <- S.submit host portNum $ rq $ buildReq inp

    showData tset (fmap (addTags inp) out)
  where
    -- | The @x@ is sentence of words and possible interpretations.
    -- If the chosen tagging mod isn't marginals, it is expected that
    -- the result of the tagging is a list of tuples @(a,b)@ the
    -- set of possible tags (which were guessed by the guessing model or
    -- as a result of analysis from the 'Analyzer') @a@, and the disambiguated
    -- tag @b@. The 'X.Seg' is then zipped with a tag to prepare the results
    -- for the output.
    addTags x = zip x . map (map snd)
    -- | Builds a request and adds the body which will be sent to the server.
    rq x = R.Request
        { R.rqBody = x}
    -- | Depending on the no-analysis and marginals boolean values a proper
    -- constructor is chosen. Constructors having an A suffix obviously are
    -- used to indicate that additional analysis is needed.
    buildReq =
        case (noAna, marginals) of
            (True,True)  -> R.Marginal
            (True,False) -> R.PlainTag
            (False,True) -> R.MarginalA
            (False,False)-> R.PlainTagA

exec Compare{..} = do
    tagsetPath' <- case tagsetPath of
        Nothing -> getDataFileName "config/tagset.cfg" -- if no path is given the default one is used
        Just x  -> return x
    tset <- parseTagset tagsetPath' <$> readFile tagsetPath'
    let convert = concat
    xs <- convert <$> parseFile tset refPath
    ys <- convert <$> parseFile tset otherPath
    let s = Acc.weakLB tset xs ys
    putStrLn $ "Number of segments in reference file: " ++ show (Acc.gold s)
    putStrLn $ "Number of correct tags: " ++ show (Acc.good s)
    putStrLn $ "Weak accuracy lower bound: " ++ show (Acc.accuracy s)


exec Prune{..} = do
    cft <- C.loadModel inModel
    C.saveModel outModel $ C.prune threshold cft

---------------------------------------
-- Reading files
---------------------------------------

parseFileG :: ([L.Text] -> X.Sent X.Word P.Tag)
           -> FilePath
           -> IO [X.Sent X.Word P.Tag]
parseFileG packSent path =
    map packSent . parseText <$> L.readFile path

-- | Assumes that the given 'FilePath' is a file containing the training set.
-- It is also assumed that the sentences in the file are clearly separated by
-- at least two whitespaces (be it newlines or spaces) and that each word is
-- in its own row and any tags should follow the word (in the same row) separated
-- by one or more whitespaces.
parseTrain :: P.Tagset -> FilePath -> IO [X.Sent X.Word P.Tag]
parseTrain tset = parseFileG (X.packSentT tset)

-- | It is assumed that the sentences in the file are clearly separated by
-- at least two whitespaces (be it newlines or spaces) and that each word is
-- in its own row and any tags should follow the word (in the same row) separated
-- by one or more whitespaces.
parseFile :: P.Tagset -> FilePath -> IO [X.Sent X.Word P.Tag]
parseFile tset = parseFileG (X.packSent tset)


---------------------------------------
-- Parsing
---------------------------------------

-- | Parses a given 'L.Text'. Function is used to prepare the data for packing
-- into 'X.Sent'. Used by 'parseFile' and 'parseTrain'.
parseText :: L.Text -> [[L.Text]]
parseText = filter (not . null) . Split.splitWhen (L.all Char.isSpace) . L.lines

-- | Used as a function which parses the formated input as described in 'parseTrain' or
-- 'parseFile' but doesn't except a 'FilePath'.
parseContent :: P.Tagset -> L.Text -> [X.Sent X.Word P.Tag]
parseContent tset = map (X.packSent tset) . parseText

---------------------------------------
-- Displaying data
---------------------------------------

-- | Displays the data of tagged sentences.
-- If tagging was done using marginal probabilities all the tags are shown
-- with their corresponding probabilities - format WORD(\tPROB\tTAG)+\n.
-- Otherwise, WORD\tTAG\n format is used and sentences are separated with a newline character.
showData :: P.Tagset
         -> Either [X.Sent X.Word P.Tag] [(X.Sent X.Word P.Tag, [P.Tag])]
         -> IO ()
showData tset (Left xss) =
    forM_ xss (\sentence -> do
        forM_ sentence (\wrd -> do
            T.putStr $ (X.orth . X.word) wrd
            forM_ (M.toList . X.unWMap $ X.tags wrd) (\(tg,prob) -> do
                T.putStr "\t"
                T.putStr $ toShortest prob
                T.putStr "\t"
                T.putStr . T.filter (/=':') . P.showTag tset $ tg
                -- ^^ Text form of a tag is filtered because by default
                -- the separator between POS and attributes is ':'
                -- TODO remove the dependency on the 'P.showTag'
                )
            T.putStr "\n"
            )
        T.putStr "\n"
        )
showData tset (Right xss) =
    forM_ xss (\(sent, tgs) -> do
        forM_ (zip sent tgs) (\(wrd, tg) -> do
            T.putStr $ (X.orth . X.word) wrd
            T.putStr "\t"
            T.putStr . T.filter (/=':') . P.showTag tset $ tg
            T.putStr "\n"
            )
        T.putStr "\n"
    )