Да, офицер, я видел знак ограничения скорости. Я тебя просто не видел.

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

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

Настраивать

Исходный код доступен в этой записной книжке Jupyter. Я использую Python 3.5 и TensorFlow 0.12. Если вы предпочитаете запускать код в Docker, вы можете использовать мой образ Docker, содержащий множество популярных инструментов глубокого обучения. Запустите его с помощью этой команды:

docker run -it -p 8888:8888 -p 6006:6006 -v ~/traffic:/traffic waleedka/modern-deep-learning

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

Поиск данных обучения

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

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

Вы можете скачать набор данных с сайта http://btsd.ethz.ch/shareddata/. На этой странице много наборов данных, но вам понадобятся только два файла, перечисленные в разделе БельгияTS для классификации (обрезанные изображения):

  • БельгияTSC_Training (171,3 МБ)
  • БельгияTSC_Testing (76,5 МБ)

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

/traffic/datasets/BelgiumTS/Training/ /traffic/datasets/BelgiumTS/Testing/

Каждый из двух каталогов содержит 62 подкаталога с последовательными именами от 00000 до 00061. Имена каталогов представляют метки, а изображения внутри каждого каталога являются образцами каждой метки.

Изучение набора данных

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

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

def load_data(data_dir):
    # Get all subdirectories of data_dir. Each represents a label.
    directories = [d for d in os.listdir(data_dir) 
                   if os.path.isdir(os.path.join(data_dir, d))]
    # Loop through the label directories and collect the data in
    # two lists, labels and images.
    labels = []
    images = []
    for d in directories:
        label_dir = os.path.join(data_dir, d)
        file_names = [os.path.join(label_dir, f) 
                      for f in os.listdir(label_dir) 
                      if f.endswith(".ppm")]
        for f in file_names:
            images.append(skimage.data.imread(f))
            labels.append(int(d))
    return images, labels

images, labels = load_data(train_data_dir)

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

После загрузки изображений в массивы Numpy я показываю образец изображения каждой метки. Смотрите код в записной книжке. Это наш набор данных:

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

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

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

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

Обработка изображений разного размера

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

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

И вообще, каковы размеры изображений? Приведем несколько примеров:

for image in images[:5]:
    print("shape: {0}, min: {1}, max: {2}".format(
          image.shape, image.min(), image.max()))
Output:
shape: (141, 142, 3), min: 0, max: 255
shape: (120, 123, 3), min: 0, max: 255
shape: (105, 107, 3), min: 0, max: 255
shape: (94, 105, 3), min: 7, max: 255
shape: (128, 139, 3), min: 0, max: 255

Размеры кажутся примерно 128x128. Я мог бы использовать этот размер, чтобы сохранить как можно больше информации, но на ранней стадии разработки я предпочитаю использовать меньший размер, потому что это приводит к более быстрому обучению, что позволяет мне быстрее выполнять итерацию. Я экспериментировал с 16x16 и 20x20, но они были слишком маленькими. В итоге я выбрал 32x32, который легко распознать (см. Ниже) и уменьшил размер модели и обучающих данных в 16 раз по сравнению с 128x128.

У меня также есть привычка часто печатать значения min () и max (). Это простой способ проверить диапазон данных и на раннем этапе выявить ошибки. Это говорит мне о том, что цвета изображения - стандартный диапазон от 0 до 255.

Минимальная жизнеспособная модель

Мы переходим к самому интересному! Продолжая тему простоты, я начал с простейшей из возможных моделей: однослойной сети, состоящей из одного нейрона на метку.

Эта сеть состоит из 62 нейронов, и каждый нейрон принимает значения RGB всех пикселей в качестве входных данных. Фактически каждый нейрон получает 32 * 32 * 3 = 3072 входа. Это полностью связанный слой, потому что каждый нейрон подключается к каждому входному значению. Вы, наверное, знакомы с его уравнением:

y = xW + b

Я начинаю с простой модели, потому что ее легко объяснить, легко отладить и быстро обучить. Как только это сработает от начала до конца, расширить его будет намного проще, чем построить что-то сложное с самого начала.

Построение графа TensorFlow

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

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

Сначала я создаю объект Graph. В TensorFlow есть глобальный график по умолчанию, но я не рекомендую его использовать. Глобальные переменные вообще плохи, потому что они слишком упрощают внесение ошибок. Я предпочитаю создавать график явно.

graph = tf.Graph()

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

with graph.as_default():
    images_ph = tf.placeholder(tf.float32, [None, 32, 32, 3])
    labels_ph = tf.placeholder(tf.int32, [None])

Форма заполнителя images_ph: [Нет, 32, 32, 3]. Это означает [размер пакета, высота, ширина, каналы] (часто сокращается как NHWC) . Значение Нет для размера пакета означает, что пакет size является гибким, что означает, что мы можем загружать в модель партии разных размеров без изменения кода. Обратите внимание на порядок ваших входных данных, потому что в некоторых моделях и фреймворках может использоваться другое расположение, например NCHW.

Затем я определяю полностью связанный слой. Вместо того, чтобы реализовывать исходное уравнение y = xW + b, я использую удобную функцию, которая делает это в одной строке, а также применяет функцию активации. Однако он ожидает ввода как одномерного вектора. Поэтому сначала я выравниваю изображения.

Я использую функцию активации ReLU здесь:

f(x) = max(0, x)

Он просто преобразует все отрицательные значения в нули. Было показано, что он хорошо работает в задачах классификации и тренируется быстрее, чем сигмовидная или tanh. Чтобы узнать больше, отметьте здесь и здесь.

# Flatten input from: [None, height, width, channels]
# To: [None, height * width * channels] == [None, 3072]
images_flat = tf.contrib.layers.flatten(images_ph)
# Fully connected layer. 
# Generates logits of size [None, 62]
logits = tf.contrib.layers.fully_connected(images_flat, 62,
    tf.nn.relu)

На выходе полносвязного слоя получается вектор логитов длиной 62 (технически это [Нет, 62], потому что мы имеем дело с партией векторов логитов).

Строка в тензоре логитов может выглядеть так: [0.3, 0, 0, 1.2, 2.1, .01, 0.4,… .., 0, 0]. Чем выше значение, тем больше вероятность, что изображение представляет эту метку. Однако логиты не являются вероятностями - они могут иметь любое значение, и их сумма не равна 1. Фактические абсолютные значения логитов не важны, только их значения относительно друг друга. При необходимости легко преобразовать логиты в вероятности с помощью функции softmax (здесь она не нужна).

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

# Convert logits to label indexes.
# Shape [None], which is a 1D vector of length == batch_size.
predicted_labels = tf.argmax(logits, 1)

На выходе argmax будут целые числа от 0 до 61.

Функция потерь и градиентный спуск

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

Кросс-энтропия - это мера разницы между двумя векторами вероятностей. Итак, нам нужно преобразовать метки и логиты в векторы вероятности. Функция sparse_softmax_cross_entropy_with_logits () упрощает это. Он берет сгенерированные логиты и метки Groundtruth и выполняет три функции: преобразует индексы меток формы [None] в логиты формы [None, 62] (однозначно векторов), затем он запускает softmax, чтобы преобразовать логиты прогнозирования и логиты меток в вероятности и, наконец, вычислить перекрестную энтропию между ними. Это создает вектор потерь формы [None] (длина 1D = размер пакета), который мы передаем через reduce_mean (), чтобы получить одно число, представляющее значение потерь. .

loss = tf.reduce_mean(
        tf.nn.sparse_softmax_cross_entropy_with_logits(
            logits, labels_ph))

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

train = tf.train.AdamOptimizer(learning_rate=0.001).minimize(loss)

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

init = tf.initialize_all_variables()

Обратите внимание, что приведенный выше код еще не выполняет ни одной операции. Он просто строит график и описывает его входные данные. Переменные, которые мы определили выше, такие как init, loss, predicted_labels, не содержат числовых значений. Это отсылки к операциям, которые мы выполним дальше.

Цикл обучения

Здесь мы итеративно обучаем модель, чтобы минимизировать функцию потерь. Однако прежде чем мы начнем обучение, нам нужно создать объект Session.

Ранее я упоминал об объекте Graph и о том, как он содержит все операции модели. Сеанс, с другой стороны, содержит значения всех переменных. Если график содержит уравнение y = xW + b, то сеанс содержит фактические значения этих переменных.

session = tf.Session(graph=graph)

Обычно после запуска сеанса первым делом выполняется операция инициализации init для инициализации переменных.

session.run(init)

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

for i in range(201):
    _, loss_value = session.run(
        [train, loss], 
        feed_dict={images_ph: images_a, labels_ph: labels_a})
    if i % 10 == 0:
        print("Loss: ", loss_value)

Если вам интересно, я установил цикл на 201, чтобы условие i% 10 выполнялось в последнем раунде, и выводил последнее значение потерь. Результат должен выглядеть примерно так:

Loss:  4.2588
Loss:  2.88972
Loss:  2.42234
Loss:  2.20074
Loss:  2.06985
Loss:  1.98126
Loss:  1.91674
Loss:  1.86652
Loss:  1.82595
...

Использование модели

Теперь у нас есть обученная модель в памяти в объекте Session. Чтобы использовать его, мы вызываем session.run () точно так же, как в обучающем коде. Операция predicted_labels возвращает результат функции argmax (), так что это то, что нам нужно запустить. Здесь я классифицирую 10 случайных изображений и распечатываю как прогнозы, так и метки наземной истины для сравнения.

# Pick 10 random images
sample_indexes = random.sample(range(len(images32)), 10)
sample_images = [images32[i] for i in sample_indexes]
sample_labels = [labels[i] for i in sample_indexes]
# Run the "predicted_labels" op.
predicted = session.run(predicted_labels,
                        {images_ph: sample_images})
print(sample_labels)
print(predicted)

Output:
[15, 22, 61, 44, 32, 22, 57, 38, 56, 38]
[14  22  61  44  32  22  56  38  56  38]

В блокноте я также включил функцию для визуализации результатов. Он генерирует что-то вроде этого:

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

Оценка

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

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

# Run predictions against the full test set.
predicted = session.run(predicted_labels, 
                        feed_dict={images_ph: test_images32})
# Calculate how many matches we got.
match_count = sum([int(y == y_) 
                   for y, y_ in zip(test_labels, predicted)])
accuracy = match_count / len(test_labels)
print("Accuracy: {:.3f}".format(accuracy))

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

Закрытие сеанса

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

# Close the session. This will destroy the trained model.
session.close()