среда, 21 сентября 2016 г.

Обучаем нейросеть распознавать рукописные цифры с помощью Torch7


В "песочнице" kaggle.com есть задача на классификацию рукописных цифр с использованием набора данных MNIST. Нужно обучить классификатор, которые по изображению написанной цифры размером 28x28 определит, что это за цифра. Это неплохая возможность для знакомства со свёрточными нейросетями, тем более, что именно они дают самую высокую точность на этой задаче.

Итак, имеется датасет размером 70000 цифр. Он условно разбит на тренировочное (42000 цифр) и тестовое (28000 цифр) множества. "Условно", потому что этот набор данных опубликован полностью, поэтому при желании можно найти разметку для тестовых данных, но мы не будем читить.

В качестве baseline возьмём такое простое, но очень эффективное решение пользователя Zhao Hanguang. Это решение использует связку метода главных компонент и SVM, работает очень быстро и всего на 35 главных компонентах даёт впечатляющие 98.243% точности. Задача минимум была побить это решение. Задача максимум - достичь 99% точности. Главной целью этих упражнений было знакомство с пакетом Torch7 и его возможностями "глубинного обучения". Сложным моментом было отсутствие в моём распоряжении машины с поддержкой CUDA, так что приходилось ограничиваться небольшими нейросетями и ждать результатов по несколько часов.

Torch предоставляет интерактивную среду с интерпретатором Lua: всё, что вы вводите интерпретируется как строка Lua и сразу выполняется. В ваших Lua-командах вы можете манипулировать объектами-тензорами (по сути просто многомерными массивами). Операции с тензорами написаны на C (есть реализации, которые используют GPU). Создатели Torch позиционируют его как "матлаб-подобную среду для машинного обучения".

Удобнее всего работать с Torch через веб-оболочку iTorch (аналогичную iPython).

Для подобных задач Torch содержит несколько пакетов: optim для поиска максимумов и минимумов, image работы и изображениями и nn для работы с нейросетями. За основу я взял пример из GitHub проекта Torch.

Итак, подключаем все модули:

--Import all dependencies
require 'nn'
require 'optim'
require 'csvigo'
require 'image'

Модуль csvigo помогает загружать данные в формате csv.

Загружаем данные

--Read datasets
train_data = csvigo.load({path = "~/data/train.csv", mode = "large"})
test_data = csvigo.load({path = "~/data/test.csv", mode = "large"})        

Извлекаем из данных отдельные колонки: признаки и разметку для тренировочных данных, а так же конвертируем таблицы Lua в тензоры Torch

--Create tensors for train and test data
train_feature_tensor = torch.Tensor(#train_data-1, 784)
train_label_tensor = torch.Tensor(#train_data-1, 1)
test_feature_tensor = torch.Tensor(#test_data-1, 784)
--Fill tensor with train and test data from file
for i=2,#train_data do
    train_feature_tensor[{i-1,{}}] = torch.Tensor(train_data[i]):narrow(1,2,784)
    train_label_tensor[i-1] = train_data[i][1]
end
 
for i=2,#test_data do
    test_feature_tensor[{i-1,{}}] = torch.Tensor(test_data[i])
end

Модуль image позволяет легко визуализировать картинки, представленные тензорами. Проверим, что наши цифры загрузились как ожидаолсь...

--Check data. We should see handwritten digits
itorch.image(train_feature_tensor[524]:resize(28,28))
itorch.image(test_feature_tensor[231]:resize(28,28))
 


Теперь задаём нейросеть.


--Create Neural network model
model = nn.Sequential()
 
model:add(nn.SpatialConvolution(1, 16, 5, 5)) --28x28x1 goes in, 24x24x16 goes out
model:add(nn.ReLU()) -- 
model:add(nn.SpatialMaxPooling(2, 2, 2, 2)) --24x24x16 goes in, 12x12x16 goes out
model:add(nn.Dropout(0.2))
 
model:add(nn.SpatialConvolution(16, 32, 5, 5)) --12x12x16 goes in, 8x8x32 goes out
model:add(nn.ReLU()) --
model:add(nn.SpatialMaxPooling(2, 2, 2, 2)) --8x8x32 goes in, 4x4x32 goes out
model:add(nn.Dropout(0.2))
 
model:add(nn.View(4*4*32))
model:add(nn.Linear(4*4*32, 64))
model:add(nn.ReLU()) --
model:add(nn.Dropout(0.2))
model:add(nn.Linear(64, 20))
model:add(nn.ReLU())
model:add(nn.Linear(20, 10))
model:add(nn.LogSoftMax())
 

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

In [14]:
model:getParameters():size()
Out[14]:
 47590
[torch.LongStorage of size 1]


Видим, что при обучении нам надо будет подобрать 47590 параметров. В Torch индексы отсчитываются от 1. Классы в классификации тоже нумеруются начиная от 1, а у нас из csv приходят 0-based метки классов. Поправим...

train_label_tensor = train_label_tensor + 1 

Во время своих первых экспериментов я столкнулся с такими проблемами: нейросети более простой архитектуры чуть-чуть не дотягивали до baseline, более сложные нейросети (например такая, как описана выше) переобучались: на тренировочном множестве они могли достигать 99%, но на тестовом отставали примерно на полпроцента. Переобучение налицо. Сначала я пытался решить проблему подбором параметра регуляризации и dropout-слоями, но 99% на тестовой выборке не достиг. Тогда я стал "деформировать" цифры из тренировочного множества чтобы увеличить разнообразие обучаюзих примеров. Для этого я нашёл готовую функцию на GitHub (автор - пользователь chsasank).

Кстати, это очень сильно увеличило время тренировки, и оно достигло нескольких часов.Но именно это дало в итоге последние доли процента на тестовых данных.

Обучение нейросети делал "явно". То есть в модуле nn нет "высокоуровневой" операции "тренировать нейросеть". Вместо этого приходится определять градиент функции стоимости и передвать его методам пакета optim. К счастью для этого не надо руками писать метод обратного распространения ошибки, он уже есть в пакете nn (nn,backward).

batchSize = 512
trainSize = train_feature_tensor:size()[1]
batchInputs = torch.Tensor(batchSize, 1, 28, 28)
batchLabels = torch.Tensor(batchSize)
lambda = 0.0005
 
-- this matrix records the current confusion across classes
confusion = optim.ConfusionMatrix(classes)
 
local params, gradParams = model:getParameters()
local optimState = {learningRate=0.04}
for epoch=1,450 do
  --local optimState = {learningRate=0.04 - epoch/1000.0 * 0.03}
  print("Epoch:"..epoch)
  for b = 1,math.ceil(trainSize/batchSize) do
    for i=1,batchSize do
      local originalImage = torch.Tensor(1, 28, 28)
      originalImage:copy(train_feature_tensor[(b*batchSize + i - 1) % trainSize + 1])
      batchInputs[i] = ElasticTransform(originalImage, 100, 10) 
      batchLabels[i] = train_label_tensor[(b*batchSize + i - 1) % trainSize + 1]
    end
 
    --Differentiation
    local function feval(params)
      gradParams:zero()
 
      local outputs = model:forward(batchInputs)
      local loss = criterion:forward(outputs, batchLabels)
      local dloss_doutput = criterion:backward(outputs, batchLabels)
      model:backward(batchInputs, dloss_doutput)
 
      --Regularization\n",
      loss = loss + 0.5 * lambda * torch.norm(params,2)^2 / batchSize;
      gradParams:add( params:clone():mul(lambda) )
 
      -- update confusion
      for i = 1,batchSize do
        confusion:add(outputs[i], batchLabels[i])
      end
 
      return loss,gradParams
    end
    optim.sgd(feval, params, optimState)
 
 -- Too big output
 --   print(confusion)
 --   print("Total valid: "..confusion.totalValid * 100)
    confusion:zero()
  end
end
 

Этот код в основном взят из примеров Torch. Тренировка на моей машине занимает несколько часов. Оценить результаты можно с помощью матрицы ошибок. Сначала посмотрим матрицу на тренировочных данных:

confusion = optim.ConfusionMatrix(classes)
-- test function
function test(eval_features, eval_labels)
  print(eval_features:size())
  print(eval_labels:size())
  confusion:zero()
  -- test samples
  local preds = model:forward(eval_features)
 
  local maxval, pred_idx = torch.max(preds, 2)
 
  -- confusion:
  for i = 1,eval_features:size()[1] do
   -- print("Add: ", pred_idx[i][1] , eval_labels[i][1])  
    confusion:add(pred_idx[i][1], eval_labels[i][1])
  end
 
   -- print confusion matrix
   print(confusion)
   --confusion:zero()
end

train_features_resized = train_feature_tensor:resize(42000,1,28,28)
test(train_features_resized:narrow(1,1,20000), train_label_tensor:narrow(1,1,20000))

Функция test выведет матрицу ошибок. У меня в машине не хватало памяти для того, чтобы пропустить через сеть больше примрно 20000 примеров за раз, так что для матрицы ошибок я взял первые 20000 примеров. В тестовой выборке 28000 примеров, поэтому её пришлось разбить на два куска. Классификация осуществляется методом forward объекта-модели. Насколько я разобрался, метод forward меняет "на месте" (in-place) внутреннее поле объекта и возвращает ссылку на него, поэтому в результате выполнения такого кода

A = model:forward(F1)
B = model:forward(F2)

Объекты A и B будут одинаковы и равны результату model:forward(F2).
"Тетрадка" iTorch выложена на GitHub.