Pull to refresh

Простой классификатор на PyBrain и PyQt4 (Python3)

Reading time 12 min
Views 34K
Изучая Python3, я портировал (как смог) библиотечку PyBrain. Об этом я уже писал здесь.
image
Теперь же я хочу немного «поиграть» с данной библиотечкой. Как я уже говорил в предыдущем посте, питон я только начал изучать, так что все написанное в этой статье не стоит воспринимать как Истину. Изучение — это путь, и он извилист.

Задачу поставим перед искусственной нейронной сетью (ИНС) весьма простую — классификацию, а именно: распознавание букв латинского алфавита.

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

Под хабракатом вас ждёт: описание способа подготовки данных на PyQt4, использование модуля argparse, ну и конечно же PyBrain!


Почитав статьи здесь на хабре и не только, понимаешь, что сложно не написать/создать/спроектировать ИНС, а подготовить для неё набор обучающих и тестовых данных. Поэтому наша задача разбивается на две подзадачи:
  • подготовить данные для обучения;
  • спроектировать и обучить ИНС.

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

Подготовка данных


Техническое задание

Давайте уточним задание: размер картинки с изображением буквы будет, скажем, 64 на 64 пиксела (итого 4096 входа у ИНС).
Для этого нам потребуется написать генератор этих картинок. И писать его мы будем, естесственно, на python'е.
Данные для обучения будут включать в себя:
  • буквы латиницы в нижнем регистре
  • буквы латиницы в верхнем регистре
  • буквы могут быть разного размера (опционально)
  • начертание может быть различным (могут использоваться разные шрифты)

Исходя из этого напишем генератор, которому на вход подаются параметры:
  • список букв, например, abc или f-x(диапазон)
  • размер шрифта, например, 40
  • используемый шрифт
  • путь к папке, куда будут складываться сгенерированные изображения


Поиск метода работы с изображениями

Для написания генератора нам понадобится информация по методам обработки изображений в python'е. Google нам помог не сильно. Предложил использовать либо Python Imaging Library — сокращённо PIL, либо PyGame.
Только вот незадача. Первый — только для python2, и последний релиз был в 2009 году. Хотя на github.com есть его форк под третий питон. Почитав мануал я понял, что не все так просто.
PyGame — вариант более интересный, даже мануал его почитал подольше. Понял, что надо конкретно разбираться в библиотеке, а с наскока что-то сделать не получится. Да и использовать микроскоп для забивания гвоздей — тоже не вариант. Не для того эта библиотека предназначена.
Погуглил ещё. Есть ещё pythonmagick, но он только для UNIX-like систем. И тут меня осенило! PyQt4!
C Qt4 я неплохо знаком, на С++/Qt много писал. Да библиотечка эта — как швейцарский нож. Хочешь — бутылку пива откроет, хочешь — из куска деревяшки красивую фигурку вырежет. Главное — уметь ножом пользоваться. На Qt и остановимся.
Поиск по хабру дал нам совсем немного информации по PyQt. Ну да ничего — разберёмся.

Пишем генерацию и сохранение изображения

Первое, что требуется — это установить PyQt4. С этим, я надеюсь, читатель справится — останавливаться на этом не буду. Перейду сразу к использованию.
Сделаем импорт необходимых модулей, и приготовим «рыбу» для программы на PyQt4.
#!/usr/bin/env python3

import sys
from PyQt4.QtGui import *
from PyQt4.Qt import *

def main():
	app = QApplication([])
	# some code here

if __name__ == "__main__":
    sys.exit(main())

Строка с app = QApplication([]) очень важна. Не забывайте её. У меня без неё python вылетает с SIGFAULT'ом и не выдает никаких предупреждений и ошибок.

Теперь займемся наполнением «рыбы» рабочей логикой. Добавим функцию save, которая будет сохранять картинку с заданными параметрами.
def save(png_file, letter = 'A', font = "Arial", size = 40, align = Qt.AlignCenter):
	img = QImage(64,64, QImage.Format_RGB32)
	img.fill(Qt.white)
	p = QPainter(img)
	p.setPen(Qt.black)
	p.setFont(QFont(font,size))
	p.drawText(img.rect(), align, letter)
	p.end()
	img.save(png_file)


С параметрами функции — всё ясно. А вот по содержимому поясню.
Сначала создается объект класса QImage, который позволяет создавать/обрабатывать свои изображения у себя в программе. Очень мощный и гибкий инструмент. Производится закраска белым цветом всего изображения размером 64 на 64 пиксела.
Затем создается объект типа QPainter, которому передается ссылка img. Этот класс позволяет рисовать на контексте устройства или, если точнее, на канве любого класса, унаследованного от QPaintDevice. А как раз таким классом и явлется QImage.
Устанавливаем черное перо, шрифт и рисуем буковку. По умолчанию — размером 40 (что почти занимает всё поле изображения) и с размещением по центру.
Ну а затем сохраняем изображение в файл. Всё просто и очевидно.

Последние штрихи

Осталось малое. Разбор параметров командной строки.
Это можно делать в лоб (с кучей if, либо жёстко задав формат входных данных), а можно с использованием всяких продвинутых модулей типа getopt или argparse. Последний, думаю, мы и изучим.
Программа наша на вход будет получать следующие параметры: шрифт, размер шрифта и директория, куда будут сваливаться готовые картинки.
Выравнивание пока оставим до лучших времен.
Чтение этого мануала как бы подсказывает нам, что надо просто использовать вот такой кусок кода:
	p = argparse.ArgumentParser(description='Symbols image generator')
	p.add_argument('-f','--font', default='Arial', help='Font name, default=Arial')
	p.add_argument('-s','--size', type=int, default=40, help='Font size, default=40')
	p.add_argument('-d','--dir', default='.', help='Output directory, default=current')
	p.add_argument('letters', help='Array of letters(abc) or range (a-z)')
	args = p.parse_args()

Таким образом мы описываем наши параметры, обо всём остальном позаботится модуль argparse. Что мне понравилось, так это автоматический показ usage и автоматическая генерация помощи по параметрам. При чём argparse ещё один аргумент (-h) в наш список сам добавил. За что ему большое спасибо. Как настоящий и ленивый программист, я очень не люблю писать хелпы и прочую документацию. Это очко в пользу argparse. Буду чаще им пользоваться.
Справка по программе у нас получается такой:
usage: gen_pic.py [-h] [-f FONT] [-s SIZE] [-d DIR] letters

Symbols image generator

positional arguments:
  letters               Array of letters(abc) or range (a-z)

optional arguments:
  -h, --help            show this help message and exit
  -f FONT, --font FONT  Font name, default=Arial
  -s SIZE, --size SIZE  Font size, default=40
  -d DIR, --dir DIR     Output directory, default=current

Теперь добавим проверку на существование пути директории и разворачивание диапазона букв. Для этого воспользуемся регулярными выражениями. Они не особо нужны в данном случае, но надо же программу посолидней сделать! Для этого нам потребуются модули os, os.path и re.
	if os.path.exists(args.dir):
		os.mkdir(args.dir)
	if re.match('^([a-z]-[a-z])|([A-Z]-[A-Z])$', args.letters):
		begin = args.letters[0]
		end = args.letters[2]
		if (ord(end)-ord(begin))>26:
			print("Error using letters. Only A-Z or a-z available, not A-z.")
			p.print_help()
			return
		letters = [chr(a) for a in range(ord(begin),ord(end)+1)]
	else:
		letters = args.letters

Ну вот. Осталось организовать цикл и передать все буковки по очереди на отрисовку.

Последний штрих — сделаем обвязочку поверх функции save() и назовём её saveWrap(). Как оригинально, не правда ли? Собственно она не делает ничего сверхестесственного, просто генерирует имя для файла исходя из передавваемых в функцию save() параметров.

Итого весь генератор занял у нас всего 55 строк (код приведён в конце статьи). Это ли не прекрасно?
Причем я уверен, что гуру питона наверняка найдут ещё кучу возможностей для оптимизации. Но зачем? Всё работает, код достаточно прост и лаконичен. Прям глаз радуется.

Разработка ИНС



Теперь начнем работу над ИНС. Для начала ознакомимся с возможностями PyBrain.
Лирическое отступление про PyBrain и Python3
Хочу уточнить, что я тестировал программу только под Python3 и пользовался портом PyBrain, который вы можете найти здесь. Пока отлаживал программу нашёл пару косяков в самом порте библиотеки.
Очень порадовал комментарий в том месте, где вываливалась библиотека:
# FIXME: the next line keeps arac from producing NaNs. I don't
# know why that is, but somehow the __str__ method of the
# ndarray class fixes something,
# str(outerr)

Видимо этот хак в Python3 не сработал.

На вход у нас подается изображение (в серых тонах), задаваемое значениями яркости каждого пикселя от 0 до 1.
Для начала сделаем ИНС, которая будет распознавать ограниченный набор символов и без разных регистров. Обучим на данных с одним шрифтом и посмотрим, как сеть распознает эти символы с другим шрифтом (тестовый набор). Возьмем, например, символы A, B, C, D, Z.

Поскольку у нас сеть будет учиться буковкам, изображения которых имеют размер 64 на 64 пиксела, то количество входов нашей сети будет равно 4096.
Самих распознаваемых букв у нас будет только 5, соответственно, и количество выходов из сети равно пяти.
Теперь встаёт вопрос: нужны ли нам скрытые слои? И если да, то сколько?
Я решил обойтись без скрытых слоёв, поэтому для создания объекта сети делаю следующий вызов:
net = buildNetwork(64 * 64, 5)

Для создания одного скрытого слоя размером в 64 нейрона и типом скрытого слоя SoftmaxLayer надо выполнить следующий вызов:
net = buildNetwork(64 * 64, 8 * 8, 5, hiddenclass=SoftmaxLayer)

К сожалению, в статье было сказано про данную функцию, но не было дано описание. Исправлю этот недочёт.
Ликбез про buildNetwork()
Функция buildNetwork() предназначена для быстрого создания FeedForward-сети и имеет следующий формат:
pybrain.tools.shortcuts.buildNetwork(*layers, **options)

layers — список или кортеж целых чисел, котоый содержит количество нейронов в каждом слое
Опции записываются в виде "name = val" и включают в себя:
bias (default = True) — начилие смещения в скрытых слоях
outputbias (default = True) — начилие смещения в выходном слое
hiddenclass и outclass — задают типы для скрытых слоёв и выходного слоя соответственно. Должны быть потомком класса NeuronLayer. Предопределенные значения — это GaussianLayer, LinearLayer, LSTMLayer, MDLSTMLayer, SigmoidLayer, SoftmaxLayer, TanhLayer.
Если установлен флаг recurrent, то будет создана сеть RecurrentNetwork, иначе FeedForwardNetwork.
Если установлен флаг fast, то будет использоваться более быстрые сети arac, в противном же случае — это будет собственная реализация PyBrain сети на питоне.

Те, кому интересно, могут выбрать другие варианты, исправив/раскомментировав вызов функции buildNetwork() в файле brain.py.

Обучение ИНС


Итак, пришло время взяться за обучение. При помощи нашей программки gen_pic.py генерируем нужные буквы.
Я сделал это так:
./gen_pic.py -d ./learn -f FreeMono ABCDZ
./gen_pic.py -d ./learn -f Times ABCDZ
./gen_pic.py -d ./learn -f Arial ABCDZ
./gen_pic.py -d ./test -f DroidMono ABCDZ
./gen_pic.py -d ./test -f Sans ABCDZ

Процесс загрузки из картинки данных и преобразование RGB-цвета в тона серого позвольте оставить за кадром. Там ничего особо интересного. Кому всё-таки жутко интересно как же это сделано — может увидеть сам в файле brain.py в функции get_data().

Само обучение производится в функции init_brain(). В эту функцию передается обучающая выборка, максимальное количество эпох для обучения и опционально тип Trainer'a, а сама функция возвращает объект уже обученной сети.
Ключевые строки создания и обучения сети выглядят так (полный код приведён к конце статьи):
def init_brain(learn_data, epochs, TrainerClass=BackpropTrainer):
	...
    net = buildNetwork(64 * 64, 5, hiddenclass=LinearLayer)
    # fill dataset with learn data
    ds = ClassificationDataSet(4096, nb_classes=5, class_labels=['A', 'B', 'C', 'D', 'Z'])
    for inp, out in learn_data:
        ds.appendLinked(inp, [trans[out]])
    ...
    ds._convertToOneOfMany(bounds=[0, 1])
    ...
    trainer = TrainerClass(net, verbose=True)
    trainer.setData(ds)
    trainer.trainUntilConvergence(maxEpochs=epochs)
    return net

Кратко поясню, что где.
ClassificationDataSet — специальный вид датасетов для целей классификации. Ему достаточно исходных данных и порядкового номера класса (trans[out]) для составления выборки.
Функция _convertToOneOfMany() переводит эти самые номера классов в значения выходного слоя.
Далее передаём «учителю» сеть и говорим, что нас интересует вывод дополнительной информации (библиотека будет печатать в консоль промежуточные вычисления).
Даём учителю датасет с обучающей выборкой (setData()) и запускаем обучение (trainUntilConvergence()), которое будет обучать либо пока сеть не сойдётся, либо пока не выйдет максимальное количество эпох обучения.

Выводы


Итак, цель достигнута.
Код написан и работает. Генератор, правда, может намного больше, чем сеть, нами сегодня построенная, в текущем её виде. Но зато осталось непаханное поле для Вас, дорогой %username%! Есть чего поправить, где подредактировать, что переписать…

Добавлю ещё, что я потестировал два Trainer'a — BackpropTrainer и RPropMinusTrainer.
Скорость работы у алгоритма обратного распространения ошибки (BackpropTrainer) плохая, сходится очень медленно. Из-за чего обучение занимает много времени.
Поменяв одну строку в brain.py можно посмотреть на работу RPropMinusTrainer. Он значительно шустрее и показывает довольно неплохие результаты.
Добавлю ещё, что добиться 100% распознавания даже для обучающей выборки мне не удалось, может надо было подбирать количество слоёв и количество нейронов в каждом — не знаю. Практического смылса в данной программе особо нету, но для изучения Python3 задача весьма неплоха: здесь и работа со списками, и со словарями, и обработка параметров командной строки, работа с изображениями, регулярные выражения, работа с файловой системой (модули os и os.path).

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

Если будут возникать вопросы — пишите в личку, но думаю, что вы и сами разберётесь что, где и как.
Будет время, может перепишу код посимпатичнее и сделаю его более настраиваемым, введу больше параметров.

Исходные тексты Вы можете взять в спойлерах ниже.
Код файла gen_pic.py
#!/usr/bin/env python3

import sys
import argparse
import re
import os
import os.path
from PyQt4.QtGui import *
from PyQt4.Qt import *


def saveWrap(dir='.', letter='A', font="Arial", size=40, align=Qt.AlignCenter):
    png_file = dir + "/" + font + "_" + letter + "_" + str(size) + ".png"
    save(png_file, letter, font, size, align)


def save(png_file, letter='A', font="Arial", size=40, align=Qt.AlignCenter):
    img = QImage(64, 64, QImage.Format_RGB32)
    img.fill(Qt.white)
    p = QPainter(img)
    p.setPen(Qt.black)
    p.setFont(QFont(font, size))
    p.drawText(img.rect(), align, letter)
    p.end()
    img.save(png_file)


def main():
    app = QApplication([])
    p = argparse.ArgumentParser(description='Symbols image generator')
    p.add_argument('-f', '--font', default='Arial', help='Font name, default=Arial')
    p.add_argument('-s', '--size', type=int, default=40, help='Font size, default=40')
    p.add_argument('-d', '--dir', default='.', help='Output directory, default=current')
    p.add_argument('letters', help='Array of letters(abc) or range (a-z)')
    args = p.parse_args()
    path = os.path.abspath(args.dir)
    if not os.path.exists(path):
        print("Directory not exists, created!")
        os.makedirs(path)
    if re.match('^([a-z]-[a-z])|([A-Z]-[A-Z])$', args.letters):
        begin = args.letters[0]
        end = args.letters[2]
        if (ord(end) - ord(begin)) > 26:
            print("Error using letters. Only A-Z or a-z available, not A-z.")
            p.print_help()
            return
        letters = [chr(a) for a in range(ord(begin), ord(end) + 1)]
    else:
        letters = args.letters
    for lett in letters:
        saveWrap(path, lett, args.font, args.size)
    return 0

if __name__ == "__main__":
    sys.exit(main())


Код файла brain.py
#!/usr/bin/env python3

import sys
import argparse
import re
import os
import os.path
from PyQt4.QtGui import *
from PyQt4.Qt import *
from pybrain.tools.shortcuts import buildNetwork
from pybrain.datasets import ClassificationDataSet
from pybrain.structure.modules import SigmoidLayer, SoftmaxLayer, LinearLayer
from pybrain.supervised.trainers import BackpropTrainer
from pybrain.supervised.trainers import RPropMinusTrainer


def init_brain(learn_data, epochs, TrainerClass=BackpropTrainer):
    if learn_data is None:
        return None
    print ("Building network")
    # net = buildNetwork(64 * 64, 8 * 8, 5, hiddenclass=TanhLayer)
    # net = buildNetwork(64 * 64, 32 * 32, 8 * 8, 5)
    net = buildNetwork(64 * 64, 5, hiddenclass=LinearLayer)
    # fill dataset with learn data
    trans = {
        'A': 0, 'B': 1, 'C': 2, 'D': 3, 'Z': 4
    }
    ds = ClassificationDataSet(4096, nb_classes=5, class_labels=['A', 'B', 'C', 'D', 'Z'])
    for inp, out in learn_data:
        ds.appendLinked(inp, [trans[out]])
    ds.calculateStatistics()
    print ("\tNumber of classes in dataset = {0}".format(ds.nClasses))
    print ("\tOutput in dataset is ", ds.getField('target').transpose())
    ds._convertToOneOfMany(bounds=[0, 1])
    print ("\tBut after convert output in dataset is \n", ds.getField('target'))
    trainer = TrainerClass(net, verbose=True)
    trainer.setData(ds)
    print("\tEverything is ready for learning.\nPlease wait, training in progress...")
    trainer.trainUntilConvergence(maxEpochs=epochs)
    print("\tOk. We have trained our network.")
    return net


def loadData(dir_name):
    list_dir = os.listdir(dir_name)
    list_dir.sort()
    list_for_return = []
    print ("Loading data...")
    for filename in list_dir:
        out = [None, None]
        print("Working at {0}".format(dir_name + filename))
        print("\tTrying get letter name.")
        lett = re.search("\w+_(\w)_\d+\.png", dir_name + filename)
        if lett is None:
            print ("\tFilename not matches pattern.")
            continue
        else:
            print("\tFilename matches! Letter is '{0}'. Appending...".format(lett.group(1)))
            out[1] = lett.group(1)
        print("\tTrying get letter picture.")
        out[0] = get_data(dir_name + filename)
        print("\tChecking data size.")
        if len(out[0]) == 64 * 64:
            print("\tSize is ok.")
            list_for_return.append(out)
            print("\tInput data appended. All done!")
        else:
            print("\tData size is wrong. Skipping...")
    return list_for_return


def get_data(png_file):
    img = QImage(64, 64, QImage.Format_RGB32)
    data = []
    if img.load(png_file):
        for x in range(64):
            for y in range(64):
                data.append(qGray(img.pixel(x, y)) / 255.0)
    else:
        print ("img.load({0}) failed!".format(png_file))
    return data


def work_brain(net, inputs):
    rez = net.activate(inputs)
    idx = 0
    data = rez[0]
    for i in range(1, len(rez)):
        if rez[i] > data:
            idx = i
            data = rez[i]
    return (idx, data, rez)


def test_brain(net, test_data):
    for data, right_out in test_data:
        out, rez, output = work_brain(net, data)
        print ("For '{0}' our net said that it is '{1}'. Raw = {2}".format(right_out, "ABCDZ"[out], output))
    pass


def main():
    app = QApplication([])
    p = argparse.ArgumentParser(description='PyBrain example')
    p.add_argument('-l', '--learn-data-dir', default="./learn", help="Path to dir, containing learn data")
    p.add_argument('-t', '--test-data-dir', default="./test", help="Path to dir, containing test data")
    p.add_argument('-e', '--epochs', default="1000", help="Number of epochs for teach, use 0 for learning until convergence")
    args = p.parse_args()
    learn_path = os.path.abspath(args.learn_data_dir) + "/"
    test_path = os.path.abspath(args.test_data_dir) + "/"
    if not os.path.exists(learn_path):
        print("Error: Learn directory not exists!")
        sys.exit(1)
    if not os.path.exists(test_path):
        print("Error: Test directory not exists!")
        sys.exit(1)
    learn_data = loadData(learn_path)
    test_data = loadData(test_path)
    # net = init_brain(learn_data, int(args.epochs), TrainerClass=RPropMinusTrainer)
    net = init_brain(learn_data, int(args.epochs), TrainerClass=BackpropTrainer)
    print ("Now we get working network. Let's try to use it on learn_data.")
    print("Here comes a tests on learn-data!")
    test_brain(net, learn_data)
    print("Here comes a tests on test-data!")
    test_brain(net, test_data)
    return 0

if __name__ == "__main__":
    sys.exit(main())



На этом знакомство с PyBrain на сегодня считаю законченным. До новых встреч!

upd: по просьбе monolithed исправил регулярное выражение.
Only registered users can participate in poll. Log in, please.
Что вам интереснее больше? На чём сконцентрироваться при написании следующей статьи?
45.25% PyQt 119
59.32% PyBrain 156
32.7% Какие-то особые приёмы при программировании на Python3 (если что-то конкретное, то опишите в комментариях) 86
23.95% Туториалы по модулям Python3 (какие именно модули — пишите в комментариях) 63
263 users voted. 70 users abstained.
Tags:
Hubs:
+28
Comments 29
Comments Comments 29

Articles