Изучая 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%PyQt119
59.32%PyBrain156
32.7%Какие-то особые приёмы при программировании на Python3 (если что-то конкретное, то опишите в комментариях)86
23.95%Туториалы по модулям Python3 (какие именно модули — пишите в комментариях)63
263 users voted. 70 users abstained.