Глубокое обучение и нейронные сети с Python и Pytorch. Часть II: данные

Входные данные нейронной сети

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

В первую очередь, нам нужно найти набор данных.

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

Наличие таких датасетов избавляет нас от необходимости собирать данные вручную и отлично подходит для начала.

Но надо сказать, что это, наверное, последний раз, когда мы пойдем по такому пути. И хотя работать с уже обработанным датасетом очень приятно, в реальном мире такое происходит нечасто. Поэтому очень важно будет научиться работать с куда более сырыми данными.

А сейчас мы просто пытаемся построить нашу первую нейронную сеть при помощи Pytorch, поэтому воспользуемся модулем torchvision и загрузим из него датасет под названием MNIST. Он представляет собой набор картинок с изображениями написанных от руки цифр от 0 до 9. Нашей задачей будет их классифицировать, то есть просто распознать.

Для начала, произведем импорт необходимых библиотек и загрузим данные.

import torch
import torchvision
from torchvision import transforms, datasets

train = datasets.MNIST('', train=True, download=True,
                       transform=transforms.Compose([
                           transforms.ToTensor()
                       ]))

test = datasets.MNIST('', train=False, download=True,
                       transform=transforms.Compose([
                           transforms.ToTensor()
                       ])) 

В данном коде мы просто загрузили датасет, перемешали его и преобразовали в тензор.

После этого нам нужно решить, как мы собираемся производить итерации по нашему набору данных.

trainset = torch.utils.data.DataLoader(train, batch_size=10, shuffle=True)
testset = torch.utils.data.DataLoader(test, batch_size=10, shuffle=False) 

Далее вы увидите, почему все, что есть в torchvision, это сплошное жульничество! Вот что именно мы сейчас сделали? Ок, немного погодите.

Разбивка данных на тренировочный и тестовый наборы

Чтобы обучать любую модель машинного обучения, нам нужно иметь два набора данных, один для обучения, а другой для проверки. Для последнего набора нам нужны данные, которых машина никогда не видела.

Перемешивание

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

Например, если вы скормите вашей сети набор картинок с нулями, то она научиться классифицировать все изображения как нули. Затем, вы дадете ей единицы и она переучится все распозновать как единицы. И так далее. По окончании работы машина все будет распозновать как последнюю цифру, изображение которой вы ей дали. А если вы перемешаете данные, машина более вероятно научится определять, что есть что.

Масштабирование и нормализация

Еще одна важная точка в нашей программе это масштабирование и нормализация наших данных. В целом, мы хотим, чтобы все входящие данные были в диапозоне от 0 до 1. Но часто многие датасеты имеют данные в совсем других диапазонах. И нам надо найти способ, чтобы отмасштабировать такие данные, исходя из желаемого диапазона (от 0 до 1).

Например, картинка состоит из значений пикселей, которые, как правило, варьируются от 0 до 255. Чтобы масштабировать такие данные, мы обычно просто делим каждое число на 255. Вот и все. Даже с учетом того, что все наши данные это только пиксели, и единственное, что мы делаем, это делим на 255, такая операция оказывает огромное влияние на работу нейронной сети.

Батчи

Как только мы все это сделали, мы конечно же хотим сразу передать наши данные в нейронную сеть.

Не так быстро!

Есть две очень важные причины этого не делать:

  1. Нейронные сети выделяются среди других техник машинного обучения свой способностью работать с большими объемами данных. Гигабайты, терабайты, петабайты! Пока мы учимся, то, как правило, работаем с датасетами меньше одного гигабайта. Такой датасет мы можем целиком засадить в VRAM нашего GPU или хотя бы в оперативную память компьютера. К сожалению, на практике так не выйдет.
  2. Наша цель при работе с нейронными сетями — заставить их находить закономерности в данных. Мы хотим, чтобы нейронные сети находили общие принципы. Для этого у нейронных сетей есть миллионы, а иногда десятки миллионов параметров, которые они могут подстраивать. Это означает, что нейронные сети могут просто запоминать информацию. Пока мы думаем, что они обобщают данные, они их тупо запоминают. Наша задача, как дата-сайентиста, максимально усложнить для нейронной сети возможность просто запоминать данные.

Это еще одна причина, почему мы отдельно оцениваем точность в тестовой выборке и точность вне ее. Если эти два параметра примерно совпадают, очень хорошо. Если же они начинают расходиться (обычно точность в тестовой выборке резко возрастает, а точность вне ее остается той же или немного падает), то это означает, что наша сеть начала просто запоминать данные (часто такую ситуацию называют переобучением, — прим. переводчика).

Один из способов воспрепятствовать сети запоминать данные — это разбить эти данные на куски (батчи) и давать их в нейронную сеть последовательно. Размер батчей, как правило, варьируется от 8 до 64 чисел.

Хотя для этого нет никакой реальной причины, в нейронных сетях распространена тенденция использовать числа с основанием 8 для таких вещей, как количество нейронов, размеры пакетов и т. д.

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

Передача в нейронную сеть всех данных разом приведет к изменению нейронов, которое не имеет никакой связи с общими закономерностями, это просто своего рода брутфорс.

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

Однако, достаточно большая нейронная сеть даже при разбитии данных на батчи, способна просто запоминать данные (то есть переобучаться).

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

Итак, что же дальше?

У нас есть данные, но что же с ними делать? Как нам с ними работать? Мы можем произвести итерацию по нашим данным следующи образом:

for data in trainset:
    print(data)
    break 

Результат:

[tensor([[[[0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          ...,
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.]]],


        [[[0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          ...,
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.]]],


        [[[0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          ...,
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.]]],


        ...,


        [[[0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          ...,
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.]]],


        [[[0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          ...,
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.]]],


        [[[0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          ...,
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.],
          [0., 0., 0.,  ..., 0., 0., 0.]]]]), tensor([2, 3, 1, 2, 0, 8, 9, 6, 1, 2])]

Каждая итерация состоит из батча размером в 10 элементов (мы сами задали такой размер) и 10 классов. Давайте посмотрим на один из них.

X, y = data[0][0], data[1][0]

Х — это наши входные данные. Это признаки, по которым мы должны делать предсказания. у — это результат классификации. Его должна научиться получать наша нейронная сеть. Мы еще раз можем в этом убедиться.

data[0] — это группа признаков, а data[1] — это целевые значения. Итак:

print(data[1])

Результат:

tensor([2, 3, 1, 2, 0, 8, 9, 6, 1, 2])

Как вы можете видеть, data[1] — это просто набор цифр, которые должны быть результатом классификации соответствующих изображений. Таким образом, если data[1][0] равно 2, мы ожидаем что data[0][0] будет изображением цифры 2. Давайте посмотрим:

import matplotlib.pyplot as plt  

plt.imshow(data[0][0].view(28,28))
plt.show() 

Это определенно изображение цифры 2.

Итак, вот наш чеклист:

  • У нас есть размеченные данные с набором свойств и соответствующие им классы.
  • Все данные численные.
  • Мы тщательно перемешали наши данные.
  • Мы разбили данные на обучающую и тестовую выборки.
  • Наши данные отмасштабированы?
  • Наша данные сбалансированы?

Кажется, у нас осталось пара неотвеченных вопросов. Для начала, отмасштабированы ли наши данные? Ранее мы уже говорили, что нейронные сети любят, чтобы данные лежали в диапазоне от 0 до 1 или от -1 до 1. Необработанные данные изображений обычно имеют формат RGB, где каждый пиксель представляет собой кортеж со значениями от 0 до 255, что является проблемой. Как насчет нашего датасета? Он в диапазоне от 0 до 255 или уже отмасштабирован? Давайте проверим:

data[0][0][0][0] 

Результат:

tensor([0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
        0., 0., 0., 0.])

Хм, одни нули. В принципе, не удивительно, у многих изображений первые несколько рядов пустые. Посмотрим, что дальше:

data[0][0][0][3] 

Результат:

tensor([0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
        0.0000, 0.0118, 0.4157, 0.9059, 0.9961, 0.9216, 0.5647, 0.1882, 0.0000,
        0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
        0.0000])

Ага, теперь мы ясно видим, что наши данные уже отмасштабированны.

В реальной жизни такого не бывает! Как мы и говорили раньше — мошенничество! Что ж, хорошо, но остался еще одни вопрос: «Сбалансированны ли наши данные?»

Что такое балансировка данных?

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

С несбалансированными данными может произойти та же история.

Представьте, что у вас есть датасет с изображениями собак и кошек. 7200 изображений собак и 2800 кошек. Классификатор наверняка очень быстро поймет, что можно легко давать правильное предсказание с 72% вероятностью, если просто всегда отвечать «собака». И крайне мало вероятно, что модель сможет оправиться от такого «успеха».

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

Под балансом мы понимаем такую ситуацию, когда количество элементов во всех предсказываемых классах нашего датасета одинаково.

Иногда этого просто невозможно добиться. Есть известные способы бороться с данной проблемой при помощи специальной процедуры взвешивания классов, однако даже это не всегда работает. Более того, нам не известны реальные приложения, которые бы справлялись с этой проблемой.

Как же нам убедиться в том, что наши данные сбалансированы? Нам надо просто пройтись по ним всем и посчитать количество в каждом классе. Ничего сложного:

total = 0
counter_dict = {0:0, 1:0, 2:0, 3:0, 4:0, 5:0, 6:0, 7:0, 8:0, 9:0}


for data in trainset:
    Xs, ys = data
    for y in ys:
        counter_dict[int(y)] += 1
        total += 1

print(counter_dict)

for i in counter_dict:
    print(f"{i}: {counter_dict[i]/total*100.0}%")

Результат:

{0: 5923, 1: 6742, 2: 5958, 3: 6131, 4: 5842, 5: 5421, 6: 5918, 7: 6265, 8: 5851, 9: 5949}
0: 9.871666666666666%
1: 11.236666666666666%
2: 9.93%
3: 10.218333333333334%
4: 9.736666666666666%
5: 9.035%
6: 9.863333333333333%
7: 10.441666666666666%
8: 9.751666666666667%
9: 9.915000000000001%

Мы не сомневаемся, что есть и более простые способы это сделать, например, встроенные в torchvision. Но как бы то ни было, вы можете видеть, что наименьший процент равен 9, а наибольший — 11. Это вполне нас устраивает. Конечно, мы могли бы отбалансировать это и лучше, но необходимости в этом нет.

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

Следующая статья — Глубокое обучение и нейронные сети с Python и Pytorch. Часть III: построение нашей нейронной сети.