Предыдущий ‹‹ Введение в компьютерное зрение с помощью PyTorch (1/6)

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

Во-первых, мы используем помощник pytorchcv для загрузки всех данных.

!wget https://raw.githubusercontent.com/MicrosoftDocs/pytorchfundamentals/main/computer-vision-pytorch/pytorchcv.py
import torch
import torch.nn as nn
import torchvision
import matplotlib.pyplot as plt
from torchinfo import summary

from pytorchcv import load_mnist, plot_results
load_mnist()

Полностью подключенные плотные нейронные сети

Базовая нейронная сеть в PyTorch состоит из нескольких слоев. Простейшая сеть будет включать только один полностью связный слой, который называется Линейный слой, с 784 входами (по одному входу для каждого пикселя входного изображения) и 10 выходами (по одному выходу для каждого класса).

Как мы обсуждали выше, размер наших цифровых изображений составляет 1 × 28 × 28, т. е. каждое изображение содержит 28 × 28 = 784 различных пикселя. Поскольку линейный слой ожидает входных данных в виде одномерного вектора, нам нужно вставить в сеть еще один слой, называемый Flatten, чтобы изменить форму входного тензора с
1 × 28 × 28 на 784. После Flatten существует основной линейный слой (в PyTorch он называется Dense), который преобразует 784 входных данных в 10 выходных — по одному на класс. Мы хотим, чтобы
n-й выход сети возвращал вероятность того, что входная цифра равна n.

Поскольку выходные данные полностью связного слоя не нормализуются между 0 и 1, их нельзя рассматривать как вероятность. Более того, если вы хотите, чтобы выходные данные представляли собой вероятности разных цифр, все они должны быть в сумме равны 1. Чтобы превратить выходные векторы в вектор вероятности, функция под названием Softmax часто используется в качестве последней функции активации в классификационная нейронная сеть. Например, softmax([−1, 1, 2]) = [0,035, 0,25, 0,705].

В PyTorch мы часто предпочитаем использовать функцию LogSoftmax, которая также вычисляет логарифмы выходных вероятностей. Чтобы превратить выходной вектор в реальные вероятности, нам нужно взять torch.exp из выходных данных.

Таким образом, архитектуру нашей сети можно определить в PyTorch с помощью функции Sequential:

net = nn.Sequential(
        nn.Flatten(), 
        nn.Linear(784,10), # 784 inputs, 10 outputs
        nn.LogSoftmax())

Обучение сети

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

print('Digit to be predicted: ',data_train[0][1])
torch.exp(net(data_train[0][0]))
Digit to be predicted:  5
tensor([[0.1174, 0.1727, 0.0804, 0.1333, 0.0790, 0.0902, 0.0657, 0.0871, 0.0807,
         0.0933]], grad_fn=<ExpBackward>)

Поскольку мы используем LogSoftmax в качестве окончательной активации нашей сети, мы передаем выходные данные сети через torch.exp для получения вероятностей. Как видите, сеть предсказывает одинаковые вероятности для каждой цифры. Это потому, что он не обучен распознавать цифры. Нам нужно предоставить ему наши обучающие данные, чтобы обучить его на нашем наборе данных.

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

train_loader = torch.utils.data.DataLoader(data_train,batch_size=64)
test_loader = torch.utils.data.DataLoader(data_test,batch_size=64) # we can use larger batch size for testing

Этапы процесса обучения следующие:

  1. Мы берем мини-пакет из входного набора данных, который состоит из входных данных (функций) и ожидаемого результата (метка).
  2. Мы вычисляем прогнозируемый результат для этой мини-партии.
  3. Разница между этим результатом и ожидаемым результатом рассчитывается с помощью функции потерь. Функция потерь показывает, насколько выходные данные сети отличаются от ожидаемых. Цель нашего обучения – минимизировать потери.
  4. Мы вычисляем градиенты этой функции потерь относительно весов (параметров) модели, которые затем используются для корректировки весов для оптимизации производительности сети. Величина корректировки контролируется параметром скорость обучения, а детали алгоритма оптимизации определяются в объекте оптимизатора.
  5. Мы повторяем эти шаги до тех пор, пока не будет обработан весь набор данных. Один полный проход по набору данных называется эпохой.

Вот функция, которая выполняет обучение за одну эпоху:

def train_epoch(net,dataloader,lr=0.01,optimizer=None,loss_fn = nn.NLLLoss()):
    optimizer = optimizer or torch.optim.Adam(net.parameters(),lr=lr)
    net.train()
    total_loss,acc,count = 0,0,0
    for features,labels in dataloader:
        optimizer.zero_grad()
        out = net(features)
        loss = loss_fn(out,labels) #cross_entropy(out,labels)
        loss.backward()
        optimizer.step()
        total_loss+=loss
        _,predicted = torch.max(out,1)
        acc+=(predicted==labels).sum()
        count+=len(labels)
    return total_loss.item()/count, acc.item()/count

train_epoch(net,train_loader)
(0.0059344619750976565, 0.8926833333333334)

Вот что мы делаем во время тренировок:

  • Переключите сеть в режим обучения (net.train())
  • Просмотрите все пакеты в наборе данных и для каждого пакета выполните следующее:
    – Вычислите прогнозы, сделанные сетью для этого пакета (выход)
    – Вычислите потерю , который представляет собой несоответствие между прогнозируемыми и ожидаемыми значениями.
    – Минимизируйте потери путем корректировки весов сети (optimizer.step())
    – Вычислите количество правильно предсказанные случаи (точность)

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

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

def validate(net, dataloader,loss_fn=nn.NLLLoss()):
    net.eval()
    count,acc,loss = 0,0,0
    with torch.no_grad():
        for features,labels in dataloader:
            out = net(features)
            loss += loss_fn(out,labels) 
            pred = torch.max(out,1)[1]
            acc += (pred==labels).sum()
            count += len(labels)
    return loss.item()/count, acc.item()/count

validate(net,test_loader)
(0.033262069702148435, 0.9496)

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

Переобучение

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

Ниже представлена ​​функция обучения, которую можно использовать как для обучения, так и для проверки. Он печатает точность обучения и проверки для каждой эпохи, а также возвращает историю, которую можно использовать для отображения потерь и точности на графике.

def train(net,train_loader,test_loader,optimizer=None,lr=0.01,epochs=10,loss_fn=nn.NLLLoss()):
    optimizer = optimizer or torch.optim.Adam(net.parameters(),lr=lr)
    res = { 'train_loss' : [], 'train_acc': [], 'val_loss': [], 'val_acc': []}
    for ep in range(epochs):
        tl,ta = train_epoch(net,train_loader,optimizer=optimizer,lr=lr,loss_fn=loss_fn)
        vl,va = validate(net,test_loader,loss_fn=loss_fn)
        print(f"Epoch {ep:2}, Train acc={ta:.3f}, Val acc={va:.3f}, Train loss={tl:.3f}, Val loss={vl:.3f}")
        res['train_loss'].append(tl)
        res['train_acc'].append(ta)
        res['val_loss'].append(vl)
        res['val_acc'].append(va)
    return res

# Re-initialize the network to start from scratch
net = nn.Sequential(
        nn.Flatten(), 
        nn.Linear(784,10), # 784 inputs, 10 outputs
        nn.LogSoftmax())

hist = train(net,train_loader,test_loader,epochs=5)
Epoch  0, Train acc=0.892, Val acc=0.893, Train loss=0.006, Val loss=0.006
Epoch  1, Train acc=0.910, Val acc=0.899, Train loss=0.005, Val loss=0.006
Epoch  2, Train acc=0.913, Val acc=0.898, Train loss=0.005, Val loss=0.006
Epoch  3, Train acc=0.915, Val acc=0.897, Train loss=0.005, Val loss=0.006
Epoch  4, Train acc=0.916, Val acc=0.897, Train loss=0.005, Val loss=0.006

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

plt.figure(figsize=(15,5))
plt.subplot(121)
plt.plot(hist['train_acc'], label='Training acc')
plt.plot(hist['val_acc'], label='Validation acc')
plt.legend()
plt.subplot(122)
plt.plot(hist['train_loss'], label='Training loss')
plt.plot(hist['val_loss'], label='Validation loss')
plt.legend()

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

Визуализация весов сети

Уровень Dense в нашей сети также называется линейным, поскольку он выполняет линейное преобразование входных данных, которое можно определить как y = Wx + b, где W — матрица весов, а b — смещение. Матрица весов W фактически отвечает за то, что может делать наша сеть, то есть за распознавание цифр.
В нашем случае он имеет размер 784 × 10, поскольку он производит 10 выходных данных (один выход на цифру) для входного изображения.

Давайте визуализируем веса нашей нейронной сети и посмотрим, как они выглядят. Когда сеть более сложна, чем один слой, может быть сложно визуализировать такие результаты, потому что в сложной сети веса не имеют особого смысла при визуализации. Однако в нашем случае каждое из 10 измерений весовой матрицы W соответствует отдельным цифрам и, таким образом, может быть визуализировано, чтобы увидеть, как происходит распознавание цифр. Например, если мы хотим узнать, равно ли наше число 0 или нет, мы умножим входную цифру на W[0] и пропустим результат через нормализацию softmax, чтобы получить ответ.

В приведенном ниже коде мы сначала перенесем матрицу W в переменную weight_tensor. Его можно получить, вызвав метод net.parameters() (который возвращает как W, так и b), а затем вызвав метод next, чтобы получить первый из двух параметров. Затем мы пройдемся по каждому измерению, изменим его форму до размера 28 × 28 и построим график. Вы можете видеть, что 10 измерений весового тензора чем-то напоминают среднюю форму цифр, которые они классифицируют:

weight_tensor = next(net.parameters())
fig,ax = plt.subplots(1,10,figsize=(15,4))
for i,x in enumerate(weight_tensor):
    ax[i].imshow(x.view(28,28).detach())

Многослойный персептрон

Чтобы еще больше повысить точность, мы можем включить один или несколько скрытых слоев. Здесь важно отметить нелинейную функцию активации между слоями, называемую ReLU. Другими функциями активации, используемыми в глубоком обучении, являются sigmoid и tanh, но ReLU чаще всего используется в компьютерном зрении, поскольку его можно быстро вычислить, а использование других функций не приносит никакой существенной пользы.

Эту сеть можно определить в PyTorch с помощью этого кода:

net = nn.Sequential(
        nn.Flatten(), 
        nn.Linear(784,100),     # 784 inputs, 100 outputs
        nn.ReLU(),              # Activation Function
        nn.Linear(100,10),      # 100 inputs, 10 outputs
        nn.LogSoftmax(dim=0))

summary(net,input_size=(1,28,28))
==========================================================================================
Layer (type:depth-idx)                   Output Shape              Param #
==========================================================================================
├─Flatten: 1-1                           [1, 784]                  --
├─Linear: 1-2                            [1, 100]                  78,500
├─ReLU: 1-3                              [1, 100]                  --
├─Linear: 1-4                            [1, 10]                   1,010
├─LogSoftmax: 1-5                        [1, 10]                   --
==========================================================================================
Total params: 79,510
Trainable params: 79,510
Non-trainable params: 0
Total mult-adds (M): 0.08
==========================================================================================
Input size (MB): 0.00
Forward/backward pass size (MB): 0.00
Params size (MB): 0.32
Estimated Total Size (MB): 0.32
==========================================================================================

Здесь мы используем функцию summary() для отображения подробной послойной структуры сети с некоторой другой полезной информацией. В частности, мы можем увидеть:

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

Давайте посмотрим, как рассчитывается количество параметров. Первый линейный слой имеет 784 входа и 100 выходов. Слой определяется как W1 × x+ b1, где W1 имеет размер 784. × 100, а b1​ - 100. Таким образом, общее количество параметров для этого слоя равно 784 × 100 + 100 = 78500.
Аналогично количество параметров для второго слоя равно 100. × 10 + 10 = 1010. Функции активации, как и Flatten слоев, не имеют параметров.

Существует другой синтаксис, который мы можем использовать для определения той же сети с помощью классов:

from torch.nn.functional import relu, log_softmax

class MyNet(nn.Module):
    def __init__(self):
        super(MyNet, self).__init__()
        self.flatten = nn.Flatten()
        self.hidden = nn.Linear(784,100)
        self.out = nn.Linear(100,10)

    def forward(self, x):
        x = self.flatten(x)
        x = self.hidden(x)
        x = relu(x)
        x = self.out(x)
        x = log_softmax(x,dim=0)
        return x

net = MyNet()

summary(net,input_size=(1,28,28),device='cpu')
==========================================================================================
Layer (type:depth-idx)                   Output Shape              Param #
==========================================================================================
├─Flatten: 1-1                           [1, 784]                  --
├─Linear: 1-2                            [1, 100]                  78,500
├─Linear: 1-3                            [1, 10]                   1,010
==========================================================================================
Total params: 79,510
Trainable params: 79,510
Non-trainable params: 0
Total mult-adds (M): 0.08
==========================================================================================
Input size (MB): 0.00
Forward/backward pass size (MB): 0.00
Params size (MB): 0.32
Estimated Total Size (MB): 0.32
==========================================================================================

Вы можете видеть, что структура нейронной сети такая же, как и у Последовательной сети, но определение более четкое. Наша пользовательская нейронная сеть представлена ​​классом, унаследованным от класса torch.nn.Module.

Определение класса состоит из двух частей:

  • В конструкторе (__init__), мы определяем все слои, которые будут иметь нашу сеть. Эти слои сохраняются как внутренние переменные класса, и PyTorch автоматически узнает, что параметры этих слоев следует оптимизировать при обучении. Внутри PyTorch использует метод parameters() для поиска всех обучаемых параметров, а nn.Module автоматически собирает все обучаемые параметры из всех подмодулей.
  • Мы определяем метод forward, который выполняет вычисления прямой передачи нашей нейронной сети. В нашем случае мы начинаем с тензора параметров x и явно передаем его через все слои и функции активации, начиная с выравнивания и заканчивая окончательным линейным выходом слоя. Когда мы применяем нашу нейронную сеть к некоторым входным данным x, записывая out = net(x), вызывается прямой метод.

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

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

hist = train(net,train_loader,test_loader,epochs=5)
plot_results(hist)
Epoch  0, Train acc=0.962, Val acc=0.951, Train loss=0.033, Val loss=0.034
Epoch  1, Train acc=0.964, Val acc=0.951, Train loss=0.033, Val loss=0.034
Epoch  2, Train acc=0.964, Val acc=0.954, Train loss=0.033, Val loss=0.033
Epoch  3, Train acc=0.966, Val acc=0.955, Train loss=0.032, Val loss=0.033
Epoch  4, Train acc=0.966, Val acc=0.957, Train loss=0.032, Val loss=0.033

Еда на вынос

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

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

Приятного обучения!
Далее ›› Введение в компьютерное зрение с помощью PyTorch (3/6)