Библиотека TensorFlow предоставляет прикладной интерфейс как для Python, так и для Haskell. Работать на Python немного легче, но предметом нашей сегодняшней статьи будет Haskell.
Далее мы займемся решением реальной задачи машинного обучения. Для этого нам понадобится набор данных «Ирисы» (Iris data set), содержащий параметры цветков трех различных видов ириса. Мы создадим полносвязную нейронную сеть (fully-connected neural network) и обучим ее определять вид растения по параметрам его цветка.
Форматируем входные данные
Первым шагом при решении большинства задач машинного обучения является предобработка данных. Ведь не могут же данные волшебным образом преобразоваться в типы Haskell. К счастью, в этом нам может помочь отличная библиотека Cassava. Набор данных «Ирисы» представлен в виде CSV-файлов, каждый из которых содержит заголовок и серию наблюдений:
120,4,setosa,versicolor,virginica 6.4,2.8,5.6,2.2,2 5.0,2.3,3.3,1.0,1 4.9,2.5,4.5,1.7,2 4.9,3.1,1.5,0.1,0
…
Каждая строка представляет собой одно наблюдение и содержит 4 величины, характеризующие параметры цветка, а также метку класса. Набор данных охватывает растения трех видов: Iris Setosa, Iris Versicolor и Iris Virginica. Соответственно, последний столбец содержит три метки классов: 0, 1 и 2.
Давайте создадим специальный тип, представляющий строку данных, который позволит нам обрабатывать файл построчно. Наш тип IrisRecord будет содержать признаки и метку класса. Этот тип сыграет роль моста между необработанными данными и тензорным форматом, с которым будет работать алгоритм машинного обучения. Для нашего типа мы используем класс типов Generic и создадим экземпляр FromRecord, что позволит нам с легкостью выполнить парсинг. Обратите внимание, мы не будем приводить секцию импорта во фрагментах кода. Полный список импорта представлен в конце статьи. Также отметим, что мы будем использовать расширение OverloadedLists.
{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE OverloadedLists #-} ... data IrisRecord = IrisRecord { field1 :: Float , field2 :: Float , field3 :: Float , field4 :: Float , label :: Int64 }
deriving (Generic) instance FromRecord IrisRecord
Теперь у нас есть необходимый тип, и далее мы напишем функцию readIrisFromFile, которая будет читать данные из CSV-файла.
readIrisFromFile :: FilePath -> IO (Vector IrisRecord) readIrisFromFile fp = do contents <- readFile fp let contentsAsBs = pack contents let results = decode HasHeader contentsAsBs :: Either String (Vector IrisRecord) case results of Left err -> error err Right records -> return records
Мы не хотим каждый раз передавать алгоритму весь набор данных. Нам необходима возможность генерировать случайные выборки.
sampleSize :: Int sampleSize = 10 chooseRandomRecords :: Vector IrisRecord -> IO (Vector IrisRecord) chooseRandomRecords records = do let numRecords = Data.Vector.length records chosenIndices <- take sampleSize <$> shuffleM [0..(numRecords - 1)] return $ fromList $ map (records !) chosenIndices
Итак, мы отобрали данные и теперь необходимо преобразовать их в TensorData, чтобы передать алгоритму. Чтобы создать элементы TensorData, необходимо передать размерность и 1-мерный вектор значений. Для этого нам нужно знать размерности входа и выхода. И та и другая зависит от количества строк в выборке. Входные данные будут содержать 4 столбца – по одному на каждый признак. Выходные данные будут иметь 1 столбец, содержащий метку класса.
irisFeatures :: Int64 irisFeatures = 4 irisLabels :: Int64 irisLabels = 3 convertRecordsToTensorData :: Vector IrisRecord -> (TensorData Float, TensorData Int64) convertRecordsToTensorData records = (input, output) where numRecords = Data.Vector.length records input = encodeTensorData [fromIntegral numRecords, irisFeatures] (undefined) output = encodeTensorData [fromIntegral numRecords] (undefined)
Теперь осталось преобразовать записи в 1-мерные векторы для кодирования. Ниже представлена итоговая функция:
convertRecordsToTensorData :: Vector IrisRecord -> (TensorData Float, TensorData Int64) convertRecordsToTensorData records = (input, output) where numRecords = Data.Vector.length records input = encodeTensorData [fromIntegral numRecords, irisFeatures] (fromList $ concatMap recordToInputs records) output = encodeTensorData [fromIntegral numRecords] (label <$> records) recordToInputs :: IrisRecord -> [Float] recordToInputs rec = [field1 rec, field2 rec, field3 rec, field4 rec]
Основы нейронных сетей
Разобравшись с данными, займемся непосредственно моделью. Наша модель должна выполнять два различных действия. Во-первых, мы хотим обучить модель на обучающих данных, то есть вычислить веса. Во-вторых, мы хотим определить ошибку прогноза модели на тестовых данных. Мы можем объединить оба действия в одном объекте модели Model. Все операции TensorFlow выполняются в монаде Session. В процессе обучения будет выполняться действие, которое модифицирует переменные, но ничего не возвращает. В результате вычисления ошибки модели будет возвращено вещественное число.
data Model = Model { train :: TensorData Float -- Training input -> TensorData Int64 -- Training output -> Session () , errorRate :: TensorData Float -- Test input -> TensorData Int64 -- Test output -> Session Float }
Теперь приступим к разработке полносвязной нейронной сети. Сеть будет содержать 4 входных нейрона (по одному на каждый признак) и 3 выходных нейрона (по одному на каждый класс). Между входным и выходным слоем будет располагаться скрытый слой, содержащий 10 нейронов. Такая архитектура предполагает, что нам понадобится 2 массива весов (weight) и свободных членов (bias). Мы напишем функцию, которая будет принимать размерности и возвращать переменные тензоры для каждого слоя. Нам нужны тензоры весов и свободных членов, а также результирующий тензор слоя.
buildNNLayer :: Int64 -> Int64 -> Tensor v Float -> Build (Variable Float, Variable Float, Tensor Build Float) buildNNLayer inputSize outputSize input = do weights <- truncatedNormal (vector [inputSize, outputSize]) >>= initializedVariable bias <- truncatedNormal (vector [outputSize]) >>= initializedVariable let results = (input `matMul` readValue weights) `add` readValue bias return (weights, bias, results)
Мы реализуем это в монаде Build, которая, кроме прочего, позволяет нам создавать переменные. Чтобы не усложнять, используем распределение truncatedNormal для всех наших переменных. Задаем размер каждой переменной в тензоре vector и инициализируем их. Затем вычисляем результирующий тензор, умножая вход на веса и прибавляя свободный член.
Создаем модель
Теперь приступим к созданию объекта модели Model опять же в пределах монады Build. Первым делом задаем плейсхолдеры для входа и выхода, а также количество скрытых нейронов. В качестве значения для batchSize указываем -1, поскольку мы хотим работать с пакетами различного размера.
irisFeatures :: Int64 irisFeatures = 4 irisLabels :: Int64 irisLabels = 3 -- ^^ From above createModel :: Build Model createModel = do let batchSize = -1 -- Allows variable sized batches let numHiddenUnits = 10 inputs <- placeholder [batchSize, irisFeatures] outputs <- placeholder [batchSize]
Далее создаем переменные слоев и результатов. Между слоями добавляем функцию активации relu:
(hiddenWeights, hiddenBiases, hiddenResults) <- buildNNLayer irisFeatures numHiddenUnits inputs let rectifiedHiddenResults = relu hiddenResults (finalWeights, finalBiases, finalResults) <- buildNNLayer numHiddenUnits irisLabels rectifiedHiddenResults
Теперь необходимо получить спрогнозированные классы. Вызываем функцию argMax, чтобы определить класс с наибольшей вероятностью. Мы также выполняем cast и render. Это специфические для Haskell TensorFlow операции, необходимые для того, чтобы преобразовать тензоры к правильному типу. Далее сравниваем прогноз с выходным плейсхолдером и вычисляем ошибку.
actualOutput <- render $ cast $ argMax finalResults (scalar (1 :: Int64)) let correctPredictions = equal actualOutput outputs errorRate_ <- render $ 1 - (reduceMean (cast correctPredictions))
Теперь рассмотрим процесс обучения. Сперва преобразуем метки классов в векторы, представленные в прямом унитарном коде oneHot. Это означает, что мы преобразуем метку 0 в вектор [1,0,0] и т.д. Мы будем сравнивать эти значения с нашими результатами (до выбора максимального значения) и таким образом реализуем функцию потерь (loss function). Затем сформируем список параметров, модифицируя которые, оптимизатор adam минимизирует нашу функцию потерь.
let outputVectors = oneHot outputs (fromIntegral irisLabels) 1 0 let loss = reduceMean $ fst $ softmaxCrossEntropyWithLogits finalResults outputVectors let params = [hiddenWeights, hiddenBiases, finalWeights, finalBiases] train_ <- minimizeWith adam loss params
Мы подготовили errorRate_ и train_. Остался один последний шаг. Необходимо подключить плейсхолдеры и создать функции, которые будут принимать тензорные данные. Применим шаблон feed, рассмотренный здесь. Наконец, наша модель готова!
return $ Model { train = \inputFeed outputFeed -> runWithFeeds [ feed inputs inputFeed , feed outputs outputFeed ] train_ , errorRate = \inputFeed outputFeed -> unScalar <$> runWithFeeds [ feed inputs inputFeed , feed outputs outputFeed ] errorRate_ }
Собираем все вместе
Теперь напишем главную функцию, которая будет запускать сессию. Она будет состоять из трех этапов. На подготовительном этапе загружаем данные и с помощью функции build создаем модель. Затем обучаем модель, выполняя 1000 итераций. Каждые 100 итераций выводим результат. Наконец, вычисляем итоговую ошибку модели с помощью тестовых данных.
runIris :: FilePath -> FilePath -> IO () runIris trainingFile testingFile = runSession $ do -- Preparation trainingRecords <- liftIO $ readIrisFromFile trainingFile testRecords <- liftIO $ readIrisFromFile testingFile model <- build createModel -- Training forM_ ([0..1000] :: [Int]) $ \i -> do trainingSample <- liftIO $ chooseRandomRecords trainingRecords let (trainingInputs, trainingOutputs) = convertRecordsToTensorData trainingSample (train model) trainingInputs trainingOutputs when (i `mod` 100 == 0) $ do err <- (errorRate model) trainingInputs trainingOutputs liftIO $ putStrLn $ "Current training error " ++ show (err * 100) liftIO $ putStrLn "" -- Testing let (testingInputs, testingOutputs) = convertRecordsToTensorData testRecords testingError <- (errorRate model) testingInputs testingOutputs liftIO $ putStrLn $ "test error " ++ show (testingError * 100) return ()
Результаты
Выполнив рассмотренный выше код, получим следующие результаты:
Current training error 60.000004
Current training error 30.000002 Current training error 39.999996 Current training error 19.999998 Current training error 10.000002 Current training error 10.000002 Current training error 19.999998 Current training error 19.999998 Current training error 10.000002 Current training error 10.000002 Current training error 0.0 test error 3.333336
Тестовый набор данных содержал 30 примеров, и величина ошибки говорит о том, что мы правильно классифицировали 29 из 30. В различных прогонах результаты отличаются (я выбрал лучший из них). Поскольку размер выборки очень мал, наблюдается высокая энтропия (иногда ошибка может достигать 40%). Используя больший объем данных, можно добиться более стабильных результатов, но наш пример является хорошим началом.
Заключение
В этой статье мы рассмотрели базовые аспекты создания нейронной сети с помощью Haskell TensorFlow. Мы разработали полносвязную нейронную сеть и обучили ее на реальных данных, загруженных с помощью библиотеки Cassava. Наша сеть обучилась классифицировать виды цветов из набора данных «Ирисы». Принимая во внимание малый объем данных, мы получили достаточно хорошие результаты.
Подробную информацию по установке Haskell TensorFlow вы можете найти в руководстве, которое поможет вам запустить рассмотренный код на своем компьютере.
Возможно, вы никогда раньше не работали с Haskell и хотите лучше с ним познакомиться. Возможно, эта статья убедила вас в том, что Haskell является будущим искусственного интеллекта. В этом случае вас заинтересует следующее руководство.
Приложение. Весь импорт
Документация по Haskell TensorFlow все еще находится в стадии активной разработки. Поэтому мы явно перечислим все модули, которые необходимо импортировать, чтобы запустить рассмотренный в статье код.
import Control.Monad (forM_, when) import Control.Monad.IO.Class (liftIO) import Data.ByteString.Lazy.Char8 (pack) import Data.Csv (FromRecord, decode, HasHeader(..)) import Data.Int (Int64) import Data.Vector (Vector, length, fromList, (!)) import GHC.Generics (Generic) import System.Random.Shuffle (shuffleM) import TensorFlow.Core (TensorData, Session, Build, render, runWithFeeds, feed, unScalar, build, Tensor, encodeTensorData) import TensorFlow.Minimize (minimizeWith, adam) import TensorFlow.Ops (placeholder, truncatedNormal, add, matMul, relu, argMax, scalar, cast, oneHot, reduceMean, softmaxCrossEntropyWithLogits, equal, vector) import TensorFlow.Session (runSession) import TensorFlow.Variable (readValue, initializedVariable, Variable)
Подводя итог: писать на хаскелле мне понравилось; есть задачи, которые можно решать на нем достаточно продуктивно; у языка есть некоторые технические проблемы.