В качестве программы выходного дня мне захотелось поиграться с как бы «нейронной» сетью (спойлер — в ней нет нейронов). А чтобы потом не было мучительно больно за бесцельно прожитые
годы часы, я подумал, что зря мы его кормим, пусть пользу приносит — пусть заодно эта сетка разберет домашний фотоархив и хотя бы разложит фотографии цветов в отдельную папку.
Самая простая сеть
Самая простая сеть нашлась в статье "
Нейросеть в 11 строчек на Python" (это перевод от
SLY_G статьи "
A Neural Network in 11 lines of Python (Part 1)", вообще у автора есть еще продолжение "
A Neural Network in 13 lines of Python (Part 2 — Gradient Descent)", но здесь достаточно первой статьи).
Краткое описание сетки — в этой сети есть ровно одна зависимость —
NumPy.
Множество входов рассматривается как матрица
, множество выходов — как вектор
. В оригинальной статье сеть умножает входную матрицу, размерностью (4 x 3), на матрицу весов входов
(3 x 4), к произведению применяет передаточную функцию, и получает матрицу слоя
(4 x 4).
Далее слой
умножается на матрицу весов выходов
(4 x 1), также пропускается через функцию, и получается слой
(4 x 1), который и есть результат работы сети.
Итого, опуская скалярную передаточную функцию, сеть реализует два матричных умножения:
Следствием этого, согласно правилам матричного умножения, получилось, что одна из размерностей в ходе работы сети не изменяется
и получить на выходе единственное число невозможно.
Поэтому я немного доработал код из статьи, добавил транспонирование после умножения и работу с произвольным числом слоев в сетке. Это дало мне возможность получать любое сочетание размерностей входов и выходов.
Например, если нужно, чтобы было на входе матрица (3 x 4), а выход — единственное число, то добавляем две матрицы синапсов (4 x 1) и (3 x 1):
Или, скажем, можно преобразовать входную матрицу (10 x 8) на выход (4 x 5):
Получившийся код:
nnmat.py
import numpy as np def nonlin(x,deriv=False): if(deriv==True): return (x)*(1-(x)) return 1/(1+np.exp(-x)) def fmax(x,deriv=False): if(deriv==True): return 0.33 return np.maximum(x,0)/3 class NN: def __init__(self, shapes, func=nonlin): self.func = func self.shapes = shapes self.syns = [ 2*np.random.random((shapes[i-1][1],shapes[i][0])) - 1 for i in range(1, len(shapes)) ] self.layers = [ np.zeros(shapes[i]) for i in range(1, len(shapes)) ] def learn(self, X, y, cycles): for j in range(cycles): res = self.calc(X) prev = y - res for i in range(len(self.layers)-1,-1,-1): l_delta = (prev*self.func(self.layers[i], True)).T if i == 0: self.syns[i] += X.T.dot(l_delta) else: prev = l_delta.dot(self.syns[i].T) self.syns[i] += self.layers[i-1].T.dot(l_delta) return self.layers[-1] def calc(self,X): for i in range(len(self.syns)): if i == 0: self.layers[i] = self.func(np.dot(X,self.syns[i])).T else: self.layers[i] = self.func(np.dot(self.layers[i-1],self.syns[i])).T return self.layers[-1] if __name__ == '__main__': X = np.array([ [0,0,1],[0,1,1],[1,0,1],[1,1,1] ]) y = np.array([[0,1,1,0]]) print('X =',X) print('y =',y) nn = NN((X.shape, (y.shape[1], X.shape[0]), y.shape)) nn.learn(X,y,1000) print('Result =',nn.calc(X).round(2))
Результат работы:
X = [[0 0 1] [0 1 1] [1 0 1] [1 1 1]] y = [[0 1 1 0]] Result = [[ 0.02 0.99 0.98 0.02]]
Загрузка фотографий
Так, сетка есть, теперь надо разобраться с загрузкой фоток. Фотографии лежат на диске, в основном в JPG, но встречаются и другие форматы. Размеры у них тоже разные, смотря чем снимали и как обрабатывали, от 3 Mpx до 16 Mpx. Сначала я попробовал загружать фотографии через Qt, класс QImage, он умеет работать с разными форматами, обеспечивает конверсию и дает прямой доступ к данным картинки. Наверняка в Python существует способ проще, но зато с QImage мне не надо было разбираться. Чтобы сеть могла работать с картинкой, следует перевести в монохромное изображение и уменьшить до стандартного размера.
def readImage(file, imageSize): img = QImage(file) if img.isNull(): return 0 img = img.convertToFormat(QImage.Format_Grayscale8) img = img.scaled(imageSize[0],imageSize[1],Qt.IgnoreAspectRatio) return img
Для передачи в сетку нужно преобразовать изображение в матрицу numpy.ndarray. QImage.bits() дает указатель на данные изображения, где каждый байт соответствует пикселу. В NumPy нашлась функция recarray, способная сделать массив записей из буфера, а у него есть метод view, который нам сделает матрицу numpy.ndarray без копирования данных.
srcBi = img.bits() srcBi.setsize(img.width() * img.height()) srcBy = bytes(srcBi) srcW, srcH = img.width(), img.height() srcArr = np.recarray((srcH, srcW), dtype=np.int8, buf=srcBy).view(dtype=np.byte,type=np.ndarray)
Сеть для изображений
Картинку, хоть и уменьшенную, непосредственно подавать на вход сети будет слишком накладно — я уже говорил, что сеть делает матричное умножение, поэтому даже один цикл обучения будет приводить к 400x400x400 = 64 млн. умножений. Знатоки рекомендуют использовать
свертку. В Википедии есть замечательная иллюстрация ее работы:
На этой анимации видно, что размерность результата равна размерности исходной матрицы. Но я немного упрощу себе жизнь, буду двигаться не по пикселам, а разобью изображение на кусочки размером равным матрице входов, и применю сетку к ним поочередно. В матрицах вырезание кусочка делается достаточно просто:
srcArr[x:x+dw, y:y+dw]
Результат обработки кусочков сетью складывается в матрицу меньшего размера, эта матрица передается на вход общей сети. То есть будет две сети — первая работает с кусочками изображения, вторая — с результатом работы первой сети над кусочками.
Создание первичной сети:
class ImgNN: def __init__(self, shape, resultShape = (16, 16), imageSize = (400,400)): self.resultShape = resultShape self.w = imageSize[0] // shape[0] self.h = imageSize[1] // shape[1] self.net = NN([shape, (1,shape[0]), (1,1)]) self.shape = shape self.imageSize = imageSize
Внутри создается self.net — собственно сеть, с заданным размером матрицы входов shape и c выходом в виде элементарной матрицы 1х1. Да, можно было наследоваться от класса сети NN, но был выходной, хотелось побыстрее получить результат, а архитектура еще не устоялась. Time to market бьется в наших сердцах!
Обсчет изображения первой сетью:
def calc(self, srcArr): w = srcArr.shape[0] // self.shape[0] h = srcArr.shape[1] // self.shape[1] resArr = np.zeros(self.resultShape) for x in range(w): for y in range(h): a = srcArr[x:x+self.shape[0], y:y+self.shape[1]] if a.shape != (self.shape[0], self.shape[1]): continue if x >= self.resultShape[0] or y >= self.resultShape[1]: continue res = self.nn.calc(a) resArr[x,y] = res[0,0] return resArr
На выходе имеем матрицу resArr, с размерностью, равной количеству кусочков, на которые было разбито изображение. Эту матрицу передаем на вход второй сети, которая даcт конечный результат.
y = np.array([[1,0,1,0]]) firstShape = (40, 40) middleShape = (5, 5) imageSize = firstShape[0]*middleShape[0], firstShape[1]*middleShape[1] ... nn = ImgNN(firstShape, resultShape=middleShape, imageSize=imageSize) nn2 = NN([middleShape, (y.shape[1], middleShape[0]), y.shape]) ... i = readImage(f, imageSize) mid = nn.calc(i) res = nn2.calc(mid)
Тут вы должны меня спросить, откуда я взял первую строчку, и что она значит:
y = np.array([[1,0,1,0]])
Это — ожидаемый результат сети в случае положительного ответа, т.е. если сеть считает, что на входе изображение цветка. Размерность выбрал из принципа «ни мало, ни много» — если брать размерность 1х1, то из одного получившегося числа трудно судить, насколько сеть «сомневается» в результате. Большую размерность задавать тоже смысла нет — она не даст больше информации. Равное количество нулей и единиц дает четкий ориентир — чем ближе к нему, тем больше совпадение. Если же взять все единицы или все нули, то у сети появится стимул к переобучению — увеличить все сомножители или, соответственно, обнулить их, чтобы получать нужный результат независимо от входных данных.
Как обучать сверточную сеть?
Обучающую выборку я сделал из своих же фотографий, попросту разложив их в два каталога:
flowers и
noflowers Пути к картинкам соберу в два массива
import os fl = [e.path for e in os.scandir('flowers')] nofl = [e.path for e in os.scandir('noflowers')] all = fl+nofl
Обучать простые сети обычно, в том числе в оригинальной статье, предлагается традиционным методом —
обратным распространением ошибки. Но чтобы этот метод применить к сверточной сети, состоящей из двух элементарных, нужно обеспечить сквозную передачу накопленной ошибки из второй сети в первую. Вообще для сверточных сетей есть и
другие методы. Переделывать работающую сеть мне было лень, по крайней мере пока, поэтому решил обучить вторую сеть, а первую вообще не обучать, оставить забитой при создании случайными значениями, рассудив, что раз глазные нервы у человека не обучаются, то и мне нечего обучать первичную сеть, «смотрящую» на изображение.
for epoch in range(100): print('Epoch =', epoch) nn = ImgNN(firstShape, resultShape=middleShape, imageSize=imageSize) nn2 = NN([middleShape, (y.shape[1], middleShape[0]), y.shape]) for f in fl: i = readImage(f, imageSize) # nn.learn(i, yy, 1) mid = nn.calc(i) nn2.learn(mid, y, 1000)
В каждой эпохе сразу после обучения прогоняю через сеть всю выборку и смотрю, что получилось.
for f in all: i = readImage(f, imageSize) mid = nn.calc(i) res = nn2.calc(mid) delta = abs(y-res) v = round(np.std(delta),3)
Если сеть обучилась правильно, то на ее выходе должно быть значение, близкое к заданному [[1,0,1,0]], если на входе цветок, и как можно более отличающееся от заданного, например [[0,1,0,1]], если на входе не цветок. Результат оценивается, эмпирически я принял отклонение от успешного результат не более 0,2 — это тоже успешный результат, и считается число ошибок. Из всех прогонов выбираем такую, где делается меньше всего ошибок, и сохраняем веса синапсов обоих сеток в файлы. Дальше эти файлы можно использовать для загрузки сеток.
if v > 0.2 and f in fl: fails += 1 failFiles.append(f) elif v<0.2 and f in nofl: fails +=1 failFiles.append(f) if minFails == None or fails < minFails: minFails = fails lastSyns = nn.net.syns lastSyns2 = nn2.syns print('fails =',fails, failFiles) print('min =',minFails) if minFails <= 1: print('found!') break for i in range(len(lastSyns)): np.savetxt('syns_save%s.txt'%i, lastSyns[i]) for i in range(len(lastSyns2)): np.savetxt('syns2_save%s.txt'%i, lastSyns2[i])
Хоть розой назови её, хоть нет
С надеждой запускаю и… подождав..., потом еще подождав..., и еще… получаю полный бред — сетка не обучается:
Ничего не вышлоflowers178.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
flowers179.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.24
flowers180.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
flowers182.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.24
flowers186-2.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
flowers186.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.24
flowers187.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.24
flowers190 (2).jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.24
flowers190.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
flowers191.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.24
flowers195.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
flowers199.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.24
flowers2.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
flowers200.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
noflowers32.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
noflowers85.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.24
noflowers88.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
noflowers122.JPG res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
noflowers123.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
noflowers173.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.24
noflowers202.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
noflowers205.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
noflowerscutxml.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.241
noflowersGetaway.jpg res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.24
noflowersIMGP1800.JPG res = [[ 0.98 0.5 0.98 0.5 ]] v = 0.24
noflowers rq-4.png res = [[ 0.97 0.51 0.97 0.51]] v = 0.239
fails = 14
Будучи носителем настоящих живых, а не искусственных нейронов, до меня дошло, что главным отличием
цветов является
цвет (да, кэп, спасибо, что ты всегда рядом, хотя зачастую опаздываешь со своими советами). Поэтому надо бы перевести его в какую-то цветовую модель, где цветовая составляющая будет выделена (HSV или HSL), и обучать сеть на цвете.
Но оказалось, что класс
QImage не знает такие цветовые пространства. Пришлось отказаться от него и загружать фотки с помощью OpenCV, где такая возможность есть.
import cv2 def readImageCV(file, imageSize): img = cv2.imread(file) small = cv2.resize(img, imageSize) hsv = cv2.cvtColor(small, cv2.COLOR_BGR2HSV) return hsv[:,:,0]/255
Правда, OpenCV наотрез отказался работать с русскими буквами в именах файлов, пришлось их переименовать.
Запустил — результат не порадовал, практически тот же.
Еще подумал, решил, что проблема в сильно случайных значениях в первой сетке, зря я понадеялся, что звезды сойдутся без моей помощи, поэтому добавил ей небольшое предобучение, всего 2 цикла на файл. Для образца положительного результата взял единичную матрицу.
yy = np.zeros(middleShape) np.fill_diagonal(yy,1) ... for f in fl: i = readImage(f, imageSize) nn.learn(i, yy, 2) # чуть-чуть обучаем первую сетку mid = nn.calc(i) nn2.learn(mid, y, 1000)
Снова запустил — стало куда интереснее, цифры стали меняться, хотя идеала не достиг.
Лучший результатEpoch = 34
flowers178.jpg res = [[ 0.86 0.47 0.88 0.47]] v = 0.171
flowers179.jpg res = [[ 0.87 0.51 0.89 0.5 ]] v = 0.194
flowers180.jpg res = [[ 0.79 0.69 0.79 0.67]] v = 0.233
flowers182.jpg res = [[ 0.87 0.53 0.88 0.48]] v = 0.189
flowers186-2.jpg res = [[ 0.89 0.41 0.89 0.39]] v = 0.144
flowers186.jpg res = [[ 0.85 0.54 0.83 0.55]] v = 0.194
flowers187.jpg res = [[ 0.86 0.54 0.86 0.54]] v = 0.199
flowers190 (2).jpg res = [[ 0.96 0.25 0.97 0.15]] v = 0.089
flowers190.jpg res = [[ 0.95 0.13 0.97 0.14]] v = 0.048
flowers191.jpg res = [[ 0.81 0.57 0.82 0.57]] v = 0.195
flowers195.jpg res = [[ 0.81 0.55 0.79 0.56]] v = 0.177
flowers199.jpg res = [[ 0.89 0.45 0.89 0.45]] v = 0.171
flowers2.jpg res = [[ 0.83 0.56 0.83 0.55]] v = 0.195
flowers200.jpg res = [[ 0.91 0.42 0.89 0.43]] v = 0.163
noflowers32.jpg res = [[ 0.7 0.79 0.69 0.8 ]] v = 0.246
noflowers85.jpg res = [[ 0.86 0.53 0.86 0.53]] v = 0.192
noflowers88.jpg res = [[ 0.86 0.56 0.87 0.53]] v = 0.207
noflowers122.JPG res = [[ 0.81 0.63 0.81 0.62]] v = 0.218
noflowers123.jpg res = [[ 0.83 0.59 0.84 0.55]] v = 0.204
noflowers173.jpg res = [[ 0.83 0.6 0.83 0.58]] v = 0.209
noflowers202.jpg res = [[ 0.78 0.7 0.8 0.65]] v = 0.234
noflowers205.jpg res = [[ 0.84 0.77 0.79 0.75]] v = 0.287
noflowerscutxml.jpg res = [[ 0.81 0.61 0.81 0.63]] v = 0.213
noflowersGetaway.jpg res = [[ 0.85 0.56 0.85 0.55]] v = 0.202
noflowersIMGP1800.JPG res = [[ 0.85 0.55 0.86 0.54]] v = 0.199
noflowers rq-4.png res = [[ 0.7 0.72 0.7 0.71]] v = 0.208
fails = 3 ['flowers180.jpg', 'noflowers 85.jpg', 'noflowersIMGP1800.JPG']
min = 3
Дальше… А дальше выходной закончился, и мне пора было заниматься хозработами.
Что делать дальше?
Конечно, и эта сеть, и то, как я ее учил, и тестовый dataset очень мало соотносятся с реальными сетями и тем, чем занимаются data scientists. Это лишь игрушка для гимнастики ума, не возлагайте на нее больших надежд. Можно наметить дальнейшие шаги, как добиться нужного результата (если он вам нужен):
- Добавить еще один промежуточный слой или несколько во вторую сеть — так у нее появится больше свободы в обучении. Все-таки сеть на матричном умножении не совсем классическая, так как в ней меньше связей-синапсов между слоями, да и сами синапсы не уникальны.
- Использовать приближения к успешным результатам как заготовки для последующих обучений — т.е. запоминать веса синапсов самого успешного результата, а не затиратьвсе случайными значениями.
- Попробовать генетические алгоритмы — смешивать и делить, размножать успешное и отбраковывать неудачное.
- Пробовать другие способы обучения, коих уже вагон и маленькая тележка.
- Использовать больше информации из исходного изображения, например, одновременно цвет и монохром подавать на различные сети, результаты обрабатывать в общей сети.
Исходный код
import numpy as np from nnmat import * import os import sys from PyQt5.QtGui import * from PyQt5.QtCore import * import meshandler import random import cv2 class ImgNN: def __init__(self, shape, resultShape = (16, 16), imageSize = (400,400)): self.resultShape = resultShape self.w = imageSize[0] // shape[0] self.h = imageSize[1] // shape[1] self.net = NN([shape, (1,shape[0]), (1,1)]) self.shape = shape self.imageSize = imageSize def learn(self, srcArr, result, cycles): for c in range(cycles): for x in range(self.w): for y in range(self.h): a = srcArr[x:x+self.shape[0], y:y+self.shape[1]] if a.shape != (self.shape[0], self.shape[1]): print(a.shape) continue self.net.learn(a, result[x,y], 1) def calc(self, srcArr): resArr = np.zeros(self.resultShape) for x in range(self.w): for y in range(self.h): a = srcArr[x:x+self.shape[0], y:y+self.shape[1]] if a.shape != (self.shape[0], self.shape[1]): continue if x >= self.resultShape[0] or y >= self.resultShape[1]: continue res = self.net.calc(a) resArr[x,y] = res[0,0] return resArr def learnFile(self, file, result, cycles): return self.learn(readImage(file, self.imageSize), result, cycles) def calcFile(self, file): return self.calc(readImage(file, self.imageSize)) def readImageCV(file, imageSize): img = cv2.imread(file) small = cv2.resize(img, imageSize) hsv = cv2.cvtColor(small, cv2.COLOR_BGR2HSV) return hsv[:,:,0]/255 def readImageQ(file, imageSize): img = QImage(file) if img.isNull(): return 0 img = img.convertToFormat(QImage.Format_Grayscale8) img = img.scaled(imageSize[0],imageSize[1],Qt.IgnoreAspectRatio) srcBi = img.bits() srcBi.setsize(img.width() * img.height()) srcBy = bytes(srcBi) srcW, srcH = img.width(), img.height() srcArr = np.recarray((srcH, srcW), dtype=np.uint8, buf=srcBy).view(dtype=np.uint8,type=np.ndarray) return srcArr/255 if __name__ == '__main__': readImage = readImageCV y = np.array([[1,0,1,0]]) firstShape = (40, 40) middleShape = (10, 10) imageSize = firstShape[0]*middleShape[0], firstShape[1]*middleShape[1] StartLearn = True if not StartLearn: pictDir = '2014-05' nn = ImgNN(firstShape, resultShape=middleShape, imageSize=imageSize) nn.net.syns[0] = np.loadtxt('syns_save0.txt') nn.net.syns[1] = np.loadtxt('syns_save1.txt') nn2 = NN([middleShape, (y.shape[1], middleShape[0]), y.shape]) nn2.syns[0] = np.loadtxt('syns2_save0.txt') nn2.syns[1] = np.loadtxt('syns2_save1.txt') files = [e.path for e in os.scandir(pictDir)] for f in files: i = readImage(f, imageSize) res = nn2.calc(i) delta = y-res v = round(np.std(delta),3) if v < 0.2: print('Flower',f) else: print('No flower',f) else: fl = [e.path for e in os.scandir('flowers')] nofl = [e.path for e in os.scandir('noflowers')] all = fl+nofl yy = np.zeros(middleShape) np.fill_diagonal(yy,1) minFails = None for epoch in range(100): print('Epoch =', epoch) nn = ImgNN(firstShape, resultShape=middleShape, imageSize=imageSize) nn2 = NN([middleShape, (y.shape[1], middleShape[0]), y.shape]) for f in fl: i = readImage(f, imageSize) nn.learn(i, yy, 2) mid = nn.calc(i) nn2.learn(mid, y, 1000) fails = 0 failFiles = [] for f in all: i = readImage(f, imageSize) mid = nn.calc(i) res = nn2.calc(mid) delta = abs(y-res) v = round(np.std(delta),3) #v = round(delta.sum(),3) print(f, 'res = ', res.round(2),'v =',v) if v > 0.2 and f in fl: fails += 1 failFiles.append(f) elif v<0.2 and f in nofl: fails +=1 failFiles.append(f) if minFails == None or fails < minFails: minFails = fails lastSyns = nn.net.syns lastSyns2 = nn2.syns print('fails =',fails, failFiles) print('min =',minFails) if minFails <= 1: print('found!') break for i in range(len(lastSyns)): np.savetxt('syns_save%s.txt'%i, lastSyns[i]) for i in range(len(lastSyns2)): np.savetxt('syns2_save%s.txt'%i, lastSyns2[i])