среда, 9 декабря 2015 г.

Зацениваем многоклассовую логистическуя регрессию

Введение


Метод логистической регрессии один из простейших методов классификации в машинном обучении. Тем не менее он ряд сильных сторон и поэтому его не стоит сбрасывать со счетов. Во-первых логистическая регрессия выдаёт не только предполагаемы класс объекта, но и оценку вероятности принадлежности объекта к тому или иному классу. Причём оценка эта выдаётся в виде гладкой функции. Во-вторых этот метод очень просто реализовать и понять: вот, например, реализация из документации Apache Spark:
В третьих, натренированная модель представляет собой вектор весов, которые просто интерпретировать.
Когда объясняют логистическую регрессию, чаще всего ограничиваются бинарной классификацией (с двумя классами). Например, эта тема прекрасно раскрыта в курсе Andrew Ng на Coursera. Однако нередко нужно различать более чем 2 класса. В этом случае, например, в курсе Andrew Ng предлагается стратегия one-vs-all. То есть для различения n классов надо натренировать n бинарных классификаторов по одному на каждый класс объектов. Поэтому мне кажется интересным с самого начала рассмотреть именно вариант с произвольным числом классов.

Теория (нестрогое изложение)


Пусть наши наблюдения описываются некоторым количеством признков ${\bf f} = \{f_1...f_m\}$. Обозначим буквой $Y$ класс объекта ($Y \in 1...k$). Будем моделировать вероятность с помощью экспоненциальной функции от взвешенной суммы признаков
$$P(Y=l) \propto \exp(w^{(l)}_1 f_1 + w^{(l)}_2 f_2 + ... + w^{(l)}_m f_m ).$$
То есть для каждого класса $l$ имеется свой набор весов $\bf w^{(l)}$. Вероятность должна быть нормирована на 1: $$P(Y=l) = \frac{\exp(w^{(l)}_1 f_1 + w^{(l)}_2 f_2 + ... + w^{(l)}_m f_m )}{\sum\limits_{j=1}^k \exp(w^{(j)}_1 f_1 + w^{(j)}_2 f_2 + ... + w^{(j)}_m f_m )}.$$ Задача обучения сводится к тому, чтобы подобрать такие веса $\bf w^{(l)}$, при которых будет достигаться оптимальное разделение классов. Для этого берётся "обучающее множество": некоторое количество экземпляров данных, на которых известны $Y$ и $\bf f$. Затем изменяя значения $\bf w^{(l)}$ максимизируют функцию правдоподобия на этих данных (на практике максимизируют логарифм правдоподобия, так как в этом случае вычисления проще).
Обозначим ${\bf W} = \left( {\bf w}^{(1)}, {\bf w}^{(2)}, ... , {\bf w}^{(k)}\right)$, ${\bf F}=\left({\bf f}, {\bf f} ... {\bf f} \right)$ (всего $k$ повторений ${\bf f}$), ${\bf F}\left(Y=j\right)=\left({\bf 0}, {\bf 0} ... {\bf f} ...{\bf 0} \right)$ (здесь положены равными нулю все ${\bf f}$, кроме той, что стоит на $j$-й позиции). Такая запись позволяет представить веса модели и признаки в виде векторов рамерностью $mk$, что на мой взглят облегчает объяснения.
$$ P(Y=l|{\bf F; W}) = \frac{\exp\left({\bf W \cdot F}\left(Y=l\right)\right)}{\sum\limits_{j=1}^k\exp\left({\bf W \cdot F}\left(Y=j\right)\right)} $$ Итак, запишем логарифм функции правдоподобия: $$ L\left({\bf W}\right) = \sum\limits_{i=1}^n \ln P\left(Y=Y_i|{\bf F}_i; {\bf W}\right). $$ Здесь $i$ обозначает номер примера в тренировочном множестве, $Y_i$ -- его класс, ${\bf F}_i$ -- его набор признаков. Продиференцировав вражение для нормированной верятности $P\left(Y|{\bf F; W}\right)$ получим: $$ \frac{\partial L\left({\bf W}\right)}{\partial {\bf W}} = \sum\limits_{i=1}^n{\bf F}_i\left(Y=Y_i\right) - \sum\limits_{i=1}^n\sum\limits_{j=1}^kP\left(Y=j|{\bf F}_i; {\bf W}\right) $$ Зная градиент функции можно искать её максимум, например методом градиентного подъёма. Ещё одно замечательное свойство многоклассовой логистической регрессии -- выпуклость возникающей задачи оптимизации (см Collins, стр 16). Значит мы сразу найдём глобальный максимум.

Для того чтобы контролировать переобучение к функции правдоподобия добаволяется дополнительный член: штраф за слишком большие веса модели ${\bf W}$. Мы будем рассматривать только квадратичный штраф: $$ L\left({\bf W}\right) = \sum\limits_{i=1}^n \ln P\left(Y=Y_i|{\bf F}_i; {\bf W}\right) - \frac{\lambda}{2}{\bf W^T}\cdot{\bf W}, $$ соответственно, в производной появится дополнительный член $-\lambda {\bf W}$.

Реализация


Для экспериментов я реализовал многоклассовый классификатор на C++ в виде консольной программы. В приоритете была простота, поэтому для нахождения минимума целевой функции при обучении используется обычный градиентный спуск с постоянным шагом. Хотя на практике применяют методы получше. Из этих же соображений для представления векторов я использовал класс valarray из стандартной библиотеки C++. Мне больше нравится библиотека Eigen, но пока что я не хочу связываться с подключением внешних библиотек.

Функция featurize соответствует вычислению ${\bf F}_i\left(Y=Y_i\right)$.
Ключевой момент в обучении классификатора -- вычисление градиента целевой функции. За это отвечает такой кусок кода:


Эксперименты с обучением на реальных данных


Чтобы попробовать метод и его реализацию на реальных данных, я взял два датасета из репозитория UCI. Один из них - известнейший датасет Iris (Ирисы Фишера), второй - датасет Wine . В первом предлагается определить тип ириса, используя в качестве параметров геометрические размеры элементов цветка (всего 4 параметра). Во втором наборе днных предлагается определить происхождение вина (категориальная переменная, которая принимает значения 1, 2, 3) по 13 параметрам, полученным в результате химического анализа вина. Надо сказать, что оба набора данных не из тех, что "бросают вызов". Практически любой классификатор может показать на них неплохие результаты без какой-то особой обработки данных напильником.

Итак, сначала убедмся, что классификатор может обучиться разделять объекты в тренировочном наборе данных. Для датасета Iris будем использовать данные "как есть", слегка поменяв формат файла (заменим запятные в качестве разделителя полей на символы табуляции) и заменив названия классов на числовые значения: "Iris setosa" => 0,  "Iris virginica" => 1, "Iris versicolor" => 2. В *nix или cygwin окружении можно использовавть такую команду (в одну строчку)

>sed -e 's/,/\t/g;s/Iris-setosa/0/;s/Iris-versicolor/1/;s/Iris-virginica/2/' ./iris.data  > ./iris.tsv

или произвести замену в своём любимом текстовом редакторе.
Тренируем модель

>./mlr train --verbose --modelfile irismodel.txt --datafile iris.tsv --featureincolumns 0,1,2,3 --labelincolumns 4 --numclasses 3 --learnrate 0.2

Теперь посмотрим на матрицу неточностей (confusion matrix) на тех же самых тренировочных данных:

>./mlr eval --modelfile irismodel.txt --datafile iris.tsv --featureincolumns 0,1,2,3 --labelincolumns 4

Получим такой вывод

50      0       0
0       49      1
0       1       49

В этой матрице 3x3 число в i-й строке и j-й колонке соответствует количеству объектов класса i которым классификатор назначил класс j. "Идеальный" классификатор должен назначать всем объектам класса i только лишь класс i и никакой другой, поэтому матрица неточностей должны быть диагональной. Недиагональные элементы соответствуют ошибкам классификации.

Мы видим, что классификатор ошибся лишь два раза . Можно сделать вывод что в данном случае классификатор хорошо настроился на разделение тренировочных данных. Несмотря на линейность мы можем хорошо разделять классы ирисов в пространстве признаков. Правда, пока что рано говорить, что получился хороший классификатор: возможно мы просто переобучились, и на новых данных ошибка будет велика. Поэтому проверим теперь наш классификатор с помощью процедуры кросс-валидации. Разделим данные в пропорции 70/30. Большую часть будем использовать для тренировки, а меньшую - для проверки качества.

>sort -R ./iris.tsv > iris.shuffled.tsv
>head -n 105 iris.shuffled.tsv > ./iris.train.tsv
>tail -n 45 iris.shuffled.tsv > ./iris.test.tsv

>./mlr train --verbose --modelfile irismodel.txt --datafile iris.train.tsv --featureincolumns 0,1,2,3 --labelincolumns 4 --numclasses 3 --learnrate 0.2

>

Теперь матрица неточностей выглядит так:

16      0       0
0       15      0
0       0       14

Можно сказать, что мы научили классификатор различать ирисы.

Теперь проделаем то же самое с датасетом Wine. Сначала как и прежде заменим запятые на символы табуляции в файле данных. Кроме того класс объекта представлен числами 1,2,3. У меня всё zero-based, поэтому надо замнить в первой колонке (у неё тоже индекс 0 а не 1): 1=>0, 2=>1, 3=>2.

>sed -e 's/,/\t/g;s/^1/0/;s/^2/1/;s/^3/2/' wine.dat > wine.tsv

Взглянув на колонки признаков, можно заметить что там встречаются значения разные по порядку величины. Например ... Поэтому неплохо было бы провети сначала нормализацию данных: с помощью сдвига и масштабирования привести их значения к 0, а разброс к некоторой одинаковой величине, например, 1. Под "разбросом" можно понимать либо разницу максимального и минимального значения, либо среднеквадратичное отклонение. Для нормализации данных я написал скрипт на Perl. Скрипт можно использовать так:

>perl normalize.pl stddev --in wine.tsv  --fields 1,2,3,4,5,6,7,8,9,10,11,12,13 > wine.normalized.tsv

Тренируем:

>./mlr train --verbose --modelfile wine.model.txt --datafile wine.normalized.tsv --featureincolumns 1,2,3,4,5,6,7,8,9,10,11,12,13 --labelincolumns 0 --numclasses 3 --learnrate 0.2
Смотрим матрицу неточностей: 
>./mlr eval --modelfile wine.model.txt --datafile wine.normalized.tsv --featureincolumns 1,2,3,4,5,6,7,8,9,10,11,12,13 --labelincolumns 0

59      0       0
0       71      0
0       0       48
Видим, что классификатор научился идеально разделять объекты в тренировочных данных. Можно сказать, что чем больше рамерность пространства признаков тем больше шансов, что данные можно будет разделить линейным классификатором.
Для чистоты эксперимента опять разделим данные на тестовую и тренировочную подвыборки.
>sort -R ./wine.normalized.tsv > wine.shuffled.tsv
>head -n 124 ./wine.shuffled.tsv > ./wine.train.tsv
>tail -n 53 ./wine.shuffled.tsv > ./wine.test.tsv

Посмотрим, что получилось.

>./mlr train --verbose --modelfile wine.model.txt --datafile wine.train.tsv --featureincolumns 1,2,3,4,5,6,7,8,9,10,11,12,13 --labelincolumns 0 --numclasses 3 --learnrate 0.2


>./mlr eval --modelfile wine.model.txt --datafile wine.test.tsv --featureincolumns 1,2,3,4,5,6,7,8,9,10,11,12,13 --labelincolumns 0

20      0       0
0       18      0
0       0       15

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

Заключение


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

Ссылки




воскресенье, 22 ноября 2015 г.

Делаем простой парсер химических формул с помощью JavaCC

Я продолжаю очень медленно и по чуть-чуть пилить "химический поиск" ChWiSe.Net. И хотя он пока что в стостоянии протипа, некоторый ненулевой поток запросов есть. Я заметил, что пользователи не всегда хотят использовать SMILES-нотацию. Думаю, она не особо известна за пределами сообщества професионалов-химиков и хемоинформатиков. Вместо этого пользователи пытаются найти вещества по обычной полуструктурной формуле, например, всем известный этиловый спирт пытаются найти запросом C2H5OH вместо SMILES-формулы CCO. Что ж, надо поддержать и такие запросы.

Нам понадобится создать парсер таких формул. Для формул вида (NH4)2CO3, Al2(SO4)3, (CH3)2O или CH3COOH необходимо правильно разобрать все скобочки, числовые коэфициенты, выделить отдельные химические элементы и группы атомов, такие, как сульфат-анион, и использовать эти выделенные составляющие формулы для поиска похожих веществ.

Я решил реализовать парсер формул в соответствии с очень простой грамматикой: терминалами являются круглые скобки, числа, символы химических элементов и "стандартные" группы атомов ( COOH, OH, SO4, и.т.д. ). Правил вывода всего пять:

ELEMENT -> elementToken
ELEMENTARYGROUP -> elementaryGroupToken
GROUP -> '(' FORMULA ')'
TERM ->  (GROUP|ELEMENTARYGROUP|ELEMENT) NUMBER?
FORMULA -> TERM*

Человеческим языком это можно описать так (снизу вверх):
  • Формула (FORMULA) это некоторое количество идущих друг за другом "термов" (TERM). Звёздочка после элемента обозначает некоторое (возможно, нулевое) количество повторений этого элемента.
  • Терм (TERM) это или группа (GROUP) или "элементарная группа" (ELEMENTARYGROUP), или элемент (ELEMENT) после чего может следовать число (NUMBER). Числа может и не быть: знак вопроса после NUMBER обозначает что этот элемент необязателен.
  • Группа (GROUP) - это формула (FORMULA) в скобках.
  • Элементарная группа (ELEMENTARYGROUP) - терминал elementaryGroup
  • Элемент (ELEMENT) - терминал символа химического элемента
Эта граммтика столь проста, что всю логику разбора формулы можно написать руками, но я так делать не стал: такой код будет плохо поддерживаемым, менять грамматику будет сложно. Есть специальные инструменты, которые позволяют создавать парсеры на вашем любимом языке программирования: lex/yacc, flex/bison, javacc, antlr, boost.spirit и многие другие. Я бы выделил две группы таких инструментов: в первой группе (lex/yacc, flex/bison, javacc, ANTLR) грамматика описывается в отдельном файле в специальном, удобном для описания грамматик формате, а затем специальные трансляторы преобразуют файл описания грамматики в код, который будет осуществлять разбор строк в соответствии с грамматикой. Другая группа инструментов основана на использовании возможностей соответствующего языка программирования. Так, например, в Boost.Spirit с помощью шаблонной магии и перегрузки операторов грамматика описывается прямо в C++-коде.

Для своих целей я выбрал JavaCC (Java Compiler Compiler). Этот простой генератор парсеров является рабочей лошадкой во многих проектах. Из open source могу упомянуть парсеры поисковых запросов Lucene и Solr.

Описание парсера состоит из двух основных частей: описания сканера и собственно парсера. Сканер осуществляет выделение из поступающей на вход строки отдельных терминалов. У меня он определяется так:

Секция TOKEN определяет известные парсеру типы токенов. У меня их 5 типов: открывающаяся скобка, закрывающаяся скобка, число (NUM), символ химического элемента (ELEMENT), и то, что я называю "элементарная группа" (ELEMENTARYGROUP).

Во второй важнейшей часть необходимо описать правила вывода парсера. В JavaCC эти правила описываются "декларативно" с примесью Java-кода. Пять правил, описанных выше на языке JavaCC записывются так:

Файл описания грамматики целиком можно посмотреть здесь.

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


Вот собственно и всё, мы написали простой парсер.

Генерация парсера

JavaCC - это утилита командной строки которая обрабатывает описание грамматики и генерирует собственно парсер: Java-класс, который при конструировании получает на вход объект класса java.io.Reader. Вызвать геренацию парсера из командной строки можно так (это одна строка):

java -cp <ПУТЬ К ДИРЕКТОРИИ JavaCC>/bin/lib/javacc.jar javacc SemistrucutredFormulaParser.jj

Вызов этой команды сгенерирует несколько *.java файлов:


ParseException.java
SemistructuredFormulaParserConstants.java
SemistructuredFormulaParser.java
SemistructuredFormulaParserTokenManager.java
SimpleCharStream.java
Token.java
TokenMgrError.java

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



Проект Maven


Шаги, описанные выше могут быть включены в проект maven. Следует положить *.jj файл в поддиректорию $PROJECT_ROOT/src/main/javacc, где $PROJECT_ROOT - корневая директория проекта. В pom.xml следует включить такую секцию:




пятница, 16 октября 2015 г.

Перевод проекта ChWiSe.Net на Backbone.js

После относительно длительного перерыва я продолжил не спеша развивать свой проект "химического поиска" ChWiSe.Net. В какой-то момент я обнаружил, что браузерный Javascript-код начинает быть запутанным и добавление новой функциональности стало нелёгкой задачей. Я решил, что пора провести мини-рефакторинг этой части. При этом возникла мысль использовать какой-либо Javascript-фреймворк. Сейчас существуюет большое количество Javascript-фреймворков, призванных облегчить и струкутрироровать клиентский код в AJAX-приложениях. При этом когда встаёт проблема выбора, если ты не знаком с парой-тройкой фреймворков, фразы типа "у каждого фреймворка своя ниша" вызывают чувство беспомощности. Совершенно непонятно, конкретно мой проект в какой нише, и какой фреймворк подойдёт лично мне лучше всего. В моём конкретно случае я остановился на Backbone.js, и, кажется, не ошибся. В этом посте я собираюсь написать о своём опыте перевода браузерной части несложного проекта с "просто javascript" на фреймворк Backbone.js. Сам по себе этот пост вряд ли годится в качестве хорошего туториала по Backbone.js (хотя я постарался дать ссылки на такие туториалы), однако тут разбира один частный случай веб-сайта, в котором Backbone.js работает хорошо.

 Одна из ключевых деталей, которых не хватало в имевшейся реализации - url hash-навигация (или просто hash навигация). Url hash навигация это такой приём в AJAX-приложении, когда ваши действия на веб-страничке, такие как поиск, центрирование карты, выбор объекта или, скажем, фильтра по колонке какой-нибудь таблицы фиксируются адресной строке браузера после символа '#'. Вы можете выполнтить какие-то действия, например поискать что-то в вашем любимом поисковике, затем скопировать URL страницы с результатами поиска и отправить ссылку вашему знакомому. Он в свою очередь, перейдёт по этой ссылке и увидит примерно то же что видели и вы: например, на страничке сайта поисковика в поисковом поле будет тот же самый запрос, а результаты будут примерно теми же (жизнь как всегда сложнее, современные поисковые движки могут давать персонализированные результаты, поэтому пользователь Б на один и тот же запрос может увидеть другие результаты, чем пользоваетль А, но сам запром будет тем же самым). Ещё одно немаловажное преймущество страничек с url hash-навигацией - адекватная работа кнопки "назад" в браузерах. Поскольку мой сайт тоже по сути дела "поисковик", для меня это фишка была обязательна.

Между делом я почитывал статьи про современные JS-фреймворки, и больше всего заинтересовался Backbone.js. Во-первых он поддерживает url hash навигацию. Хотя для реализации url hash навигации можно обойтись и без фреймворков, Backbone.js предлагает для этого логичную и легко поддерживаемую структуру кода. Во-вторых, как мне показалось, моя веб-страничка по структуре идеально вписывается в "типовую архитектуру" Backbone.js приложения (об этом ниже). Ну и, наконец, Backbone.js очень хвалили за "легковесность" и отсутствие "магии": исходный код Backbone.js относительно невелик и, как сообщают, его несложно прочесть и понять. Надо учитывать, что Backbone.js использует jQuery и Underscore.js. Поэтому при добавлении в ваш проект зависимость от Backbone.js их размер тоже стоит учесть. Если вы и так использовали эти библиотеки, то объём данных, скачиваемых браузером при заходе на вашу страницу увеличится только на размер самого скачиваемого файла Backbone.js.

В моём конкретном случае клиентская часть уже и так использовала jQuery, так что мне пришлось притянуть в проект лишь библиотеку Underscore.js. Зато я смог отказаться от шаблонной библиотеки EJS, так как в Underscore.js уже есть движок шаблонов.

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

Именно для таких страничек Backbone.js подходит очень хорошо! В общем, я решил переделать клиентскую часть с использованием Backbone.js.

Для создания таких сайтов Backbone предлагает такие абстракции:

"Model" (модель) - обычно это внутреннее представление информации о некоторой еденичной сущности: цена и характеристики товара, координаты и тип объекта на карте, "поля" некой карточи в картотеке - всё это хорошие кандидаты на то, чтобы быть смоделированными моделью Backbone.js.

"Collection" - по сути это тоже модель, которая содержит в себе другие модели. Удобно думать о коллекции как о простом массиве. Обычно Collection соответствует различным спискам, а отдельные элементы в модели-массиве - отдельным записям в этих списках. При этом модели совсем необязательно быть элементом коллекции. Например, у меня в ChWiSe.Net есть два класс моделей "нижнего уровня". Одна из них, ChWiSe.Models.SearchResultCompound отвечает за внутреннее представление одного результата поиска. Другая, ChWiSe.Models.ServerMessage отвечает за представление "сообщения от сервера", такие как ошибки, исправлнеие опечаток и.т.д. Так же есть модель-коллекция, которая содержит все результаты поиска ChWiSe.Models.SearchResults. Элементы этой коллекции - модели нижнего уровня типа ChWiSe.Models.SearchResultCompound . Сообщения от сервера класса ChWiSe.Models.SearchResultCompound  так же содержаться в этой модели-коллекции просто в виде дополнительного поля, а не элемента коллекции.

Модели Backbone.js обеспечивают не только представление ваших данных в клиентской части веб-приложения, но и взаимодействие с сервером.  Можно вносить свои коррективы в этот процесс переопределяя в ваших моделях функции url, fetch и parse.

"View" (вид) - отвечает за "рендеринг" модели. Под рендерингом подразумевается генерация html-разметки, которая будет отображаться в браузере. Я уже упомянул, что мне понадобилось 3 различных модели. Я сделал столько же представлений: ChWiSe.Views.SearchResultCompound, ChWiSe.Views.ServerMessage и ChWiSe.Views.SearchResults. Проилюстрирую роль моделей и представлений в моём проекте следующими рисунками:






"Router" (маршрутизатор). Именно этот компонент отвечает за hash-навигацию. В объекте-маршрутизаторе задаётся соответствеие шаблона hash-строки в адресной строке браузера и javascript-функции. Наприемер, у меня таблица маршрутизации очень простая и пока что выглядит так:

routes: { // sets the routes
    "" : "showWelcomePage",
    "search?*queryString" : "search",
  },

Смысл очень простой: напримре, если в строке браузера появится URL, заканчиваюзийся на search?sq=ethanol, то вызовется JS-функция search. При этом в качестве аргумента её передастся строка "sq=ethanol". В скрипте (например, в обработчике нажатия на кнопку "Search") мы не вызываем функцию, отвечающую за поиск непосредственно, а "переходим" на соотвтетствующий URL:

ChWiSe.router.navigate("search?" + encodedURIfragment, {trigger: true} );

Вот и всё!

В качестве ещё одного дополнительного украшения я сделал бесконечную прокрутку результатов (похоже на то, как сделано в Facebook, Вконтакте и DuckDuckGo) вместо щёлкания по кнопкам-ссылкам с номерами страниц. Тут мне помогла библиотека infiniScroll.js.

Заключение:

В целом Backbone.js мне понравился: большое сообщество, хорошие туториалы, относительная простота в освоении - всё это несомненные преимущества этого фреймворка. Ещё один плюс - типовая архитектура клиентского кода. Так что побочным положительным эффектом от перехода на Backbone может стать лучшая структурированность JavaScrip-кода вашего проекта.

Ссылки:

Неплохая статья для начала чтобы пощупать Backbone.js

Пожалуй наиболее всеобъемлющее введение в Backbone.ks. Рекомендую для продолжения знакомства с Backbone.js сразу послед прочтения предыдущей статьи

Конечно же, документация на официальном сайте

вторник, 21 июля 2015 г.

Colorshield. 8x8 RGB светодиодная матрица + Arduino

Заказал поиграться пару вот таких светодиодных индикаторов (8x8, RGB). И заодно Ардуино-шилды (ColorShield) к ним.

Индикатор и Shield-драйвер

Особо не разбирался во внутренностях, нашёл библиотеку Colorduino, с которой всё сразу заработало. С библиотекой поставляется пример Plasma (переливающиеся разноцветные полосы).

Пример "Плазма"


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



Взяв за основу пример с "плазмой" я написал простенький скетч, который перебирает цифры 0-9, отображая их разными цветами. Для этого я использовал шрифты размером 8x8 пикселей из проекта font8x8, просто скопировав "кусочек" с цифрами:





Чтобы рисовать символы я написал такую функцию:

Вот что получилось:

Остальные цифры тоже показываются :)

Полные исходники скетча можно посмотреть в GitHub.


вторник, 2 июня 2015 г.

Aspell. Как получить список "всех" слов русского языка.

Иногда возникает необходимость получить список всех слов русского (или какого-нибудь другого) языка. На помощь может прийти утилита aspell (основное предназначение которой - исправлять ошибки при наборе текста). Например, у меня на ноутбуке стоит Ubuntu, нужно поставить пакеты aspell и aspell-ru для русских словарей. В Windows я использовал Cygwin, хотя скорей всего можно и просто под Windows собрать.

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

aspell -l ru dump master | aspell -l ru expand > russianwords.txt

В файле russianwords.txt мы увидим вот такую простыню текста:

обезличение обезличением обезличением обезличению обезличении обезличения
обезличенный обезличенные обезличенных обезличенным обезличенными обезличенная обезличенною обезличенное обезличенного обезличенной обезличенном обезличенному обезличенную обезличен обезличена обезличена обезличено обезличено обезличены обезличены обезличенна обезличенно обезличенны
обезличенность обезличенностью обезличенности
обезличивание обезличиванием обезличиванием обезличиванию обезличивании обезличивания


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

...
британцем
британцев
британец британцах британцами британцам британцы британце британцу британца

...

Мы видим, что слово "британец" породило формы "британцах", "британцами" и.т.д., а слова "британцем", "британцев" и "британца" стоят отдельно. То есть с точки зрения словаря aspell слова "британцев" и "британцем" рассматриваются не как формы слова "британец", а как отдельные независимые слова! Что ж, для основной цели aspell, исправления опечаток, это совершенно некритично. Но если мы хотим приводить словоформу "к основной" форме, например, для полнотекстового индексирования/поиска, то для этих слов такой механизм будет работать недостаточно хорошо.

Команда aspell -l ru dump master  выводит словарь в неразвёрнутом формате.
В этом "неразвёрнутом" словаре изменение слова британец определяется таким правилом:

британец/O
...
британцев
британцем

...

Правило /O сформулировано примерно таким образом: если слово заканчивается на "ец", откусить "ец", и добавлять "цах", "цами" и.т.д. для образования форм "британцах", "британцами"...

Со словом "испанец" та же ситуация:

...
испанец/O
...
испанцев
испанцем

...

слово "конец" изменяется по такому же правилу, но дополнительные слова имеют другие окончания

конец/O
...
концов
...
концом


Изменение слова "Данаиды" тоже регулируется правилом "/O".

Данаиды/O

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

суббота, 30 мая 2015 г.

Перемалываем 2.2 Тб данных в Amazon EC2 с помощью Apache Spark

В качестве личного проекта  с перерывами попиливаю проект "химического поиска". Я задался целью создать "умный" поиск химических веществ. Под словом "умный" я подразумеваю возможность отвечать на вопросы на простом человеческом языке. Поэтому в проекте есть место технологиям из области компьютерной лингвистики или NLP (natural language processing). А где NLP там и работа с большими объёмами текстов, алгоритмы машинного обучения и много других интересных вещей.

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

N-грамма это просто кусочек из N последовательноых слов, взятых из текста. Чаще всего говорят об униграммах, биграммах и триграммах.  Например, предложение Ethanol may be administered as an antidote to methanol poisoning содержит следующие триграммы: Ethanol may be; may be administered; be administered as; administered as an; as an antidote; an antidote to; antidote to methanol.

В свободном доступе есть 2.2 террабайтный корпус n-грамм (n=1..5) сгенерированный Google по текстам книг за несколько столетий. Каждая запись об n-грамме содержит так же год и количество повторений в данный год, примерно так:

Google предоставляет веб-интерфейс, с помощью которого можно, например, сравнить популярность Ленина и Сталина в разные годы. Или, скажем, сравнить популярность слов "эксплуатация" и "эксплоатация". Если же нужен доступ ко всему списку n-грамм, то весь датасет можно скачать по кусочкам здесь (вообще-то вроде как существует две версии датасета-2009 и 2012 годов). Но есть способ гораздо лучше: Amazon предоставляет удобный доступ к этому корпусу (вроде как первой версии, 2009 года), выложив его в своё хранилище S3 (наряду с несколькими другими открытыми датасетами). Это делает привлекательным использование сервиса EC2, поскольку при определённых условиях (аренда машин в той же локации, где хостится датасет) можно выиграть в скорости избежать расходов на передачу данных.

Недавно я прочитал туториал к Apache Spark. Это фреймворк для кластерных вычислений очень похожий на Scoobi, о котором я писал раньше. Относительно недавно он стал "top level" проектом в Apache Software Foundation, что позитивно сказывается на развитии проекта: популярность в сообществе растёт, новые версии выходят регулярно и часто. Разумеется, мне тоже было интресно познакомиться со Spark поближе.

Итак, нашлась подходящая задача: будем молотить 2.2 террабайтный корпус n-грамм кластером из машин, арендованных в Amazon EC2. В качестве программной платформы будем использовать Apache Spark, в котором будем запускать довольно нехитрый "скрипт" на Scala. В дальнешем я буду взаимозаменяемо использовать слова "скрипт", "Spark-скрипт", "Spark-программа" и тому подобные. Всё это одно и то же: нехитрая программка написанная на языке Scala (и выложенная на GitHub). Логика очень простая: программа просматривает список n-грамм и проверяет, входит ли в n-грамму название одного из примерно 12000 химических веществ (список взят из википедии). Если да - то сохраняем эту n-грамму в выходной файл. Попутно считаем ежегодные количества повторения n-граммы в книгах за все года и выводим в результат суммарное количество повторений. Написание программ под Spark очень напоминает написание программ под Scoobi и написание цепочек преобразований над коллекциями Scala: в Scoobi и Spark необходимо описать трансформации с помощью привычных  функций (map, filter, flatMap, и.т.д.) Только эти трансформации применяются не к коллекциям Scala API, а к распределённым "спискам" (RDD в Spark, Distributed List в Scoobi), которые могут обрабатываться параллельно в кластере. Документация Apache Spark содержит отличную вводную, так что не вижу особого смысла разбирать свой скрипт более подробно (разумеется, готов ответить на любые вопросы в коментариях). 

Сначала надо скачать Apache Spark (на момент написания заметки актуальная версия была 1.3.1). Затем его надо собрать: для Ububntu: ставим переменную JAVA_HOME (например, в моём случае так: export JAVA_HOME=/usr/lib/jvm/java-8-oracle в других системах путь может отличаться), устанавливаем maven из репозитория Ubuntu (дистрибутив Spark 1.3.1 содержит maven внутри себя, так что устанавливать предварительно maven не обязательно), запускаем скрипт make_distribution.sh. Скрипт предупредил меня, что Java 1.6 не установлена (ещё бы, у меня стоит Java 1.8). Вводим 'y' (всё равно прдолжить). Ждём окончания сборки Spark (на моём стареньком ноутбуке заняло минут 15-20).

Теперь займёмся подготовкой Scala-программы, которая делает необходимую обработку данных. Понадобится git и sbt. Клонируем исходники с github и собираем Spark-скрипт (в результате должен получиться *.jar файл).

git clone https://github.com/AlexanderSavochkin/BookNgrams-filter
 
cd BookNgrams-filter
 
sbt clean compile assembly

В директории проекта должна появиться поддиректория target в которой можно найти готовый jar-файл, примерно c таким путём и названием: ../BookNgrams-filter/target/scala-2.10/ngramsfilter-assembly-1.0.jar .

Spark-программы можно запускать и локально, что и рекомендуется сделать предварительно, чтобы сперва убедиться, что ваша программа делает то что надо на маленьком объёме данных (скормив мальенький файлик testngramsfile.txt).

%/ПУТЬ К SPARK/bin/spark-submit --class net.chwise.dataaquisition.textmining.NGramFilter --master local[2] ./ngramsfilter-assembly-1.0.jar -d -p -g testngramsfile.txt -o result -t list-compoundnames.normalized.txt

Необязательный флаг -d заставляет скрипт сохранять промежуточные вычисления, что сильно помогает в отладке. Файл с названиями химических веществ list-compoundnames.normalized.txt можно взять в поддиректории data github-проекта.

Теперь перейдём к запуску нашей Spark-программы в настоящем клстере, который мы арендуем в сервисе Amazon AWS. Вместе со Saprk'ом поставляется скрипт управления Spark-кластером в Amazon EC2. Путь к нему: %SPARK_HOME/ec2/spark-ec2 . Этот скрипт позволяет запускать/останавливать кластер, узнавать информацию о кластере, одним словом управлять им. В общем это, похоже,  заточенный под Spark аналог Apache Whirr, (о котором я писал в одном из предыдущих постов).

Итак, в самом начале генрируем в панели управления Amazon AWS пару ключей (об этом я тоже писал). Устанавливаем переменные окружения  AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY.  Значения для этих ключей можно узнать здесь: https://portal.aws.amazon.com/gp/aws/securityCredentials.

%export AWS_ACCESS_KEY_ID=... 
%export AWS_SECRET_ACCESS_KEY=...

Пришло время запускать кластер. Документация Amazon говорит, что датасет Google Books Ngrams хостится в регионе us-east-1, и, следовательно надо запускать кластер в этом же регионе чтобы избежать дополнительных трат на передачу данных. Кроме того этот датасет хранится в запакованном виде, поэтому нужно предпринять некоторые дополнительные действия (я потратил очень много сил, пытаясь заставить Spark читать сжатые данные в формате lzo, пока не нашёл этой ссылки, огромное спасибо автору).

Сначала стоит запустить "кластер" из одной машины (жирным шрифтом показано, как задать количество машин в кластере и регион) и проверить работоспособность программы на относительно маленьком подмножествет данных, например, только на униграммах. Запускаем кластер так (про то, как получить файл с ключом, который у меня называется spark-cluster.pem, я тоже писал в посте про запуск Scoobi в Amazon EC2):

%SPARK_HOME/ec2/spark-ec2 -k spark-cluster -i /home/asavochkin/Work/Projects/ChWiSe/spark-cluster.pem -s 1 --region=us-east-1 --user-data=/home/asavochkin/Work/Projects/ChWiSe/lzo-scripts/lzo.sh launch test-spark-cluster

Setting up security groups...
Creating security group test-spark-cluster-master
Creating security group test-spark-cluster-slaves
Searching for existing cluster test-spark-cluster...
Spark AMI: ami-5bb18832
Launching instances...
Launched 1 slaves in us-east-1d, regid = r-5b9c33ba
Launched master in us-east-1d, regid = r-769c3397
Waiting for all instances in cluster to enter 'ssh-ready' state..............

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

%SPARK_HOME/ec2/spark-ec2 -k spark-cluster -i /home/asavochkin/Work/Projects/ChWiSe/spark-cluster.pem login test-spark-cluster

Если всё прошло гладко, то после этокй команды мы окажемся в консоли мастер-ноды кластера. С этой машины можно запускать spark-программы, загружать данные в/из HDFS и.т.д. В общем, основное рабочее окружение при работе с кластером - здесь, в консоли мастер-машины. Установим здесь всё что требуется (нам нужны только git и sbt):

>sudo yum install git

>yum install -y http://dl.bintray.com/sbt/rpm/sbt-0.13.5.rpm
 

Клонируем репозиторий...

>git clone https://github.com/AlexanderSavochkin/BookNgrams-filter

Собираем скрипт...

cd BookNgrams-filter
 
sbt clean compile assembly


создаём в кластерной файловой системе HDFS поддиректорию ngrams

~/ephemeral-hdfs/bin/hadoop fs -mkdir ngrams 
 
Кладём в кластерную поддиректорию ngrams список названий веществ

~/ephemeral-hdfs/bin/hadoop fs -put list-compoundnames.normalized.txt ngrams

Наконец, запускаем то, что собрали.

#Это всё одна строчка!
~/spark/bin/spark-submit --jars local:/root/hadoop-lzo/target/hadoop-lzo-0.4.20-SNAPSHOT.jar  --class net.chwise.dataaquisition.textmining.NGramFilter ngramsfilter-assembly-1.0.jar -t ngrams/list-compoundnames.normalized.txt -g s3n://datasets.elasticmapreduce/ngrams/books/20090715/eng-us-all/1gram/data -o ngrams/chemical-ngrams.tsv

Проверим, что всё стработало как задуманно. Результат должен появиться в поддиректории ngrams/chemical-ngrams.tsv.

~/ephemeral-hdfs/bin/hadoop fs -ls ngrams/chemical-ngrams.tsv
 
Скачиваем результат из кластера смотрим, что получилось

~/ephemeral-hdfs/bin/hadoop fs -get ngrams/chemical-ngrams.tsv

Когда кластер больше не нужен, можно "выключить" его такой командой:

$SPARK_HOME/ec2/spark-ec2 -k spark-cluster -i /home/asavochkin/Work/Projects/ChWiSe/spark-cluster.pem destroy test-spark-cluster

С этого момента мы ни за что не платим (но и серверов в нашем распоряжении больше нет).

Теперь очередь настоящего кластера из... пусть будет из 6 машин.

%SPARK_HOME/ec2/spark-ec2 -k spark-cluster -i /home/asavochkin/Work/Projects/ChWiSe/spark-cluster.pem -s 6 --region=us-east-1 --user-data=/home/asavochkin/Work/Projects/ChWiSe/lzo-scripts/lzo.sh launch test-spark-cluster

Снова устанавливаем весь необходимый софт как описано выше (git, sbt), забираем исходники проекта с github. собираем, копируем список названий веществ в кластер и запускаем нашу обработку данных.

#Это всё одна строчка!
~/spark/bin/spark-submit --jars local:/root/hadoop-lzo/target/hadoop-lzo-0.4.20-SNAPSHOT.jar  --class net.chwise.dataaquisition.textmining.NGramFilter ngramsfilter-assembly-1.0.jar -t ngrams/list-compoundnames.normalized.txt -g s3n://datasets.elasticmapreduce/ngrams/books/20090715/eng-us-all/1gram/data,s3n://datasets.elasticmapreduce/ngrams/books/20090715/eng-us-all/2gram/data,s3n://datasets.elasticmapreduce/ngrams/books/20090715/eng-us-all/3gram/data,s3n://datasets.elasticmapreduce/ngrams/books/20090715/eng-us-all/4gram/data,s3n://datasets.elasticmapreduce/ngrams/books/20090715/eng-us-all/5gram/data,s3n://datasets.elasticmapreduce/ngrams/books/20090715/eng-gb-all/1gram/data,s3n://datasets.elasticmapreduce/ngrams/books/20090715/eng-gb-all/2gram/data,s3n://datasets.elasticmapreduce/ngrams/books/20090715/eng-gb-all/3gram/data,s3n://datasets.elasticmapreduce/ngrams/books/20090715/eng-gb-all/4gram/data,s3n://datasets.elasticmapreduce/ngrams/books/20090715/eng-gb-all/5gram/data -o ngrams/chemical-ngrams.tsv

Обработка всех англоязычнх n-gramm заняла почти сутки. Размер результата примерно 64 Mb. Результат выглядиит так:
...
copper and sulphuric acid       464
histamine . they are    89
the dynamics of oxygen  64
a higher phosphate      65
than propane .  80

...

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

Стоит упомянуть об удобной возможности приостанавливать spark-кластер:
%SPARK_HOME/ec2/spark-ec2 -k spark-cluster -i /home/asavochkin/Work/Projects/ChWiSe/spark-cluster.pem stop test-spark-cluster

и потом, при необходимости перезапускать его

%SPARK_HOME/ec2/spark-ec2 -k spark-cluster -i /home/asavochkin/Work/Projects/ChWiSe/spark-cluster.pem start test-spark-cluster

В этом "приостановленном" состоянии инстанс стоит нам гораздо меньше денег, приходится платить только лишь за хранение образа (что на порядок дешевле). Хоть это и небесплатно, зато после перезапуска не нужно с самого начала устанавливать git, sbt, собирать проект и.т.д. Однако данные в ephemeral hdfs не переживут подобной приостановки.


суббота, 7 февраля 2015 г.

Камера Вильсона на коленке

Недавно по социальным сетям прошла "волна" постов и обсуждений опыта с созданием на коленке камеры Вильсона (или Cloud Chamber)



Об этом даже написал сайт meduza.io.

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

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

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

В общем, ограничусть выкладыванием фотографий и видео:

К одной из сторон камеры требуется прикрепить войлок, который потом пропитывается изопропиловым спиртом.






 Теперь в игру вступает сухой лёд. Он у нас был в форме гранул


...Просто добавь изопропанола!


Окончательный вид камеры. Внизу камеры - пищевая фольга


Видео с треком (в самом конце ролика)



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

Сайенс! У-у-у-у-ё!!!