1. Вступление

Известно, что библиотеки для глубокого обучения (Deep learning) очень хорошо дружат с языком Python: например, библиотека Keras написана на Python и может использовать в качестве бекенда Theano (тоже на Python) или Tensorflow (C++/Python).

Но пользователи R тоже не обделены возможностью обучать глубокие нейронные сети. Помимо недавно появившего интерфейса для Tensorflow, существует не столь известная, но набирающая популярность библиотека mxnet, написанная на C++ и укомплектованная интерфейсами и для R, и для Python (а также для Julia, Matlab, Scala и Javascript!). Данная библиотека обладает высокой производительностью и умеренным расходом памяти, умеет работать как на CPU, так и на GPU Nvidia, используя CUDA/cuDNN (причем можно обучаться сразу на нескольких видеокартах).

Основным препятствием для ее освоения является очень своеобразно написанная и не всегда поддерживаемая в актуальном состоянии документация: если для Python она более-менее полная и последовательная, то в случае с R все плохо. MXNet R Reference Manual является лишь списком функций и аргументов, подавляющее большинство примеров написаны с использованием Python, так что в R приходится действовать по аналогии и искать ответы в issues на Гитхабе. Это сообщение является попыткой слегка исправить ситуацию и создать руководство по установке и использованию mxnet в R. Возможно, будет и продолжение про более продвинутые темы.

2. Установка CUDA/cuDNN

Используется Linux; предполагается, что R и RStudio уже установлены. Полезные руководства: раз, два, три, четыре.

Обучать нейросети на CPU долго и скучно, поэтому прежде всего нужна видеокарта от Nvidia. Хорошо, когда она на архитектуре последнего поколения - сейчас это Pascal. Еще лучше, если это GTX 1080 c 8 Гб памяти. Но и на GTX 1050Ti у меня все достаточно бодро работает.

Устанавливаем драйвер по инструкции:

sudo apt-get purge nvidia* # удаляем старый драйвер, если он был
sudo add-apt-repository ppa:graphics-drivers/ppa 
sudo apt update
# 370 заменить на актуальную версию
sudo apt-get install nvidia-370 nvidia-settings 
sudo nvidia-xconfig

Устанавливаем CUDA: скачиваем runfile с https://developer.nvidia.com/cuda-downloads, запускаем установку командой sudo sh cuda_8.0.44_linux.run (в моем случае использовалась версия 8.0.44, у вас может быть более новая, а для старых видеокарт - наоборот, нужна более старая).

Устанавливаем cuDNN: регистриуемся и скачиваем с https://developer.nvidia.com/rdp/cudnn-download, распаковываем, копируем нужные файлы в положенные места:

tar -zxf cudnn-8.0-linux-x64-v5.1.tgz
cd cuda # имя папки после распаковки
sudo cp -P include/cudnn.h /usr/include
sudo cp -P lib64/libcudnn* /usr/lib/x86_64-linux-gnu/
sudo chmod a+r /usr/lib/x86_64-linux-gnu/libcudnn*

За работой видеокарты поможет следить консольная утилита nvidia-smi.

3. Установка mxnet с интерфейсами для R и Python

Ставим нужные зависимости:

sudo apt-get update
sudo apt-get install -y build-essential git libatlas-base-dev libopencv-dev
sudo apt-get install libcurl4-openssl-dev libssl-dev
sudo apt-get install libxml2-dev

При дальнейшей установке, возможно, будет не хватать чего-то еще - ставим аналогичным образом.

Установка самой библиотеки mxnet происходит путем скачивания с Гитхаба и сборки из исходников:

git clone --recursive https://github.com/dmlc/mxnet
cd mxnet
nano make/config.mk 

В файл make/config.mk нужно внести следующие изменения, чтобы библиотека собралась с поддержкой GPU:

USE_CUDA = 1
USE_CUDA_PATH = /usr/local/cuda
USE_CUDNN = 1
USE_BLAS = atlas

Вместо atlas можно указать blas или openblas. Также файл make/config.mk можно скопировать на уровень выше (в папку mxnet) и отредактировать копию. Сама сборка запускается командой make -j4, где 4 - количество используемых ядер. Лучше указать все имеющиеся, потому что процесс компиляции небыстрый. При повторной сборке нужно будет предварительной выполнить команду make clean_all.

Установка пакета для R

Запускаем из папки mxnet:

cd R-package
Rscript -e "library(devtools); library(methods); options(repos=c(CRAN='https://cran.rstudio.com')); install_deps(dependencies = TRUE)"
cd ..
make rpkg
R CMD INSTALL mxnet_current_r.tar.gz

После чего вносим изменения в /usr/lib/R/etc/ldpaths (команда sudo nano /usr/lib/R/etc/ldpaths):

export CUDA_HOME=/usr/local/cuda 
export LD_LIBRARY_PATH=${CUDA_HOME}/lib64:${LD_LIBRARY_PATH}

Установка библиотеки для Python

cd ~/mxnet/setup-utils
bash install-mxnet-ubuntu-python.sh

Таким образом будет выполнена установка для системной версии Python, у меня это 2.7 - с Python 3 могут быть проблемы. Если у вас установлен дистрибутив Anaconda, то в ~/.bashrc нужно закомментировать строку вида export PATH="/home/andrey/anaconda3/bin:$PATH". Впрочем, установка с использованием виртуальных окружений Anaconda тоже не представляет сложностей, по ссылке есть описание.

Для корректной работы библиотеки мне пришлось отредактировать файл ~/.bashrc (команда sudo nano ~/.bashrc), добавив следующие строки:

export CUDA_HOME=/usr/local/cuda-8.0
export LD_LIBRARY_PATH=${CUDA_HOME}/lib64
PATH=${CUDA_HOME}/bin:${PATH}
export PATH

После чего можно перейти в папку mxnet и запустить тесты на CPU и GPU:

python example/image-classification/train_mnist.py
python example/image-classification/train_mnist.py --network lenet --gpus 0 

4. Подготовка данных

Описан процесс подготовки данных, начиная с “сырых” картинок. Для некоторых классических наборов данных уже есть готовые бинарные файлы: http://data.dmlc.ml/mxnet/data/.

Будем использовать набор cifar10, архив train.7z: картинки в формате .png 32х32х3, то есть 32x32 пикселя с тремя цветовыми каналами (RGB). Всего 50000 картинок в 10 классах, по 5000 в каждом. После распаковки назовем папку с картинками data.

Используемый далее скрипт im2rec.py должен работать с файлами разных форматов, включая .png, но у меня он работал только с .jpg. Мне подказали, что дело может быть в сборке opencv без поддержки формата .png, но сборка opencv из исходников тоже ничего не дала, как и установка командой sudo apt-get install python-opencv. Так что будем конвертировать в .jpg.

Устанавливаем утилиту imagemagick: sudo apt-get install imagemagick. Конвертируем .png в .jpg и удаляем исходные файлы в формате .png:

cd ~/R/cifar10/data

for img in *.png
 do 
  convert "$img" "$img.jpg"
 done
 
rm *.png

Теперь файлы имеют имена вида 1.png.jpg. Их нужно переименовать таким образом, чтобы они всегда шли в соответствии с порядком номеров, то есть добавить в начале имени каждого файла нужное количество нулей:

# Исходные имена файлов
files <- list.files("data")

# Создаем вектор из нужного количества 0, которые добавляются к номеру картинки
max_length <- max(sapply(files, nchar))
zeros <- max_length - sapply(files, nchar)
zeros <- paste0(sapply(zeros, function(x) paste(rep(0, x), collapse = "")))

# Новые имена файлов
newnames <- paste0("./data/", zeros, files)

# Полные имена файлов (с папкой, где они лежат)
files <- paste0("./data/", files)

# Переименовываем файлы в формате 00001.png.jpg
Map(function(x, y) file.rename(from = x, to = y), files, newnames)

Осталось разложить файлы по папкам таким образом, чтобы в каждой папке были изображения одного класса:

# Исходные имена файлов
files <- list.files("data")

# Метки классов
labels <- read.table("trainLabels.csv", header = TRUE, sep = ",")

# Создаем папки для каждого класса
lapply(as.character(unique(labels$label)), 
       function(x) dir.create(path = paste0("./data/", x)))

# Новые имена файлов
newnames <- paste0("./data/", labels$label, "/", files)

# Полные имена файлов (с папкой, где они лежат)
files <- paste0("./data/", files)

# Раскладываем файлы по папкам
Map(function(x, y) file.rename(from = x, to = y), files, newnames)

Библиотека mxnet работает с бинарными файлами формата RecordIO. Создать их можно с помощью идущего в комплекте скрипта на Python, для которого нужно разложить файлы как раз так, как мы только что сделали - каждый класс в отдельной папке. Вначал создается два списка: 80% файлов - обучающая выборка (cifar_train.lst), 20% - проверочная (cifar_val.lst). Затем из файлов в этих списках создаются бинарные файлы cifar_train.rec и cifar_val.rec. Отдельный тестовый набор для финальной оценка качества создавать не будем, ограничимся проверочным.

cd ~/R/cifar10/
python ~/mxnet/tools/im2rec.py --list=1 --recursive=1 --train-ratio=0.8 cifar data
python ~/mxnet/tools/im2rec.py --num-thread=4 --pass-through=1 cifar_train.lst data
python ~/mxnet/tools/im2rec.py --num-thread=4 --pass-through=1 cifar_val.lst data

5. Обучение сверточной нейронной сети

setwd("~/R/cifar10/")
library(mxnet)

Для обучения нейросети нужны три компонента: итераторы для данных, символьное описание модели (архитектура сети) и собственно вызов функции для обучения. См. примеры, а также basic_model.R.

Итераторы для данных

Создадим функцию, которая будет возвращать итераторы для обучающей и проверочной выборки. Эта функция принимает следующие аргументы: размерность данных, пути к файлам с обучающей и проверочной выборками, размер мини-выборки (batch size):

get_iterator <- function(data_shape, 
                         train_data, 
                         val_data, 
                         batch_size = 128) {
    train <- mx.io.ImageRecordIter(
        path.imgrec = train_data,
        batch.size  = batch_size,
        data.shape  = data_shape,
        rand.crop   = TRUE,
        rand.mirror = TRUE)
  
    val <- mx.io.ImageRecordIter(
        path.imgrec = val_data,
        batch.size  = batch_size,
        data.shape  = data_shape,
        rand.crop   = FALSE,
        rand.mirror = FALSE
        )
 
  return(list(train = train, val = val))
}

Таким образом можно работать со сколь угодно большими наборами данных. Оперативная память и память видеокарты ограничивают только размер мини-выборки, которая загружается вся сразу. Маленькие картинки можно загружать десятками-сотнями, большие - меньшими порциями.

Создаем итераторы, указав размерность (28, 28, 3), то есть картинки будут обрезаться до размера 28х28:

data  <- get_iterator(data_shape = c(28, 28, 3),
                      train_data = "/home/andrey/R/cifar10/cifar_train.rec",
                      val_data   = "/home/andrey/R/cifar10/cifar_val.rec",
                      batch_size = 100)
train <- data$train
val   <- data$val

Выше мы указали rand.crop = TRUE, то есть обрезка до нужного размера будет происходить случайным образом (насколько я понимаю, для каждой эпохи обучения), и это увеличит разнообразие предъявляемых обучающих примеров. rand.mirror = TRUE с той же целью создает зеркальные варианты изображений. Для проверочных данных никакие трансформации не задаем.

Архитектура нейросети

Используем один из вариантов архитектуры Resnet:

conv_factory <- function(data, num_filter, kernel, stride,
                         pad, act_type = 'relu', conv_type = 0) {
    if (conv_type == 0) {
      conv = mx.symbol.Convolution(data = data, num_filter = num_filter,
                                   kernel = kernel, stride = stride, pad = pad)
      bn = mx.symbol.BatchNorm(data = conv)
      act = mx.symbol.Activation(data = bn, act_type = act_type)
      return(act)
    } else if (conv_type == 1) {
      conv = mx.symbol.Convolution(data = data, num_filter = num_filter,
                                   kernel = kernel, stride = stride, pad = pad)
      bn = mx.symbol.BatchNorm(data = conv)
      return(bn)
    }
}

residual_factory <- function(data, num_filter, dim_match) {
  if (dim_match) {
    identity_data = data
    conv1 = conv_factory(data = data, num_filter = num_filter, kernel = c(3, 3),
                         stride = c(1, 1), pad = c(1, 1), act_type = 'relu', conv_type = 0)
    
    conv2 = conv_factory(data = conv1, num_filter = num_filter, kernel = c(3, 3),
                         stride = c(1, 1), pad = c(1, 1), conv_type = 1)
    new_data = identity_data + conv2
    act = mx.symbol.Activation(data = new_data, act_type = 'relu')
    return(act)
  } else {
    conv1 = conv_factory(data = data, num_filter = num_filter, kernel = c(3, 3),
                         stride = c(2, 2), pad = c(1, 1), act_type = 'relu', conv_type = 0)
    conv2 = conv_factory(data = conv1, num_filter = num_filter, kernel = c(3, 3),
                         stride = c(1, 1), pad = c(1, 1), conv_type = 1)
    
    # adopt project method in the paper when dimension increased
    project_data = conv_factory(data = data, num_filter = num_filter, kernel = c(1, 1),
                                stride = c(2, 2), pad = c(0, 0), conv_type = 1)
    new_data = project_data + conv2
    act = mx.symbol.Activation(data = new_data, act_type = 'relu')
    return(act)
  }
}

residual_net <- function(data, n) {
  #fisrt 2n layers
  for (i in 1:n) {
    data = residual_factory(data = data, num_filter = 16, dim_match = TRUE)
  }
  
  
  #second 2n layers
  for (i in 1:n) {
    if (i == 1) {
      data = residual_factory(data = data, num_filter = 32, dim_match = FALSE)
    } else {
      data = residual_factory(data = data, num_filter = 32, dim_match = TRUE)
    }
  }
  #third 2n layers
  for (i in 1:n) {
    if (i == 1) {
      data = residual_factory(data = data, num_filter = 64, dim_match = FALSE)
    } else {
      data = residual_factory(data = data, num_filter = 64, dim_match = TRUE)
    }
  }
  return(data)
}

get_symbol <- function(num_classes = 10) {
  conv <- conv_factory(data = mx.symbol.Variable(name = 'data'), num_filter = 16,
                      kernel = c(3, 3), stride = c(1, 1), pad = c(1, 1),
                      act_type = 'relu', conv_type = 0)
  n <- 3 # set n = 3 means get a model with 3*6+2=20 layers, set n = 9 means 9*6+2=56 layers
  resnet <- residual_net(conv, n) #
  pool <- mx.symbol.Pooling(data = resnet, kernel = c(7, 7), pool_type = 'avg')
  flatten <- mx.symbol.Flatten(data = pool, name = 'flatten')
  fc <- mx.symbol.FullyConnected(data = flatten, num_hidden = num_classes, name = 'fc1')
  softmax <- mx.symbol.SoftmaxOutput(data = fc, name = 'softmax')
  return(softmax)
}

# Сеть для 10 классов
resnet <- get_symbol(10)

Поскольку сеть очень глубокая, ее неудобно описывать слой за слоем. Здесь применяются дополнительные функции для создания нужного количества слоев; это не очень интуитивно, но пользоваться готовыми решениями и адаптировать их под свои нужды не так уж сложно. А создать свою сеть из умеренного количества слоев вообще не проблема: объект, содержащий описание n-1 слоев, передается функции-конструктору n-ого слоя. Чтобы узнать, какие бывают слои, воспользуйтесь командой apropos("mx.symbol."). Можно даже создавать свои собственные слои, но это уже сложнее, и описания для R я не нашел.

Обучение модели

Начинаем обучение модели с использованием GPU (ctx = mx.gpu(0)) в течение трех эпох, сохраняя модель после каждой эпохи (epoch.end.callback = mx.callback.save.checkpoint("resnet")):

model <- mx.model.FeedForward.create(
  symbol             = resnet,
  X                  = train,
  eval.data          = val,
  ctx                = mx.gpu(0),
  eval.metric        = mx.metric.accuracy,
  num.round          = 3,
  learning.rate      = 0.05,
  momentum           = 0.9,
  wd                 = 0.00001,
  kvstore            = "local",
  array.batch.size   = 100,
  epoch.end.callback = mx.callback.save.checkpoint("resnet"),
  batch.end.callback = mx.callback.log.train.metric(150),
  initializer        = mx.init.Xavier(factor_type = "in", magnitude = 2.34),
  optimizer          = "sgd"
)
## Start training with 1 devices
## Batch [150] Train-accuracy=0.302533333333333
## Batch [300] Train-accuracy=0.363466666666667
## [1] Train-accuracy=0.397518796992481
## [1] Validation-accuracy=0.4561
## Model checkpoint saved to resnet-0001.params
## Batch [150] Train-accuracy=0.538733333333333
## Batch [300] Train-accuracy=0.565000000000001
## [2] Train-accuracy=0.581825
## [2] Validation-accuracy=0.6296
## Model checkpoint saved to resnet-0002.params
## Batch [150] Train-accuracy=0.6462
## Batch [300] Train-accuracy=0.660499999999999
## [3] Train-accuracy=0.671699999999999
## [3] Validation-accuracy=0.6096
## Model checkpoint saved to resnet-0003.params

В рабочей папке сохранились файлы resnet-0001.params, resnet-0002.params, resnet-0002.params и resnet-symbol.json. В них есть все необходимое, чтобы продолжить обучать модель. Загружаем модель после 3 эпох:

model <- mx.model.load("resnet", 3)

Продолжаем обучение, уменьшив скорость (learning.rate = 0.03):

model <- mx.model.FeedForward.create(
  symbol             = model$symbol,
  X                  = train,
  eval.data          = val,
  ctx                = mx.gpu(0),
  eval.metric        = mx.metric.accuracy,
  num.round          = 12,
  learning.rate      = 0.03,
  momentum           = 0.9,
  wd                 = 0.00001,
  kvstore            = "local",
  array.batch.size   = 100,
# epoch.end.callback = mx.callback.save.checkpoint("resnet"),
  batch.end.callback = mx.callback.log.train.metric(150),
  initializer        = mx.init.Xavier(factor_type = "in", magnitude = 2.34),
  optimizer          = "sgd",
  arg.params         = model$arg.params, 
  aux.params         = model$aux.params
)
## Start training with 1 devices
## Batch [150] Train-accuracy=0.722133333333333
## Batch [300] Train-accuracy=0.727466666666666
## [1] Train-accuracy=0.733508771929825
## [1] Validation-accuracy=0.6953
## Batch [150] Train-accuracy=0.744866666666666
## Batch [300] Train-accuracy=0.750466666666667
## [2] Train-accuracy=0.75545
## [2] Validation-accuracy=0.7529
## Batch [150] Train-accuracy=0.766133333333334
## Batch [300] Train-accuracy=0.769800000000001
## [3] Train-accuracy=0.77345
## [3] Validation-accuracy=0.7685
## Batch [150] Train-accuracy=0.7774
## Batch [300] Train-accuracy=0.7809
## [4] Train-accuracy=0.78505
## [4] Validation-accuracy=0.772
## Batch [150] Train-accuracy=0.795533333333333
## Batch [300] Train-accuracy=0.797933333333334
## [5] Train-accuracy=0.8005
## [5] Validation-accuracy=0.7801
## Batch [150] Train-accuracy=0.807466666666667
## Batch [300] Train-accuracy=0.809100000000001
## [6] Train-accuracy=0.811400000000001
## [6] Validation-accuracy=0.7899
## Batch [150] Train-accuracy=0.8132
## Batch [300] Train-accuracy=0.814500000000001
## [7] Train-accuracy=0.816875
## [7] Validation-accuracy=0.7833
## Batch [150] Train-accuracy=0.8214
## Batch [300] Train-accuracy=0.823466666666668
## [8] Train-accuracy=0.824075000000001
## [8] Validation-accuracy=0.8008
## Batch [150] Train-accuracy=0.829466666666667
## Batch [300] Train-accuracy=0.829433333333335
## [9] Train-accuracy=0.831825000000001
## [9] Validation-accuracy=0.7973
## Batch [150] Train-accuracy=0.836733333333333
## Batch [300] Train-accuracy=0.837
## [10] Train-accuracy=0.83925
## [10] Validation-accuracy=0.7949
## Batch [150] Train-accuracy=0.8382
## Batch [300] Train-accuracy=0.840033333333334
## [11] Train-accuracy=0.8414
## [11] Validation-accuracy=0.7961
## Batch [150] Train-accuracy=0.8482
## Batch [300] Train-accuracy=0.846933333333334
## [12] Train-accuracy=0.8481
## [12] Validation-accuracy=0.8098

Видим, что модель начинает переобучаться, потому что качество на обучающих данных растет, а на проверочных - нет. Хотя было бы полезно продолжить обучение и посмотреть, действительно ли это плато, или будет еще небольшой рост. Очень радует скорость работы, на обучение ушли считанные минуты.

Архитектуру сети можно визуализировать (примеры):

graph.viz(model$symbol$as.json(), graph.width.px = 1200, graph.height.px = 3000)

На этом рассмотрение основ заканчивается, продолжение следует.