Введение

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

Но как это происходит на самом деле? Теоретически я могу сгенерировать синтетический набор данных, обучить сеть, а затем применить его к реальным данным вот так (я сам нарисовал их карандашом):

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

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

1. Набор данных

Набор данных состоит из изображений открытых кругов 30x30 пикселей. Будет что-то, что я называю «основным изображением», представляющее собой набор координат x-y точек, предназначенных для соединения линиями. Затем это основное изображение будет нарисовано на растровом изображении, к нему будет добавлен шум, и оно будет передано в модель NN.

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

1.1 Кривые Безье

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

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

point1 = {'x': 0, 'y': 0.5}
point2 = {'x': 0, 'y': 0}
point3 = {'x': 0.5, 'y': 0}

Если я построю кривую Безье по этим точкам, то получу что-то вроде этого:

Код пока выглядит следующим образом:

import numpy as np
import matplotlib.pyplot as plt
​
def get_corner_points(n):
    point1 = {'x': 0, 'y': 0.5}
    point2 = {'x': 0, 'y': 0}
    point3 = {'x': 0.5, 'y': 0}
​
    xs = np.zeros(n)
    ys = np.zeros(n)
    for i, t in enumerate(np.linspace(0, 1, n)):
        xs[i] = (1-t)**2 * point1['x'] + \
                2*(1-t)*t * point2['x'] + \
                (t)**2 * point3['x']
        ys[i] = (1-t)**2 * point1['y'] + \
                2*(1-t)*t * point2['y'] + \
                (t)**2 * point3['y']
    xs = np.array(xs)
    ys = np.array(ys)
    return xs, ys
​
if __name__ == '__main__':
    xs, ys = get_corner_points(100)
    plt.plot(xs, ys)
    plt.gcf().set_size_inches(5, 5)
    plt.show()

Функция get_corner_points() сама строит кривую. Он принимает количество сгенерированных точек в качестве параметра. Функция называется так, потому что она генерирует «углы» нашего круга. Если я отразим их вокруг изображения, я получу полный круг. Другими словами, если бы я изменил основной код следующим образом:

if __name__ == '__main__':
    xs, ys = get_corner_points(100)
    plt.plot(xs, ys)
    plt.plot(1 - xs, ys)
    plt.plot(xs, 1 - ys)
    plt.plot(1 - xs, 1 - ys)
    plt.gcf().set_size_inches(5, 5)
    plt.show()

У меня получится вот такой круг:

Обратите внимание, что наша окружность касается оси в точках (0, 0,5), (0,5, 0). Итак, если я изменю свои точки Безье на что-то вроде этого:

point1 = {'x': 0, 'y': 0.4}
point2 = {'x': 0, 'y': 0}
point3 = {'x': 0.4, 'y': 0}

Я получу следующее:

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

def get_corner_points(p1, p2, n):
    point1 = {'x': 0, 'y': 0.5 * p1}
    point2 = {'x': 0, 'y': 0}
    point3 = {'x': 0.5 * p2, 'y': 0}

Параметры p1 и p2 будут варьироваться в диапазоне (0, 1) — квадрат будет равен 0, а круг — 1. Пример, когда p1 = 0,4 и p2 = 0,8, выглядит так:

Полный код пока такой:

import numpy as np
import matplotlib.pyplot as plt
​
def get_corner_points(p1, p2, n):
    point1 = {'x': 0, 'y': 0.5 * p1}
    point2 = {'x': 0, 'y': 0}
    point3 = {'x': 0.5 * p2, 'y': 0}
​
    xs = np.zeros(n)
    ys = np.zeros(n)
    for i, t in enumerate(np.linspace(0, 1, n)):
        xs[i] = (1-t)**2 * point1['x'] + \
                2*(1-t)*t * point2['x'] + \
                (t)**2 * point3['x']
        ys[i] = (1-t)**2 * point1['y'] + \
                2*(1-t)*t * point2['y'] + \
                (t)**2 * point3['y']
    xs = np.array(xs)
    ys = np.array(ys)
    return xs, ys
​
if __name__ == '__main__':
    xs, ys = get_corner_points(0.4, 0.8, 100)
    plt.plot(xs, ys)
    plt.plot(1 - xs, ys)
    plt.plot(xs, 1 - ys)
    plt.plot(1 - xs, 1 - ys)
    plt.gcf().set_size_inches(5, 5)
    plt.show()

1.2 Полный круг

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

Итак, обо всем по порядку. Давайте создадим функцию, которая будет генерировать наш круг. Он примет те же параметры, что и get_corner_points(). Третий параметр «n» будет количеством точек в (целом) результирующем круге.

def get_circle(p1, p2, n):
    corner1_xs, corner1_ys = get_corner_points(p1, p2, n)
    corner2_xs, corner2_ys = 1 - corner1_xs, corner1_ys
    corner3_xs, corner3_ys = corner1_xs, 1 - corner1_ys
    corner4_xs, corner4_ys = 1 - corner1_xs, 1 - corner1_ys

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

На изображении справа показано, что мы имеем сейчас. Обратите внимание, что если мы следуем порядку точек против часовой стрелки, мы начинаем с угла 4, первой точки, затем встречаемся с углом 4, последней точкой, затем с углом 3, последней точкой, углом 3, первой точкой, углом 1, первой точкой и так далее. Нам нужно изменить порядок вещей, чтобы они выглядели как на изображении слева:

def get_circle(p1, p2, n):
    corner3_xs, corner3_ys = get_corner_points(p1, p2, n)
    corner4_xs, corner4_ys = 1 - corner3_xs[::-1], \
                             corner3_ys[::-1]
    corner2_xs, corner2_ys = corner3_xs[::-1], \
                             1 - corner3_ys[::-1]
    corner1_xs, corner1_ys = 1 - corner3_xs, 1 - corner3_ys

Теперь добавить линии между углами не проблема:

line12_y = np.ones(n)
    line12_x = np.linspace(corner1_xs[-1],
                           corner2_xs[0], n + 2)[1:-1]
    line23_y = np.linspace(corner2_ys[-1],
                           corner3_ys[0], n + 2)[1:-1]
    line23_x = np.zeros(n)
    line34_y = np.zeros(n)
    line34_x = np.linspace(corner3_xs[-1],
                           corner4_xs[0], n + 2)[1:-1]
    line41_y = np.linspace(corner4_ys[-1],
                           corner1_ys[0], n + 2)[1:-1]
    line41_x = np.ones(n)

Здесь индекс линии определяет, какие углы соединяет линия. Например, линия12 соединяет угол1 и угол2; линия41 соединяет угол4 и угол1 и так далее.

Распечатаем результат и посмотрим, что у нас есть:

plt.plot(corner1_xs, corner1_ys)
    plt.plot(line12_x, line12_y)
    plt.plot(corner2_xs, corner2_ys)
    plt.plot(line23_x, line23_y)
    plt.plot(corner3_xs, corner3_ys)
    plt.plot(line34_x, line34_y)
    plt.plot(corner4_xs, corner4_ys)
    plt.plot(line41_x, line41_y)
    plt.gcf().set_size_inches(5, 5)
    plt.show()

Выглядит хорошо. Теперь соберем все точки в один массив путем конкатенации. Есть только одна проблема. Я хочу, чтобы первой точкой массива была точка «ноль градусов» или точка, отмеченная красным крестом ниже:

Это заставит меня разделить строку 41 на половины, а затем соединить точки:

xs = np.concatenate([
        line41_x[len(line41_x) // 2:],
        corner1_xs, line12_x,
        corner2_xs, line23_x,
        corner3_xs, line34_x,
        corner4_xs, line41_x[:len(line41_x) // 2]
    ])
    ys = np.concatenate([
        line41_y[len(line41_y) // 2:],
        corner1_ys, line12_y,
        corner2_ys, line23_y,
        corner3_ys, line34_y,
        corner4_ys, line41_y[:len(line41_y) // 2]
    ])

Теперь, нанеся на график xs и ys и нанеся их частично, мы получим основное изображение незамкнутого круга:

plt.subplot(1, 2, 1)
    plt.plot(xs, ys)
    plt.subplot(1, 2, 2)
    plt.plot(xs[15:-15], ys[15:-15])
    plt.gcf().set_size_inches(10, 5)
    plt.show()

Теперь нам нужно сделать так, чтобы расстояние между точками было равномерным. Самый простой способ сделать это - использовать функцию numpy interp. Для этого я создам массив расстояний между точками для текущего круга, затем массив желаемых (равномерных) расстояний и сделаю интерполяцию:

dx = np.diff(xs)
    dy = np.diff(ys)
    distances = np.square(dx ** 2 + dy ** 2)
    location_old = [0]
    for d in distances:
        location_old.append(location_old[-1] + d)
    location_old = np.array(location_old)
    location_old /= np.sum(distances)
    location_new = np.linspace(0, 1, n)
    xs = np.interp(location_new, location_old, xs)
    ys = np.interp(location_new, location_old, ys)

Теперь массивы xs и ys содержат по n элементов каждый, и если мы снова построим изображения выше, мы получим:

Обратите внимание, что изображение слева обрезано на 30% (поскольку у нас есть этот код):

plt.plot(xs[15:-15], ys[15:-15])

Разрыв стал больше, потому что у нас в целом меньше очков, но все равно убрано 15 из них.

Теперь я добавлю в эту функцию только возвращаемое значение. Окончательный код функции выглядит так:

def get_circle(p1, p2, n):
    corner3_xs, corner3_ys = get_corner_points(p1, p2, n)
    corner4_xs, corner4_ys = 1 - corner3_xs[::-1], \
                             corner3_ys[::-1]
    corner2_xs, corner2_ys = corner3_xs[::-1], \
                             1 - corner3_ys[::-1]
    corner1_xs, corner1_ys = 1 - corner3_xs, 1 - corner3_ys
    line12_y = np.ones(n)
    line12_x = np.linspace(corner1_xs[-1],
                           corner2_xs[0], n + 2)[1:-1]
    line23_y = np.linspace(corner2_ys[-1],
                           corner3_ys[0], n + 2)[1:-1]
    line23_x = np.zeros(n)
    line34_y = np.zeros(n)
    line34_x = np.linspace(corner3_xs[-1],
                           corner4_xs[0], n + 2)[1:-1]
    line41_y = np.linspace(corner4_ys[-1],
                           corner1_ys[0], n + 2)[1:-1]
    line41_x = np.ones(n)
​
    xs = np.concatenate([
        line41_x[len(line41_x) // 2:],
        corner1_xs, line12_x,
        corner2_xs, line23_x,
        corner3_xs, line34_x,
        corner4_xs, line41_x[:len(line41_x) // 2]
    ])
    ys = np.concatenate([
        line41_y[len(line41_y) // 2:],
        corner1_ys, line12_y,
        corner2_ys, line23_y,
        corner3_ys, line34_y,
        corner4_ys, line41_y[:len(line41_y) // 2]
    ])
​
    dx = np.diff(xs)
    dy = np.diff(ys)
    distances = np.square(dx ** 2 + dy ** 2)
    location_old = [0]
    for d in distances:
        location_old.append(location_old[-1] + d)
    location_old = np.array(location_old)
    location_old /= np.sum(distances)
    location_new = np.linspace(0, 1, n)
    xs = np.interp(location_new, location_old, xs)
    ys = np.interp(location_new, location_old, ys)
​
    return xs, ys
​
​
if __name__ == '__main__':
    xs, ys = get_circle(0.4, 0.8, 100)
    plt.subplot(1, 2, 1)
    plt.plot(xs, ys)
    plt.subplot(1, 2, 2)
    plt.plot(xs[15:-15], ys[15:-15])
    plt.gcf().set_size_inches(10, 5)
    plt.show()

1.3 Случайное преобразование

Чтобы сгенерировать «все виды открытых кругов», давайте возьмем то, что у нас есть, и масштабируем его до случайного значения. А еще вращать. Операция будет выполняться путем умножения матриц. Функция, отвечающая за это преобразование, будет принимать следующие параметры:

  • x и y координаты нашего круга
  • угол поворота
  • шкала х, шкала у

Так что это будет заглушка для функции:

def transform(xs, ys, theta, xscale, yscale):
    for i in range(len(xs)):
        pass # Do matrix multiplication
    return xs, ys

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

Пока есть две проблемы: во-первых, нам нужно переместить центр нашей окружности в начало координат. Во-вторых, наша тета должна быть в радианах. Давайте исправим это в коде и добавим матричные коэффициенты:

def transform(xs, ys, theta, xscale, yscale):
    # Move to origin of coordinates
    xs -= 0.5
    ys -= 0.5
    # Convert theta to rad
    theta = theta / 180 * np.pi
    a11 = np.cos(theta)
    a12 = -np.sin(theta)
    a21 = np.sin(theta)
    a22 = np.cos(theta)
    for i in range(len(xs)):
        pass # Do matrix multiplication
    return xs, ys

Теперь нам нужно только выполнить фактическое масштабирование и умножение матриц. Добавим их в код:

def transform(xs, ys, theta, xscale, yscale):
    # Move to origin of coordinates
    xs -= 0.5
    ys -= 0.5
    # Convert theta to rad
    theta = theta / 180 * np.pi
​
    xs *= xscale
    ys *= yscale
    a11 = np.cos(theta)
    a12 = -np.sin(theta)
    a21 = np.sin(theta)
    a22 = np.cos(theta)
    for i in range(len(xs)):
        x, y = xs[i], ys[i]
        xs[i] = x * a11 + y * a12
        ys[i] = x * a21 + y * a22
    return xs, ys

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

def transform(xs, ys, theta, xscale, yscale):
    # Move to origin of coordinates
    xs -= 0.5
    ys -= 0.5
    # Convert theta to rad
    theta = theta / 180 * np.pi
​
    xs *= xscale
    ys *= yscale
    a11 = np.cos(theta)
    a12 = -np.sin(theta)
    a21 = np.sin(theta)
    a22 = np.cos(theta)
    for i in range(len(xs)):
        x, y = xs[i], ys[i]
        xs[i] = x * a11 + y * a12
        ys[i] = x * a21 + y * a22
    xs += 0.5
    ys += 0.5
    
    return xs, ys
​
​
if __name__ == '__main__':
    xs, ys = get_circle(0.4, 0.8, 100)
    xs, ys = transform(xs, ys, 10, 0.8, 1.3)
​
    plt.subplot(1, 2, 1)
    plt.plot(xs, ys)
    plt.subplot(1, 2, 2)
    plt.plot(xs[15:-15], ys[15:-15])
    plt.gcf().set_size_inches(10, 5)
    plt.show()

Если мы запустим код, мы получим следующее изображение:

Теперь мы готовы перейти к следующему этапу — нарисовать реальное изображение с пикселями — с помощью OpenCV.

1.4 Преобразование в пиксели

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

import cv2 as cv

Далее давайте заглушку для нашей функции:

def to_image(xs, ys, img_size):
    result = np.zeros((img_size, img_size))
​
    return result
​
​
if __name__ == '__main__':
    xs, ys = get_circle(0.4, 0.8, 100)
    xs, ys = transform(xs, ys, 10, 0.8, 1.3)
    img = to_image(xs, ys, 30)
​
    plt.pcolor(img, cmap='Wistia')
    plt.gcf().set_size_inches(5, 5)
    plt.show()

Теперь давайте добавим процедуру рисования. Имейте в виду, что наше основное изображение находится в диапазоне (0, 1) — это должно быть сопоставлено с (0, 30), иначе наше изображение будет отображаться как одна точка:

def to_image(xs, ys, img_size):
    result = np.zeros((img_size, img_size))
    xs = (xs * img_size).astype(int)
    ys = (ys * img_size).astype(int)
    thickness = 1
    for i in range(1, len(xs)):
        cv.line(result, 
                (xs[i - 1], ys[i - 1]), 
                (xs[i], ys[i]), 1, thickness)
​
    return result

После запуска кода получаем следующее изображение:

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

xs, ys = transform(xs, ys, 10, 0.8, 0.6)

И получите это:

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

# Add shift noise
    shift_x = np.random.uniform(-0.1, 0.1)
    shift_y = np.random.uniform(-0.1, 0.1)
    xs += shift_x
    ys += shift_y

Во-вторых, к каждой точке добавляется шум:

# Every point noise
    img_noise_std = np.random.uniform(0.01, 0.03)
    xs += np.random.normal(0, img_noise_std, xs.shape)
    ys += np.random.normal(0, img_noise_std, ys.shape)

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

# White noise
white_noise_std = np.random.uniform(0.1, 0.3)
result += np.random.normal(0, white_noise_std, result.shape)

Вот полный код функции:

def to_image(xs, ys, img_size):
    result = np.zeros((img_size, img_size))
​
    # Add shift noise
    shift_x = np.random.uniform(-0.1, 0.1)
    shift_y = np.random.uniform(-0.1, 0.1)
    xs += shift_x
    ys += shift_y
​
    # Every point noise
    img_noise_std = np.random.uniform(0.01, 0.03)
    xs += np.random.normal(0, img_noise_std, xs.shape)
    ys += np.random.normal(0, img_noise_std, ys.shape)
​
    xs = (xs * img_size).astype(int)
    ys = (ys * img_size).astype(int)
​
    thickness = 1
    for i in range(1, len(xs)):
        cv.line(result,
                (xs[i - 1], ys[i - 1]),
                (xs[i], ys[i]), 1, thickness)
    # White noise
    white_noise_std = np.random.uniform(0.1, 0.3)
    result += np.random.normal(0, white_noise_std, result.shape)
​
​
    return result
​
​
if __name__ == '__main__':
    xs, ys = get_circle(0.4, 0.8, 100)
    xs, ys = transform(xs, ys, 10, 0.8, 0.6)
    img = to_image(xs, ys, 30)
​
    plt.pcolor(img, cmap='Wistia')
    plt.gcf().set_size_inches(5, 5)
    plt.show()

И вот наш вывод на данный момент:

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

# Blur image and noise
    blur_noise_std = np.random.uniform(0.1, 0.3)
    blur_noise = np.random.normal(0, blur_noise_std, result.shape)
    img_blur = np.random.randint(1, 3)
    noise_blur = np.random.randint(1, 5)
    cv.blur(result, (img_blur, img_blur), result)
    cv.blur(blur_noise, (noise_blur, noise_blur), blur_noise)
    result += blur_noise

Во-вторых, толщина линии. Я хотел бы также рандомизировать его. Это простое изменение, так как мы используем «толщину» в вызове cv.line(). Мне просто нужно инициализировать толщину случайным значением:

thickness = np.random.randint(1, 4)

Вот как выглядит пример вывода:

Теперь последняя часть. Прежде чем передать это изображение в модель, мы должны убедиться, что его значения лежат в определенном диапазоне (от 0 до 1). Для этого я добавлю следующие строки:

result -= result.min()
    result /= result.max()

Это все для изображения. Вот как выглядит полный код функции:

def to_image(xs, ys, img_size):
    result = np.zeros((img_size, img_size))
​
    # Add shift noise
    shift_x = np.random.uniform(-0.1, 0.1)
    shift_y = np.random.uniform(-0.1, 0.1)
    xs += shift_x
    ys += shift_y
​
    # Every point noise
    img_noise_std = np.random.uniform(0.01, 0.03)
    xs += np.random.normal(0, img_noise_std, xs.shape)
    ys += np.random.normal(0, img_noise_std, ys.shape)
​
    xs = (xs * img_size).astype(int)
    ys = (ys * img_size).astype(int)
​
    thickness = np.random.randint(1, 4)
    for i in range(1, len(xs)):
        cv.line(result,
                (xs[i - 1], ys[i - 1]),
                (xs[i], ys[i]), 1, thickness)
​
    # Blur image and noise
    blur_noise_std = np.random.uniform(0.1, 0.3)
    blur_noise = np.random.normal(0, blur_noise_std, result.shape)
    img_blur = np.random.randint(1, 3)
    noise_blur = np.random.randint(1, 5)
    cv.blur(result, (img_blur, img_blur), result)
    cv.blur(blur_noise, (noise_blur, noise_blur), blur_noise)
    result += blur_noise
​
    # White noise
    white_noise_std = np.random.uniform(0.1, 0.3)
    result += np.random.normal(0, white_noise_std, result.shape)
​
    result -= result.min()
    result /= result.max()
    return result

1.5 Генератор изображений

Теперь, когда мы можем создать изображение с некоторыми параметрами, давайте создадим функцию, которая будет генерировать эти параметры и, следовательно, генерировать изображение. Эта функция также даст фактические «открытости» изображению и создаст для него метку. Функция не будет принимать никаких параметров, так как она будет генерировать параметры и будет выводить изображение вместе с его меткой. Метки будут даны в виде sin и cos угла. Мы не хотим использовать сам угол, потому что сети будет сложно определить важность ошибки.

Например, представьте, что у нас есть круг, открытый на 0,1 градуса. Наша сеть угадывает 359 градусов, что является хорошим предположением. Но для сети ошибка кажется высокой. Если мы вместо этого используем cos, он будет близок к 1 как для 0,1, так и для 359 градусов.

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

def generate_image():
    # Generate random values as parameters
    # for circle generation and the transform
    xs, ys = get_circle(0.4, 0.8, 100)
    xs, ys = transform(xs, ys, 10, 0.8, 0.6)
    img = to_image(xs, ys, 30)
    
    label = [0, 0] # Random label, for now
    return img, label

Наша основная функция также претерпит небольшие изменения:

if __name__ == '__main__':
    img, label = generate_image()
​
    print(label)
    plt.pcolor(img, cmap='Wistia')
    plt.gcf().set_size_inches(5, 5)
    plt.show()

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

angle = np.random.uniform(0, 360)
    angle_rad = angle / 180 * np.pi
    ...
    xs, ys = transform(xs, ys, angle, 0.8, 0.6)
    ...
    label = [np.sin(angle_rad), np.cos(angle_rad)]

Теперь мы добавляем «opennes» к кругу. Это будет сделано путем обрезки некоторых значений из массивов «xs» и «ys». Кроме того, я хотел бы установить это значение в процентах относительно длины всего круга. Неважно, будет ли наш круг длиной 100 точек, но я все равно введу их как параметры:

n_circle_pts = 100
    open_percent = 20
    n_pts_skip = int(n_circle_pts / 100 * open_percent)
    ...
    xs, ys = get_circle(0.4, 0.8, n_circle_pts)
    ...
    img = to_image(xs[n_pts_skip // 2:-n_pts_skip // 2],
                   ys[n_pts_skip // 2:-n_pts_skip // 2], 30)
    ...

Это дает нам круг, открытый на 20% его длины. Обратите внимание, что если мы изменим количество точек для основного изображения:

n_circle_pts = 350

Круг останется открытым в 20%.

Теперь рандомизируем все оставшиеся параметры:

def generate_image():
    n_circle_pts = 350
​
    # Parameters randomization
    angle = np.random.uniform(0, 360)
    open_percent = np.random.uniform(10, 40)
    circle_p1 = np.random.uniform(0.7, 1.)
    circle_p2 = np.random.uniform(0.7, 1.)
    scale_x = np.random.uniform(0.5, 1.1)
    scale_y = np.random.uniform(0.5, 1.1)
​
    n_pts_skip = int(n_circle_pts / 100 * open_percent)
    angle_rad = angle / 180 * np.pi
    xs, ys = get_circle(circle_p1, circle_p2, n_circle_pts)
    xs, ys = transform(xs, ys, angle, scale_x, scale_y)
    img = to_image(xs[n_pts_skip // 2:-n_pts_skip // 2],
                   ys[n_pts_skip // 2:-n_pts_skip // 2], 30)
​
    label = [np.sin(angle_rad), np.cos(angle_rad)]
    return img, label

Обновив нашу основную функцию для отображения нескольких изображений:

if __name__ == '__main__':
    for i in range(16):
        img, label = generate_image()
        plt.subplot(4, 4, i + 1)
        plt.pcolor(img, cmap='Wistia')
    plt.gcf().set_size_inches(8, 8)
    plt.show()

Это даст нам следующий образец изображения:

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

n_images = 1024
    images, labels = [], []
    for i in range(n_images):
        img, label = generate_image()
        images.append(img)
        labels.append(label)
    np.save('images.npy', np.array(images))
    np.save('labels.npy', np.array(labels))

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

from tqdm import tqdm

И измените мой код генерации набора данных на это:

n_images = 256 * 1024
    images, labels = [], []
    for i in tqdm(range(n_images)):
        img, label = generate_image()
        images.append(img)
        labels.append(label)
    np.save('images.npy', np.array(images))
    np.save('labels.npy', np.array(labels))

Вот полный код генерации набора данных:

import cv2 as cv
import numpy as np
from tqdm import tqdm
import matplotlib.pyplot as plt
​
def get_corner_points(p1, p2, n):
    point1 = {'x': 0, 'y': 0.5 * p1}
    point2 = {'x': 0, 'y': 0}
    point3 = {'x': 0.5 * p2, 'y': 0}
​
    xs = np.zeros(n)
    ys = np.zeros(n)
    for i, t in enumerate(np.linspace(0, 1, n)):
        xs[i] = (1-t)**2 * point1['x'] + \
                2*(1-t)*t * point2['x'] + \
                (t)**2 * point3['x']
        ys[i] = (1-t)**2 * point1['y'] + \
                2*(1-t)*t * point2['y'] + \
                (t)**2 * point3['y']
    xs = np.array(xs)
    ys = np.array(ys)
    return xs, ys
​
def get_circle(p1, p2, n):
    corner3_xs, corner3_ys = get_corner_points(p1, p2, n)
    corner4_xs, corner4_ys = 1 - corner3_xs[::-1], \
                             corner3_ys[::-1]
    corner2_xs, corner2_ys = corner3_xs[::-1], \
                             1 - corner3_ys[::-1]
    corner1_xs, corner1_ys = 1 - corner3_xs, 1 - corner3_ys
    line12_y = np.ones(n)
    line12_x = np.linspace(corner1_xs[-1],
                           corner2_xs[0], n + 2)[1:-1]
    line23_y = np.linspace(corner2_ys[-1],
                           corner3_ys[0], n + 2)[1:-1]
    line23_x = np.zeros(n)
    line34_y = np.zeros(n)
    line34_x = np.linspace(corner3_xs[-1],
                           corner4_xs[0], n + 2)[1:-1]
    line41_y = np.linspace(corner4_ys[-1],
                           corner1_ys[0], n + 2)[1:-1]
    line41_x = np.ones(n)
​
    xs = np.concatenate([
        line41_x[len(line41_x) // 2:],
        corner1_xs, line12_x,
        corner2_xs, line23_x,
        corner3_xs, line34_x,
        corner4_xs, line41_x[:len(line41_x) // 2]
    ])
    ys = np.concatenate([
        line41_y[len(line41_y) // 2:],
        corner1_ys, line12_y,
        corner2_ys, line23_y,
        corner3_ys, line34_y,
        corner4_ys, line41_y[:len(line41_y) // 2]
    ])
​
    dx = np.diff(xs)
    dy = np.diff(ys)
    distances = np.square(dx ** 2 + dy ** 2)
    location_old = [0]
    for d in distances:
        location_old.append(location_old[-1] + d)
    location_old = np.array(location_old)
    location_old /= np.sum(distances)
    location_new = np.linspace(0, 1, n)
    xs = np.interp(location_new, location_old, xs)
    ys = np.interp(location_new, location_old, ys)
​
    return xs, ys
​
def transform(xs, ys, theta, xscale, yscale):
    # Move to origin of coordinates
    xs -= 0.5
    ys -= 0.5
    # Convert theta to rad
    theta = theta / 180 * np.pi
​
    xs *= xscale
    ys *= yscale
    a11 = np.cos(theta)
    a12 = -np.sin(theta)
    a21 = np.sin(theta)
    a22 = np.cos(theta)
    for i in range(len(xs)):
        x, y = xs[i], ys[i]
        xs[i] = x * a11 + y * a12
        ys[i] = x * a21 + y * a22
    xs += 0.5
    ys += 0.5
​
    return xs, ys
​
def to_image(xs, ys, img_size):
    result = np.zeros((img_size, img_size))
​
    # Add shift noise
    shift_x = np.random.uniform(-0.1, 0.1)
    shift_y = np.random.uniform(-0.1, 0.1)
    xs += shift_x
    ys += shift_y
​
    # Every point noise
    img_noise_std = np.random.uniform(0.01, 0.03)
    xs += np.random.normal(0, img_noise_std, xs.shape)
    ys += np.random.normal(0, img_noise_std, ys.shape)
​
    xs = (xs * img_size).astype(int)
    ys = (ys * img_size).astype(int)
​
    thickness = np.random.randint(1, 4)
    for i in range(1, len(xs)):
        cv.line(result,
                (xs[i - 1], ys[i - 1]),
                (xs[i], ys[i]), 1, thickness)
​
    # Blur image and noise
    blur_noise_std = np.random.uniform(0.1, 0.3)
    blur_noise = np.random.normal(0, blur_noise_std, result.shape)
    img_blur = np.random.randint(1, 3)
    noise_blur = np.random.randint(1, 5)
    cv.blur(result, (img_blur, img_blur), result)
    cv.blur(blur_noise, (noise_blur, noise_blur), blur_noise)
    result += blur_noise
​
    # White noise
    white_noise_std = np.random.uniform(0.1, 0.3)
    result += np.random.normal(0, white_noise_std, result.shape)
​
    result -= result.min()
    result /= result.max()
    return result
​
​
def generate_image():
    n_circle_pts = 350
​
    # Parameters randomization
    angle = np.random.uniform(0, 360)
    open_percent = np.random.uniform(10, 40)
    circle_p1 = np.random.uniform(0.7, 1.)
    circle_p2 = np.random.uniform(0.7, 1.)
    scale_x = np.random.uniform(0.5, 1.1)
    scale_y = np.random.uniform(0.5, 1.1)
​
    n_pts_skip = int(n_circle_pts / 100 * open_percent)
    angle_rad = angle / 180 * np.pi
    xs, ys = get_circle(circle_p1, circle_p2, n_circle_pts)
    xs, ys = transform(xs, ys, angle, scale_x, scale_y)
    img = to_image(xs[n_pts_skip // 2:-n_pts_skip // 2],
                   ys[n_pts_skip // 2:-n_pts_skip // 2], 30)
​
    label = [np.sin(angle_rad), np.cos(angle_rad)]
    return img, label
​
​
if __name__ == '__main__':
    for i in range(16):
        img, label = generate_image()
        plt.subplot(4, 4, i + 1)
        plt.pcolor(img, cmap='Wistia')
    plt.gcf().set_size_inches(8, 8)
    plt.show()
​
    n_images = 256 * 1024
    images, labels = [], []
    for i in tqdm(range(n_images)):
        img, label = generate_image()
        images.append(img)
        labels.append(label)
    np.save('images.npy', np.array(images))
    np.save('labels.npy', np.array(labels))
​

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

2. Модель

2.1. Архитектура

Для наших целей мы будем использовать двухслойную сеть CNN, за которой следует плотный слой. У нас будут относительно большие ядра (5–7 пикселей) и большие пулы (2–4 пикселя). Таким образом, всю архитектуру можно описать так:

  • Конв 2д
  • Активация Тан
  • Среднее объединение
  • Конв 2д
  • Активация Тан
  • Среднее объединение
  • Уплощение
  • Плотный слой
  • Активация Тан
  • Плотный слой
  • Активация Тан

А вот и список варьируемых параметров:

  • Количество сверточных фильтров
  • Размер ядра свертки
  • Размер пула
  • Плотная сеть — количество скрытых нейронов

Итак, вот наш начальный черновик кода класса модели:

import torch
​
class LargeWin(torch.nn.Module):
    def __init__(self, params):
        ch1, ch2 = params['ch1'], params['ch2']
        ch3 = params['ch3']
        pool1_size = params['pool1_size']
        pool2_size = params['pool2_size']
        ks1, ks2 = params['ks1'], params['ks2']
        super(LargeWin, self).__init__()
​
    def forward(self, x):
        return x

Я передаю параметры здесь в виде словаря, чтобы потом было проще автоматизировать тестирование. Среди параметров есть ch1, ch2 — количество фильтров для сверток. Ch3 — количество скрытых нейронов для плотной сети. Pool1_size и pool2_size — размеры слоев пула после сверток. Ks1 и ks2 — размеры ядра свертки.

Давайте теперь добавим сетевые слои. Обратите внимание, что входной размер плотного слоя (после выравнивания) будет зависеть от размера ядер и окон объединения. Он будет рассчитан в конструкторе модели как ch_in:

self.conv1 = torch.nn.Conv2d(1, ch1, ks1)
        self.conv2 = torch.nn.Conv2d(ch1, ch2, ks2)
        img_size1 = (img_size - ks1 // 2 * 2) // pool1_size
        img_size2 = (img_size1 - ks2 // 2 * 2) // pool2_size
        ch_in = img_size2 ** 2 * ch2
        if ch_in > 4 or img_size2 <= 0:
            raise Exception('Crazy input')
        self.dense1 = torch.nn.Linear(ch_in, ch3)
        self.dense2 = torch.nn.Linear(ch3, 2)
        self.pool1 = torch.nn.AvgPool2d(pool1_size)
        self.pool2 = torch.nn.AvgPool2d(pool2_size)

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

Метод forward() вызывает слои один за другим, добавляя пулы и нелинейности между ними. Вот как выглядит полный код модели:

import torch
​
class LargeWin(torch.nn.Module):
    def __init__(self, params):
        img_size = 30
        ch1, ch2 = params['ch1'], params['ch2']
        ch3 = params['ch3']
        pool1_size = params['pool1_size']
        pool2_size = params['pool2_size']
        ks1, ks2 = params['ks1'], params['ks2']
        super(LargeWin, self).__init__()
​
        self.conv1 = torch.nn.Conv2d(1, ch1, ks1)
        self.conv2 = torch.nn.Conv2d(ch1, ch2, ks2)
        img_size1 = (img_size - ks1 // 2 * 2) // pool1_size
        img_size2 = (img_size1 - ks2 // 2 * 2) // pool2_size
        ch_in = img_size2 ** 2 * ch2
        if ch_in > 4 or img_size2 <= 0:
            raise Exception('Crazy input')
        self.dense1 = torch.nn.Linear(ch_in, ch3)
        self.dense2 = torch.nn.Linear(ch3, 2)
        self.pool1 = torch.nn.AvgPool2d(pool1_size)
        self.pool2 = torch.nn.AvgPool2d(pool2_size)
​
    def forward(self, x):
        x = self.conv1(x)
        x = self.pool1(x)
        x = torch.tanh(x)
​
        x = self.conv2(x)
        x = self.pool2(x)
        x = torch.tanh(x)
​
        x = x.reshape([x.shape[0], -1])
        x = self.dense1(x)
        x = torch.tanh(x)
        x = self.dense2(x)
​
        return torch.tanh(x)

2.2 Тестирование модели

Хорошо, кода было много, и мы его еще не запускали. Давайте исправим это сейчас. Мы создадим основную функцию, которая будет запускать образец ввода через модель. Но сначала нам нужно сгенерировать этот образец, поэтому давайте импортируем наш generate_dataset.py:

from generate_dataset import generate_image

А теперь добавьте саму функцию:

if __name__ == '__main__':
    img, label = generate_image()
    print(img.shape)

Далее мы создадим объекты модели. Обратите внимание, что они получают параметры в виде словаря, поэтому это будет выглядеть несколько странно:

if __name__ == '__main__':
    img, label = generate_image()
    print(img.shape)
    modelLargeWin = LargeWin({
        "ch1": 4, 'ch2': 4, 'ch3': 4,
        'pool1_size': 4, 'pool2_size': 2,
        'ks1': 7, 'ks2': 5
    })

Далее мы пропустим изображение через модель, но сначала нам нужно преобразовать его в тензор и придать ему правильную форму (добавьте примеры и размеры каналов):

if __name__ == '__main__':
    img, label = generate_image()
    print(img.shape)
    modelLargeWin = LargeWin({
        "ch1": 4, 'ch2': 4, 'ch3': 4,
        'pool1_size': 4, 'pool2_size': 2,
        'ks1': 7, 'ks2': 5
    })
​
    img_t = torch.tensor(img).float()
    img_t = img_t.unsqueeze(0).unsqueeze(0)
    outLargeWin = modelLargeWin(img_t)
    print('LargeWin', outLargeWin)

Это должно вывести исходный размер изображения и вывод модели:

(30, 30)
LargeWin tensor([[-0.5127,  0.0033]], grad_fn=<TanhBackward>)

Если модель выдает что-то нормальное (два числа sin и cos) — можно переходить к обучению.

3. Обучение

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

import os
import torch
import numpy as np
from torch.utils.data import TensorDataset, DataLoader
​
if __name__ == '__main__':
    device = torch.device('cuda:0'
        if torch.cuda.is_available()
        else 'cpu')
    data = torch.tensor(np.load('images.npy'))
    labels = torch.tensor(np.load('labels.npy'))
    train_data = data.float().unsqueeze(1).to(device)
    train_labels = labels.float().to(device)
    train_dataset = TensorDataset(train_data, train_labels)
    train_dataloader = DataLoader(train_dataset, 1024)
    print(len(train_dataloader))

Теперь функция для фактического обучения. Пока ничего особенного. Обучите модель, сохраните веса в файле.

def train_model(model, parameters, dataloader):
    opt = torch.optim.Adam(model.parameters())
    mse = torch.nn.MSELoss()
    for epoch in range(51):
        train_loss = 0
        for input, label in dataloader:
            opt.zero_grad()
            out = model(input)
            loss = mse(out, label)
            train_loss += loss
            loss.backward()
            opt.step()
        train_loss = train_loss / len(dataloader)
        if epoch % 10 == 0:
            print(epoch, train_loss.item())
    params = list(parameters.values())
    params_string = '_'.join([str(i) for i in params])
    file = open('models/{}_{}.pt'.format(
        type(model).__name__, 
        params_string
    ), 'wb')
    torch.save(model.state_dict(), file)

Чтобы протестировать этот скрипт, я просто создаю модель и запускаю ее через функцию обучения. Обратите внимание, что он попытается сохранить вес в папке «models», поэтому нам нужно ее создать.

import models
os.makedirs('models', exist_ok=True)
    parameters = {
        "ch1": 2, 'ch2': 2, 'ch3': 2,
        'pool1_size': 3, 'pool2_size': 2,
        'ks1': 5, 'ks2': 7
    }
    model = models.LargeWin(parameters).to(device)
    train_model(model, parameters, train_dataloader)

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

После запуска скрипта у нас создана папка «models» и файл «LargeWin_2_2_2_3_2_5_7.pt» внутри, а значит обучение работает нормально. Давайте теперь зарегистрируем процесс, чтобы мы могли легко увидеть, какие модели лучше учатся. Для этого мы будем использовать mlflow:

import mlflow

Это будет имя и параметры модели регистрации, а также потери для каждой эпохи. Вот как выглядит train_model() после того, как я это сделаю:

def train_model(model, parameters, dataloader):
    opt = torch.optim.Adam(model.parameters())
    mse = torch.nn.MSELoss()
    with mlflow.start_run(run_name="OpenCircle"):
        # Log the model name
        mlflow.log_param('model_type', type(model).__name__)
        for key in parameters:
            mlflow.log_param(key, parameters[key])
        for epoch in range(51):
            train_loss = 0
            for input, label in dataloader:
                opt.zero_grad()
                out = model(input)
                loss = mse(out, label)
                train_loss += loss
                loss.backward()
                opt.step()
            train_loss = train_loss / len(dataloader)
            if epoch % 10 == 0:
                print(epoch, train_loss.item())
                # Log the progress
                mlflow.log_metric(
                    'train_loss',
                    train_loss.item(), 
                    epoch
                )
        params = list(parameters.values())
        params_string = '_'.join([str(i) for i in params])
        file = open('models/{}_{}.pt'.format(
            type(model).__name__,
            params_string
        ), 'wb')
        torch.save(model.state_dict(), file)

Теперь, если мы запустим код, у нас должна появиться новая папка с именем «mlruns». Если мы вызовем команду bash в нашей папке

mlflow ui --port=5000

и открываем наш браузер по адресу localhost:5000 — мы должны увидеть отчет об обучении одной модели.

Далее нам нужно попробовать разные параметры для обучения модели. Другими словами, мы должны изменить это

parameters = {
        "ch1": 2, 'ch2': 2, 'ch3': 2,
        'pool1_size': 3, 'pool2_size': 2,
        'ks1': 5, 'ks2': 7
    }
    model = models.LargeWin(parameters).to(device)
    train_model(model, parameters, train_dataloader)

в это:

for ch1 in [2, 4]:
        for ch2 in [2, 4]:
            for ch3 in [2, 4]:
                for pool1_size in [2, 3, 4, 8]:
                    for pool2_size in [2, 3, 4, 8]:
                        for ks1 in [5, 7]:
                            for ks2 in [5, 7]:
    # This code should run in the innermost loop
    # I moved it all the way to the right for illustration
    parameters = {
        "ch1": ch1,
        'ch2': ch2,
        'ch3': ch3,
        'pool1_size': pool1_size,
        'pool2_size': pool2_size,
        'ks1': ks1,
        'ks2': ks2
    }
    model = models.LargeWin(parameters).to(device)
    train_model(model, parameters, train_dataloader)

Также помните, что наша модель может создавать исключения «Безумный ввод», поэтому нам нужно попытаться их поймать. На этом этапе я собираюсь разделить приведенный выше код на функции, чтобы он подходил по размеру:

def run_train_model_large(ch1, ch2, ch3, 
                        pool1_size, pool2_size, 
                        ks1, ks2):
        try:
            parameters = {
                "ch1": ch1,
                'ch2': ch2,
                'ch3': ch3,
                'pool1_size': pool1_size,
                'pool2_size': pool2_size,
                'ks1': ks1,
                'ks2': ks2
            }
            model = models.LargeWin(parameters).to(device)
            train_model(model, parameters, train_dataloader)
        except Exception as e:
             print(e)
​
    for ch1 in [2, 4]:
        for ch2 in [2, 4]:
            for ch3 in [2, 4]:
                for pool1_size in [2, 3, 4, 8]:
                    for pool2_size in [2, 3, 4, 8]:
                        for ks1 in [5, 7]:
                            for ks2 in [5, 7]:
                                run_train_model_large(
                                    ch1, ch2, ch3,
                                    pool1_size,
                                    pool2_size,
                                    ks1, ks2
                                )

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

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

4. Анализ

4.1 Оценка

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

import os
import torch
import numpy as np
import matplotlib.pyplot as plt
from torch.utils.data import TensorDataset, DataLoader
import models
​
if __name__ == '__main__':
    device = torch.device('cpu')
    data = np.load('images.npy')
    labels = np.load('labels.npy')
    data = torch.tensor(data)\
        .float().unsqueeze(1).to(device)
    labels = torch.tensor(labels).float().to(device)
    dataset = TensorDataset(data, labels)
    dataloader = DataLoader(dataset, 1,  shuffle=True)
​
    for f in os.scandir('models'):
        print(f)
        model = load_from_file(f)
        eval_model(model, dataloader)

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

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

def load_from_file(f):
    name_parts = f.name.split('.')[0].split('_')
    parameters = {
        "ch1": int(name_parts[1]),
        'ch2': int(name_parts[2]),
        'ch3': int(name_parts[3]),
        'pool1_size': int(name_parts[4]),
        'pool2_size': int(name_parts[5]),
        'ks1': int(name_parts[6]),
        'ks2': int(name_parts[7])
    }
    model = models.LargeWin(parameters)
    model.load_state_dict(torch.load(
        open(f.path, 'rb'),
        map_location='cpu'
    ))
​
    return model

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

def eval_model(model, dataloader):
    for input, label in dataloader:
        out = model(input)
​
        # Remove example and channels dimension
        img = input.squeeze(0).squeeze(0).detach().numpy()
        out = out[0].detach().numpy()
        pred_sin = out[0]
        pred_cos = out[1]
​
        # Plot the input
        plt.subplot(2, 1, 1)
        plt.pcolor(img, cmap='Wistia')
​
        # Plot the output
        plt.subplot(2, 1, 2)
        plt.plot([-1, 1], [pred_sin, pred_sin])
        plt.plot([pred_cos, pred_cos], [-1, 1])
        plt.gca().set_xticks([-1, 0, 1])
        plt.gca().set_yticks([-1, 0, 1])
        plt.grid()
        plt.gcf().set_size_inches(3, 6)
        plt.show()

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

Также мы наглядно понимаем, что означает итоговый проигрыш. Если у нас после обучения MSE 0,12 — хорошо это или плохо. Или судить, предсказывает ли модель неправильные действительно зашумленные изображения, или она плоха в целом. В конце концов, чтобы увидеть, работает ли он вообще.

4.2 Статистика производительности

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

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

Для начала давайте загрузим логи mlflow. В веб-приложении mlflow нажмите «загрузить csv» и сохраните в каталоге проекта:

И загрузите файл в python с пандами:

import pandas as pd
​
data = pd.read_csv("runs.csv")
data = data[[
    'model_type',
    'Status',
    'ch1', 'ch2', 'ch3',
    'ks1', 'ks2',
    'pool1_size', 'pool2_size'
]]
print(data.shape)

Обратите внимание, что нам не нужны все столбцы, поэтому мы перечислим те, которые будут полезны. Теперь давайте отфильтруем только одну модель для анализа (для начала LargeWin) и модели, которые не вышли из строя (со статусом «завершено»):

data = data[data['Status'] == 'FINISHED']
data = data[data['model_type'] == 'LargeWin']
print(data.shape)

И, наконец, вычислить корреляции:

print(data.corr(method='pearson')['train_loss'])

Что дает следующий вывод (для меня)

train_loss    1.000000
ch1          -0.390550
ch2          -0.252477
ch3          -0.031024
ks1          -0.430135
ks2           0.114280
pool1_size   -0.407319
pool2_size    0.133425

Это говорит нам о том, что чем больше pool1_size, например, мы используем, тем меньше будет убыток. Но если мы возьмем большой pool2_size — потери, как правило, будут больше. Сделаем пару графиков:

import matplotlib.pyplot as plt
​
plt.scatter(data['pool1_size'], data['train_loss'], 
            label="Pool1", alpha=0.2)
plt.scatter(data['ks2'], data['train_loss'], 
            label="Ks2", alpha=0.2)
plt.legend()
plt.show()

Это даст такое изображение:

Мы видим, что модель в среднем работает лучше при меньшем ks2, но наилучший результат был получен при большом ks2, что интересно.

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

for ch1 in [4]:
        for ch2 in [2, 4]:
            for ch3 in [2]:
                for pool1_size in [3, 4]:
                    for pool2_size in [2]:
                        for ks1 in [7]:
                            for ks2 in [5, 7]:
                                run_train_model_large(
                                    ch1, ch2, ch3,
                                    pool1_size,
                                    pool2_size,
                                    ks1, ks2
                                )

Я пока не буду проводить тренировки снова. Сначала нам нужно взглянуть на обученные фильтры, потому что нам может понадобиться обновить процесс обучения.

4.3 Сверточные фильтры

Теперь, когда мы знаем, что наша модель работает, давайте выясним, как она работает. Начнем с извилин. А поскольку свертки состоят из фильтров, простой способ показать их работу — показать изображение после применения каждого фильтра. Например, если у нас есть слой свертки с 4 фильтрами и одним входным изображением канала, мы хотим увидеть 4 изображения: каждый из фильтров применяется к изображению. Если у нас на входе 2 канала — мы получим 8 изображений: каждый из фильтров применяется к каждому из слоев изображения.

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

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

def forward(self, x, out_layer=-1):
        x = self.conv1(x)
        if out_layer == 0:
            return x
        x = self.pool1(x)
        x = torch.tanh(x)
​
        x = self.conv2(x)
        if out_layer == 1:
            return x
        x = self.pool2(x)
        x = torch.tanh(x)
​
        x = x.reshape([x.shape[0], -1])
        x = self.dense1(x)
        x = torch.tanh(x)
        x = self.dense2(x)
​
        return torch.tanh(x)

Теперь мы можем вернуться к сценарию построения и набросать его скелет:

import os
import torch
import numpy as np
import matplotlib.pyplot as plt
from generate_dataset import generate_image
import models
​
np.random.seed(42)
​
img, lbl = generate_image()
t_img = torch.tensor(img).float() \
    .unsqueeze(0).unsqueeze(0)
for f in os.scandir('models'):
    print(f)
    model = models.load_from_file(f)
    out = model(t_img).detach().reshape(-1).numpy()
​
    print(out)
    print(lbl)
    plt.pcolor(img, cmap='Wistia')
    plt.show()
​
    show_weight(model.conv1.weight, model(t_img, 0)[0])
    show_weight(model.conv2.weight, model(t_img, 1)[0])

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

Функция show_weight() принимает в качестве аргументов вес для построения и выходные данные модели после применения веса. Он содержит некоторые команды построения графиков, а также некоторую логику для упорядочивания изображений:

def show_weight(weight, output):
    plot_cnt = 1
    img_width = weight.shape[0]
    img_height = weight.shape[1]
    img_height += 1  # The outputs row
​
    # Plot weights
    for i in range(weight.shape[1]):
        for j in range(weight.shape[0]):
            filter = weight[j, i].detach().numpy()
            plt.subplot(img_height, img_width, plot_cnt)
            plt.pcolor(filter, cmap='Wistia')
            plot_cnt += 1
​
    # Plot outputs - at the bottom
    for j in range(weight.shape[0]):
        plt.subplot(img_height, img_width, plot_cnt)
        out_img = output[j].detach().numpy()
        plt.pcolor(out_img, cmap='Wistia')
        plot_cnt += 1
​
    plt.tight_layout(0.1, 0.1, 0.1)
    plt.gcf().set_size_inches(img_width * 2, img_height * 2)
    plt.show()

Если мы запустим скрипт, мы получим такие изображения:

Для первого слоя (выше) и второго слоя ниже:

В последней строке показаны выходные данные слоя, а в первых строках показаны фильтры, примененные к выходным данным предыдущего слоя. Для первого слоя мы видим какой-то шаблон для фильтра, и его вывод выглядит осмысленным. Но для второго фильтра как веса фильтра, так и выходные данные выглядят как полный шум, что является хорошим признаком переобучения. Чтобы справиться с этим, мы можем использовать некоторые методы регуляризации, такие как регуляризация L2. Для этого в PyTorch нам нужно добавить параметр weight_decay для оптимизатора:

opt = torch.optim.Adam(model.parameters())
# Change to:
opt = torch.optim.Adam(
    model.parameters(), 
    weight_decay=1e-2
)

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

import os
import torch
import mlflow
import models
import numpy as np
from torch.utils.data import TensorDataset, DataLoader
​
def train_model(model, parameters, dataloader):
    opt = torch.optim.Adam(
        model.parameters(),
        weight_decay=1e-2
    )
    mse = torch.nn.MSELoss()
    with mlflow.start_run(run_name="OpenCircleWD"):
        mlflow.log_param('model_type', type(model).__name__)
        for key in parameters:
            mlflow.log_param(key, parameters[key])
        for epoch in range(5001):
            train_loss = 0
            for input, label in dataloader:
                opt.zero_grad()
                out = model(input)
                loss = mse(out, label)
                train_loss += loss
                loss.backward()
                opt.step()
            train_loss = train_loss / len(dataloader)
            if epoch % 10 == 0:
                print(epoch, train_loss.item())
                mlflow.log_metric(
                    'train_loss',
                    train_loss.item(),
                    epoch
                )
        params = list(parameters.values())
        params_string = '_'.join([str(i) for i in params])
        file = open('models/{}_{}_wd.pt'.format(
            type(model).__name__,
            params_string
        ), 'wb')
        torch.save(model.state_dict(), file)
​
if __name__ == '__main__':
    device = torch.device('cuda:0'
        if torch.cuda.is_available()
        else 'cpu')
    data = torch.tensor(np.load('images.npy'))
    labels = torch.tensor(np.load('labels.npy'))
    train_data = data.float().unsqueeze(1).to(device)
    train_labels = labels.float().to(device)
    train_dataset = TensorDataset(train_data, train_labels)
    train_dataloader = DataLoader(train_dataset, 1024)
    print(len(train_dataloader))
​
    os.makedirs('models', exist_ok=True)
​
    def run_train_model_large(ch1, ch2, ch3,
                        pool1_size, pool2_size,
                        ks1, ks2):
        try:
            parameters = {
                "ch1": ch1,
                'ch2': ch2,
                'ch3': ch3,
                'pool1_size': pool1_size,
                'pool2_size': pool2_size,
                'ks1': ks1,
                'ks2': ks2
            }
            model = models.LargeWin(parameters).to(device)
            train_model(model, parameters, train_dataloader)
        except Exception as e:
             print(e)
​
    for ch1 in [4]:
        for ch2 in [2, 4]:
            for ch3 in [2]:
                for pool1_size in [3, 4]:
                    for pool2_size in [2]:
                        for ks1 in [7]:
                            for ks2 in [5, 7]:
                                run_train_model_large(
                                    ch1, ch2, ch3,
                                    pool1_size,
                                    pool2_size,
                                    ks1, ks2
                                )

Через некоторое время мы получаем еще один набор обученных моделей. Для меня, если я визуализирую веса, это выглядит примерно так:

Или для другой модели:

Вот как выглядит второй слой:

Другая модель:

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

5. Разбивка

Этот раздел призван проиллюстрировать, как работают различные части модели.

5.1 Плотные слои

Для иллюстрации возьмем простейшую конфигурацию сети: 2 входа, 2 выхода и 2 скрытых узла. Обратите внимание, что нам не нужно учитывать скрытые узлы. Мы можем построить график только самого вывода и ввода. Так что будет 2 графика, по одному на каждый из выходов. 2 входа будем обозначать как x и y, выход — как цвет. Входные значения варьируются от -1 до 1, так как активация на предыдущем слое — tanh. Вот как это выглядит в коде:

import os
import torch
import numpy as np
import matplotlib.pyplot as plt
import models
​
​
def show_dense(model):
    size = 10
    for channel in range(2):
        plt.subplot(2, 1, channel+1)
        plt.title('Sin' if channel == 0 else 'Cos')
        data = np.zeros((size, size))
        space = np.linspace(-1, 1, size)
        for idx_i, i in enumerate(space):
            for idx_j, j in enumerate(space):
                model_in = torch.tensor([i, j]).float()
                out = model(model_in, in_layer=1)[channel]
                data[idx_j, idx_i] = out.item()
        plt.pcolor(data, cmap='hot', vmin=-2, vmax=2)
        plt.xticks([0, 5, 10], [-1, 0, 1])
        plt.yticks([0, 5, 10], [-1, 0, 1])
    plt.gcf().set_size_inches(5, 10)
    plt.show()
​
if __name__ == '__main__':
    for f in os.scandir('models'):
        if f.name != 'LargeWin_4_2_2_3_2_7_7_wd.pt':
            continue
        print(f)
        model = models.load_from_file(f)
        show_dense(model)

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

Красноватые цвета здесь означают значения ближе к -1, желтоватые к +1. Давайте посмотрим на нижнее (cos) изображение. Если бы входы в сеть были (-1, 1), верхний левый угол, выход был бы -1. Если бы входные данные были (+1, -1) — мы бы появились в правом нижнем углу и вывели бы +1. Для ввода типа (+1, +1) — сеть выдаст небольшое значение, близкое к 0. Конечно, те же соображения применимы и к верхнему графику.

5.2 Общая схема

На приведенной ниже диаграмме кратко показана общая работа сети:

Итак, у нас есть входное изображение, оно сначала проходит через набор из 4 фильтров (Filter 1 на графике или conv1 в нашей сети). Это дает нам 4 отфильтрованных изображения, которые затем проходят через другой набор фильтров Фильтр 2. Поскольку наш слой conv2 дает 2 выходных канала, всего у него 8 фильтров. На графике показаны только 4, принадлежащие первому выходному каналу. Выход этих 4 фильтров суммируется, потому что именно так работает слой свертки. После этого мы получаем изображение 2x2, всего 4 пикселя. Средний пул просто берет среднее значение этих пикселей, что дает одно число. Это число становится одним из входов в плотную сеть.

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

Итак, скажем, наша сеть выводит предсказание «90°» для изображения. Для этого выход cos должен быть близок к «+1», выход sin — к «0». Взгляните еще раз на графики плотной сети выше. Такой результат мы получаем, когда выход свертки близок к (-1, +1).

Чтобы получить вывод «90°», первый набор фильтров (относящийся к выходному каналу 0 и показанный на графике) должен выводить «-1», другой набор — «+1».

У меня после всего этого остался вопрос, как фильтр выведет эти 4 пикселя, которые после усреднения станут решающими «-1» и «+1». Для этого я написал еще один скрипт, иллюстрирующий поведение фильтра. Давайте сначала посмотрим на его вывод:

Он иллюстрирует работу одного из фильтров во втором сверточном слое. У нас есть текстовое описание в верхнем левом углу, оно показывает, какой из 8 фильтров мы рассматриваем. Прямо под ним, с пометкой «Оригинал» — входное изображение до объединения; а тот, что справа («Src»), — это то же изображение после объединения.

Как известно, применить фильтр означает умножить фильтр на части изображения, отмеченные на графике разными цветами. Эти части изображения, умноженные на фильтр, помечены от «Out 1» до «Out 4». После умножения значения каждого «Out» суммируются, образуя всего 4 одиночных значения (или изображение 2x2). Это будет выход для одного фильтра, но для слоя свертки у нас есть четыре фильтра, и их выходы суммируются. Это будет выход одного канала из свертки, обозначенный на графике выше как «Выход» в левом нижнем углу.

Как это связано с нашим примером? Для того, чтобы первый канал выдавал «-1» — все четыре фильтра с «Filter out idx: 0» должны выводить большое отрицательное значение (помните, что у нас есть тангенсная нелинейность, поэтому большое отрицательное значение будет преобразовано до -1). Ну, не все из них, но в основном, в среднем.

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

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

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

Давайте посмотрим на другую операцию фильтра:

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

Теперь предположим, что мы поняли, как формируется матрица «Выход». Среднее значение этой матрицы будет первым входом в нашу плотную сеть. Если вспомнить, как работает плотная сеть, то можно подключить выход фильтров к «Большому выходу» — выходу всей сети. Итак, если все фильтры выдают положительное значение, сеть имеет тенденцию выводить «Большой грех, ноль Cos». Если одни фильтры выдают положительное значение, а другие отрицательное, таким образом, что они компенсируют друг друга — сеть выдаст ноль как для Sin, так и для Cos, чего теоретически никогда не должно быть.

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

5.3 Больше ценностей изнутри

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

def forward(self, x, out_layer=-1):
        x = self.conv1(x)
        if out_layer == 11:
            return x
        x = self.pool1(x)
        x = torch.tanh(x)
        if out_layer == 12:
            return x
​
        x = self.conv2(x)
        if out_layer == 21:
            return x
        x = self.pool2(x)
        if out_layer == 22:
            return x
        x = torch.tanh(x)
​
        x = x.reshape([x.shape[0], -1])
        x = self.dense1(x)
        x = torch.tanh(x)
        x = self.dense2(x)
​
        return torch.tanh(x)

Значение по умолчанию для параметра (-1) — что-то недопустимое, поэтому модель работает от начала до конца, если не указано иное.

5.4 Код иллюстрации

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

import numpy as np
from generate_dataset import generate_image
import matplotlib.pyplot as plt
​
​
​
def show_matrix(arr, ax, title='', text=None):
    arr = arr[::-1]
    ax.set_title(title)
    ax.matshow(arr, cmap='Wistia')
    for i in range(arr.shape[0]):
        for j in range(arr.shape[1]):
            if text is None:
                ax.text(j, i, str(round(arr[i, j], 2)), 
                        va='center', ha='center')
            else:
                ax.text(j, i, text, 
                        va='center', ha='center')
    ax.set_axis_off()
​
    return ax
​
# Setting a seed, so that we generate the same image
np.random.seed(42)
img, lbl = generate_image()
​
fig, ax = plt.subplots(1, 2)
show_matrix(img, ax[0])
show_matrix(img[5:15, 5:15], ax[1])
plt.show()

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

Код должен создать следующее изображение:

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

Затем мы добавляем функцию, которая показывает реальные графики. Всего у нас будет 9 изображений, организованных в сетку 3x3. Начнем с описания, ввода и фильтра.

def illustrate(model, img, filter_in_idx, filter_out_idx):
    filter = model.conv2 \
        .weight[filter_out_idx, filter_in_idx] \
        .detach().numpy()
​
    fig, ax = plt.subplots(3, 3)
    description = f"Filter in idx: {filter_in_idx} \n " \
                  f"Filter out idx: {filter_out_idx} \n "
    show_matrix(np.array([[0]]), ax[0, 0], "", description)
    show_matrix(filter, ax[0, 2], "Filter")
    fig.set_size_inches(10, 10)
    plt.tight_layout()
    plt.show()

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

for filter_out_idx in [0, 1]:
    for filter_in_idx in [0, 1, 2, 3]:
        for f in os.scandir('models'):
            if f.name != 'LargeWin_4_2_2_3_2_7_7_wd.pt':
                continue
            print(f)
            model = models.load_from_file(f)
            illustrate(
                model, img, 
                filter_in_idx, filter_out_idx
            )

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

def illustrate(model, img, filter_in_idx, filter_out_idx):
    filter = model.conv2 \
        .weight[filter_out_idx, filter_in_idx] \
        .detach().numpy()
    img_t = torch.tensor(img).unsqueeze(0).unsqueeze(0).float()
​
    img_orig = model(
        img_t, 
        out_layer=11
    )[0, filter_in_idx].detach().numpy()
    layer_in = model(
        img_t, 
        out_layer=12
    )[0, filter_in_idx].detach().numpy()
    out_nopool = model(
        img_t, 
        out_layer=21
    )[0, filter_out_idx].detach().numpy()
​
    fig, ax = plt.subplots(3, 3)
    description = f"Filter in idx: {filter_in_idx} \n " \
                  f"Filter out idx: {filter_out_idx} \n "
    show_matrix(np.array([[0]]), ax[0, 0], "", description)
    show_matrix(filter, ax[0, 2], "Filter")
    show_matrix(img_orig, ax[1, 0], "Original", '')
    show_matrix(out_nopool, ax[2, 0], "Output")
    show_matrix(layer_in, ax[0, 1], "Src")
    fig.set_size_inches(10, 10)
    plt.tight_layout()
    plt.show()

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

Теперь пришло время добавить функцию свертки. Он примет изображение и фильтр, как обычная свертка. Он разделит входные части изображения размером с размер фильтра (7x7 в нашем случае) и умножит на фильтр.

def convolve(arr, filter):
    shifts_x = arr.shape[0] - filter.shape[0] + 1
    shifts_y = arr.shape[1] - filter.shape[1] + 1
    result = []
    for i in range(shifts_x):
        conv_row = []
        for j in range(shifts_y):
            conv_item = arr[
                        i:i + filter.shape[0], 
                        j:j + filter.shape[1]
                        ]
            conv_row.append(conv_item * filter)
        result.append(conv_row)
    return result

Поскольку вход второго слоя имеет размер 8x8, фильтр будет применен к 4 фрагментам изображения, и функция вернет массив 2x2. Обратите внимание, что «conv_item * filter» вернет матрицу, в которой каждый элемент conv_item умножается на соответствующий элемент фильтра.

Когда функция готова, мы можем отобразить ее значения в «illustrate()»:

def illustrate(model, img, filter_in_idx, filter_out_idx):
    filter = model.conv2 \
        .weight[filter_out_idx, filter_in_idx] \
        .detach().numpy()
    img_t = torch.tensor(img).unsqueeze(0).unsqueeze(0).float()
​
    img_orig = model(
        img_t,
        out_layer=11
    )[0, filter_in_idx].detach().numpy()
    layer_in = model(
        img_t,
        out_layer=12
    )[0, filter_in_idx].detach().numpy()
    out_nopool = model(
        img_t,
        out_layer=21
    )[0, filter_out_idx].detach().numpy()
    conv = convolve(layer_in, filter)
​
    fig, ax = plt.subplots(3, 3)
    description = f"Filter in idx: {filter_in_idx} \n " \
                  f"Filter out idx: {filter_out_idx} \n "
    show_matrix(np.array([[0]]), ax[0, 0], "", description)
    show_matrix(filter, ax[0, 2], "Filter")
    show_matrix(img_orig, ax[1, 0], "Original", '')
    show_matrix(out_nopool, ax[2, 0], "Output")
    show_matrix(layer_in, ax[0, 1], "Src")
    show_matrix(conv[1][0], ax[1, 1], "Out 1")
    show_matrix(conv[1][1], ax[1, 2], "Out 2")
    show_matrix(conv[0][0], ax[2, 1], "Out 3")
    show_matrix(conv[0][1], ax[2, 2], "Out 4")
    fig.set_size_inches(10, 10)
    plt.tight_layout()
    plt.show()

Код выведет что-то вроде этого:

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

5. Реальные данные

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

5.1 Оценка

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

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

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

Давайте напишем скрипт, который сделает это за нас:

import os
import cv2 as cv
import numpy as np
import matplotlib.pyplot as plt
​
if __name__ == '__main__':
    for f in os.scandir('data_real'):
        img = cv.imread(f.path, cv.IMREAD_GRAYSCALE)
        img_size = np.min(img.shape)
        img = img[:img_size, :img_size]
        img = img.astype(float)
        img = cv.resize(img, (30, 30))
        img = img - np.min(img)
        img = img / np.max(img)
        img = 1 - img
​
        plt.pcolor(img, cmap='Wistia')
        plt.show()

Код предполагает, что все изображения, которые мы вырезаем вручную, должны находиться в папке с именем «data_real». OpenCV читает изображения как массив целых чисел со значениями в диапазоне от 0 до 255. Поскольку наша сеть принимает массивы со значениями 0–1, нам также необходимо преобразовать изображения в числа с плавающей запятой и нормализовать их, вычитая минимальное значение из каждого изображения и разделив его на максимальное значение. Также, поскольку на наших изображениях круг черный на белом, а в наборе данных круг белый на черном, мы заканчиваем предварительную обработку строкой img = 1 — img, которая инвертирует изображение.

Вот пример того, что должен выводить код:

Существует некоторое несоответствие между OpenCV и Matplotlib для оси Y, поэтому изображение выглядит перевернутым по вертикали. Это то же изображение, что и на графике выше.

Теперь, имея данные, мы можем преобразовать их в удобный для PyTorch загрузчик данных и запустить их через нашу модель. Чтобы запустить его через модель, мы будем использовать нашу функцию из алгоритма eval, которая принимает модель и загрузчик данных. Кажется, это нормально, за исключением того, что эта функция принимает набор данных, который также включает метки, которых у нас нет для наших реальных данных. Ну это не проблема, мы можем заменить настоящие метки пустым (нулевым) тензором. Вот как выглядит обновленный код:

import os
import torch
import cv2 as cv
import numpy as np
import matplotlib.pyplot as plt
from torch.utils.data import TensorDataset, DataLoader
import models
from eval import eval_model
​
if __name__ == '__main__':
    device = torch.device('cpu')
    data = []
    for f in os.scandir('data_real'):
        print(f.name)
        img = cv.imread(f.path, cv.IMREAD_GRAYSCALE)
        img_size = np.min(img.shape)
        img = img[:img_size, :img_size]
        img = img.astype(float)
        img = cv.resize(img, (30, 30))
        img = img - np.min(img)
        img = img / np.max(img)
        img = 1 - img
​
        # plt.pcolor(img, cmap='Wistia')
        # plt.show()
​
        data.append(img)
    data = torch.tensor(data)\
        .float().unsqueeze(1).to(device)
    dataset = TensorDataset(data, torch.zeros_like(data))
    dataloader = DataLoader(dataset, 1,  shuffle=False)
​
    for f in os.scandir('models'):
        if f.name != 'LargeWin_4_2_2_3_2_7_7_wd.pt':
            continue
        print(f)
        model = models.load_from_file(f)
        eval_model(model, dataloader)
​

При запуске кода у меня сразу не получилось:

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

5.2 Расследование

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

# np.random.seed(42)
# img, lbl = generate_image()
img = cv.imread('data_real/01.png', cv.IMREAD_GRAYSCALE)
img_size = np.min(img.shape)
img = img[:img_size, :img_size]
img = img.astype(float)
img = cv.resize(img, (30, 30))
img = img - np.min(img)
img = img / np.max(img)
img = 1 - img

После запуска кода получил следующий результат:

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

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

На этот раз оценочный код дал гораздо лучший результат:

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

5.6 Исправление

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

Часть, которую я буду менять, — это функция generate_image(). Часть, где масштабируется изображение:

scale_x = np.random.uniform(0.5, 1.1)
    scale_y = np.random.uniform(0.5, 1.1)

Я добавлю возможность масштабирования еще больше, скажем, до 0,2 раза от исходного размера:

scale_x = np.random.uniform(0.2, 1.1)
    scale_y = np.random.uniform(0.2, 1.1)

После этого я решу, насколько я хочу его сдвинуть:

shift_x, shift_y = 0, 0
    shift_thresh = 0.5
    if scale_x < shift_thresh:
        shift_x = np.random.uniform(
            -shift_thresh + scale_x, 
            shift_thresh - scale_x
        )
    if scale_y < shift_thresh:
        shift_y = np.random.uniform(
            -shift_thresh + scale_y, 
            shift_thresh - scale_y
        )

Здесь я инициализирую значение сдвига равным нулю, затем, если мое изображение масштабируется менее чем в 0,5 раза от исходного размера — я изменю значения сдвига. Величина этого изменения будет не более «shift_thresh — scale_y» с обеих сторон. Это гарантирует, что вся информация останется внутри изображения.

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

Итак, вот как выглядит новая функция:

def generate_image():
    n_circle_pts = 350
​
    # Parameters randomization
    angle = np.random.uniform(0, 360)
    open_percent = np.random.uniform(10, 40)
    circle_p1 = np.random.uniform(0.7, 1.)
    circle_p2 = np.random.uniform(0.7, 1.)
    scale_x = np.random.uniform(0.2, 1.1)
    scale_y = np.random.uniform(0.2, 1.1)
    shift_x, shift_y = 0, 0
    shift_thresh = 0.5
    if scale_x < shift_thresh:
        shift_x = np.random.uniform(
            -shift_thresh + scale_x,
            shift_thresh - scale_x
        )
    if scale_y < shift_thresh:
        shift_y = np.random.uniform(
            -shift_thresh + scale_y,
            shift_thresh - scale_y
        )
​
    n_pts_skip = int(n_circle_pts / 100 * open_percent)
    angle_rad = angle / 180 * np.pi
    xs, ys = get_circle(circle_p1, circle_p2, n_circle_pts)
    xs, ys = transform(xs, ys, angle, scale_x, scale_y)
    xs += shift_x
    ys += shift_y
    img = to_image(xs[n_pts_skip // 2:-n_pts_skip // 2],
                   ys[n_pts_skip // 2:-n_pts_skip // 2], 30)
​
    label = [np.sin(angle_rad), np.cos(angle_rad)]
    return img, label

Будьте осторожны, чтобы не переоценить значение «shift_thresh». Если он слишком велик, некоторые изображения не будут содержать необходимой информации, и сеть не сможет их распознать. В моем случае значение 0,8 было слишком большим.

5.7 Результат

После обучения сети я получил значительно лучший результат. Вторые сверточные фильтры стали обращать внимание на центральные пиксели, и все реальные изображения распознавались (более или менее) корректно.

Вот изображение весов после тренировки:

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

Вот что выводит сценарий оценки с недавно обученной моделью:

В целом работой модели я доволен. Думаю, я готов оставить все как есть.

Урто

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

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

Удачи и счастливого кодирования!