Статьи

Обработка естественного языка с помощью Ruby: n-граммы

Доска "НЛП"

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

Один из самых основных методов в НЛП — это n- грамматический анализ, с которого мы начнем в этой статье!

Что такое n -gram Анализ

Учитывая очень простое предложение:

Быстрая, коричневая лиса, перепрыгнула через ленивого пса.

Для некоторого грамматического анализа было бы очень полезно разбить это предложение на последовательные пары слов:

(The, быстрый), (быстрый, коричневый), (коричневый, лиса),…

В приведенном выше примере мы разбили предложение на последовательные кортежи слов. Приведенные выше примеры представляют собой 2- граммы, более известные как «биграммы». 1- грамм называется «униграмма», а 3- грамм называется «триграмма». Для n- диаграмм с 4 или более членами, мы обычно просто называем это 4-граммами, 5-граммами и т. Д. Некоторые примеры приведены по порядку:

  • Unigram: (The), (quick), (brown), ...
  • Биграм: (The, quick), (quick, brown), (brown, fox), ...
  • Триграмма: (The, quick, brown), (quick, brown, fox), (brown, fox, jumps), ...
  • 4 грамма: (The, quick, brown, fox), (quick, brown, fox, jumps), (brown, fox, jumps, over), ...

Чем это полезно? Допустим, у нас есть огромная коллекция предложений (одним из которых является наше предложение «Быстрая коричневая лиса» сверху), и мы создали гигантский массив всех возможных биграмм в нем. Затем мы получаем пользовательский ввод, который выглядит следующим образом:

Мне очень нравятся коричневые быстрые лисы

Если мы также разделим это предложение на биграммы, мы можем с достаточной уверенностью сказать, что биграмма (brown, quick) скорее всего, грамматически неверна, потому что мы обычно встречаем эти два слова наоборот. Применение n- грамматического анализа к тексту является очень простым и мощным методом, который часто используется в задачах моделирования языка, подобных той, которую мы только что показали, и поэтому часто является основой более продвинутых приложений NLP (некоторые из которых мы рассмотрим в этой серии статей). ).

В приведенных нами примерах часть «граммы» в « n- граммах» может быть взята как «слово», но это не обязательно должно иметь место. Например, при секвенировании ДНК «граммы» могут означать один символ в последовательности пар оснований. Возьмем последовательность пар оснований «ATCGATTGAGCTCTAGCG» — мы могли бы сделать из этой последовательности биграммы, чтобы вычислить, какие пары встречаются чаще всего вместе, что позволяет нам предсказать, что может последовать в последовательности. Например, за «A» чаще всего следует «G», поэтому, если мы увидим «A» в следующий раз, мы можем предположить, что следующим элементом в последовательности будет «G».

Выбор хорошего источника данных

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

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

Для задач, специфичных для анализа языка, оказывается, что многие академические институты проводят огромные наборы данных для обучения для задач анализа с помощью n- грамматики. Эти огромные коллекции текстовых данных называются «текстовыми корпусами» (это множественное число, одна из этих коллекций называется «текстовым корпусом»). Эти корпуса являются эффективно помеченными наборами письменного текста.

В 1967 году был опубликован оригинальный сборник американского английского, который сегодня известен как «Коричневый корпус». Этот сборник был составлен с целью создания исчерпывающего отчета о том, как можно больше использовался американский английский в то время. Результатом стал гигантский набор данных предложений, который с достаточной точностью отражает частоту, с которой можно встретить определенные слова в обычных предложениях американского английского в 1967 году.

После первоначальной публикации к нему было применено «тегирование части речи» (часто сокращенно обозначаемое как «маркировка POS») (следовательно, теперь его называют корпусом). POS-теги — это способ пометить каждое слово во всем корпусе своей ролью (глагол, существительное). Википедия поддерживает список тегов , которые использует Brown Corpus , что является исчерпывающим, если не сказать больше!

Brown Corpus остается одной из самых популярных корпораций для анализа, поскольку она бесплатна для некоммерческих целей и легко доступна. Есть много других доступных корпусов , в том числе Google n -grams Corpus, который содержит более 150 миллиардов слов!

Получение коричневого корпуса

В этой первой части серии мы собираемся получить копию «Коричневого корпуса» и провести с ней простой анализ n- граммы. Версия «Корпуса Брауна» с тегами доступна в виде файла ZIP по адресу http://nltk.googlecode.com/svn/trunk/nltk_data/packages/corpora/brown.zip .

Загрузите и распакуйте этот ZIP-файл, чтобы у вас остался brown каталог, содержащий много файлов .txt :

sh $ ll brown total 10588 -rw-r--r-- 1 nathan nathan 20187 Dec 3 2008 ca01 -rw-r--r-- 1 nathan nathan 20357 Dec 3 2008 ca02 -rw-r--r-- 1 nathan nathan 20214 Dec 3 2008 ca03 (...)

Написание класса n -grams

Нам нужен способ генерировать набор n -грамм из предложения. Нужно взять предложение, разбить его на куски и вернуть последовательных членов в группы n long. К счастью, в Ruby это тривиально:

 def ngrams(n, string) string.split(' ').each_cons(n).to_a end 

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

Давайте обернем это в действительно простой класс:

 class Ngram attr_accessor :options def initialize(target, options = { regex: / / }) @target = target @options = options end def ngrams(n) @target.split(@options[:regex]).each_cons(n).to_a end def unigrams ngrams(1) end def bigrams ngrams(2) end def trigrams ngrams(3) end end 

Мы добавляем метод конструктора, который принимает target строку для работы и позволяет людям по желанию определить, как мы разбиваем данную цель перед генерацией n- грамм (по умолчанию это делится на слова, но вы можете изменить это на расщепление в символы, в качестве примера.)

Затем мы определяем три вспомогательных метода для наших наиболее распространенных n- грамм, а именно: unigrams , unigrams и trigrams .

Давайте посмотрим, работает ли это:

 bigrams = Ngram.new("The quick brown fox jumped over the lazy dog").bigrams puts bigrams.inspect # = >[["The", "quick"], ["quick", "brown"], ["brown", "fox"], ["fox", "jumped"], ["jumped", "over"], ["over", "the"], ["the", "lazy"], ["lazy", "dog"]] 

Успех! Мы получили именно тот результат, к которому стремились, и практически не написали никакого кода для этого! Теперь давайте воспользуемся этим классом, чтобы сделать что-то классное с Коричневым корпусом, который мы скачали.

Извлечение приговоров из корпуса

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

Базовое предложение в помеченном корпусе выглядит примерно так:

/ В Фултоне / округ np-tl / nn-tl Grand / jj-tl сказал жюри / nn-tl / vbd Friday / nr an / при расследовании / nn of / в недавних / np $ / первичных / jj выборах в Атланте / nj / nn представил / vbd / no / at доказательство / nn ”/”, что / cs any / dti неисправностей / nns занял / vbd место / nn ./.

Слова и теги разделяются с помощью / . Теги описывают вид слов, на которые мы смотрим, например, «существительное» или «глагол», и часто время и роль слова, например, «прошедшее время» и «множественное число».

Учитывая строку, мы знаем, что первое, что нам нужно сделать, это получить массив всех слов:

 words = sentence.strip.split(' ') 

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

Как только у нас будет этот массив слов, нам нужно разделить каждое слово и оставить только первую часть:

 words.map do |word| word.split('/').first end.join(' ') 

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

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

 def sentences(path) File.open(@path) do |file| file.each_line.each_with_object([]) do |line, acc| stripped_line = line.strip unless stripped_line.nil? || stripped_line.empty? acc << line.split(' ').map do |word| word.split('/').first end.join(' ') end end end end 

Один интересный метод, который мы здесь each_with_objecteach_with_object . Это любезно предоставлено модулем Enumerable и фактически является специализированной версией inject . Вот пример без использования любого из этих методов:

 sentences = [] file.each_line do |line| # ... unless stripped_line.blank? # ... sentences << words.map do |word| # ... end end end 

Как видите, теперь нам нужно самим управлять переменной sentences . Метод inject приближает нас на один шаг:

 file.each_line.inject([]) do |acc, line| # ... unless stripped_line.blank? # ... acc << words.map do |word| # ... end end acc end 

Тем не менее, мы должны вернуть acc (сокращение от «сумматора», которое является общей номенклатурой для объекта, который строится при использовании inject или других вариантов стиля сгиба.) Если мы этого не сделаем, переменная acc при следующем запуске цикла будет стать тем, что мы вернули в последнем цикле. Таким образом, родился each_with_object , который заботится о том, чтобы всегда возвращать объект, который вы строите, не беспокоясь об этом.

Совет: Одно из изменений между each_with_object и each_with_object заключается в том, что параметр acc передается в блок в обратном порядке. Обязательно ознакомьтесь с документацией, прежде чем использовать эти методы, чтобы убедиться, что вы правильно ее выбрали!

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

 class BrownCorpusFile def initialize(path) @path = path end def sentences @sentences ||= File.open(@path) do |file| file.each_line.each_with_object([]) do |line, acc| stripped_line = line.strip unless stripped_line.nil? || stripped_line.empty? acc << line.split(' ').map do |word| word.split('/').first end.join(' ') end end end end end 

Наддув класса Корпус

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

 class Corpus def initialize(glob, klass) @glob = glob @klass = klass end def files @files ||= Dir[@glob].map do |file| @klass.new(file) end end def sentences files.map do |file| file.sentences end.flatten end def ngrams(n) sentences.map do |sentence| Ngram.new(sentence).ngrams(n) end.flatten(1) end def unigrams ngrams(1) end def bigrams ngrams(2) end def trigrams ngrams(3) end end 

Класс супер прост. У нас есть конструктор, который использует шаблон glob для выбора файлов в нашей папке corpus ( brown папка, которую вы извлекли ранее), и класс для передачи каждого найденного файла. Если вы не знакомы с шаблонами глобуса, они часто используются в терминале для подстановочных символов, например. *.txt — это шаблон glob, который найдет все файлы, оканчивающиеся на .txt .

Затем мы определяем files и методы sentences . Первый использует глобус, чтобы найти все подходящие файлы, зацикливает их и создает новый экземпляр класса, который мы передали конструктору. Последний вызывает метод files и перебирает созданные классы, вызывая метод sentences класса и выравнивая результат в Array на одном уровне.

Теперь для некоторых удобных методов, которые обертывают наш класс Ngram . Метод ngrams вызывает sentences и возвращает массив n -грамм для этих предложений. Обратите внимание, что когда мы вызываем flatten , мы просим его сгладить только один уровень, иначе мы потеряем Array n -gram. unigrams , unigrams и trigrams — это всего лишь вспомогательные методы, которые делают вещи лучше.

Делать некоторые n- грамматический анализ

У нас есть готовый базовый инструмент, поэтому давайте попробуем и проведем некоторый анализ в качестве теста на корпусе, пытаясь увидеть, сколько собственных существительных мы можем извлечь наивным способом. Этот метод называется «распознавание именованных сущностей» в языке NLP.

 corpus = Corpus.new('brown/c*', BrownCorpusFile) 

Мы начинаем с создания нового экземпляра класса Corpus мы написали, и говорим, что корпус, на который мы смотрим, использует формат BrownCorpusFile .

Открыв самый первый файл корпуса в текстовом редакторе ( ca01 ), есть много имен собственных (включая места и имена), упомянутых со словом «из» заранее. Примером может служить предложение «Джанет Джосси из Северных равнин […]». Наиболее подходящие существительные, скорее всего, будут состоять из двух слов или меньше, поэтому давайте пройдемся по всем предложениям в триграммах, ища что-нибудь, у кого первый член «из» и второй член начинаются с заглавной буквы. Мы включим в результат третьего члена триграммы, если он также начинается с заглавной буквы.

Выходной формат, который мы ищем — это Hash который выглядит примерно так:

 { "Some Noun" => 2 } 

Где 2 в этом примере — это число раз, когда данное имя существительное встречалось.

Вот решение этой проблемы в полном объеме:

 capitals = ('A'..'Z') results = Hash.new(0) corpus.trigrams.each do |trigram| if trigram.first == "of" && capitals.include?(trigram[1].chars.first) result = [trigram[1]] if capitals.include?(trigram[2].chars.first) result << trigram[2] end results[result] += 1 end end 

Пример начинается с определения диапазона, который содержит все заглавные буквы. Этот диапазон используется позже, чтобы проверить, начинаются ли члены триграммы с заглавной буквы. Мы также создаем результаты Hash , хотя мы используем для этого Hash.new(0) , который устанавливает все значения в 0 по умолчанию. Это позволяет нам увеличивать значения каждого ключа, не беспокоясь о том, был ли ключ создан и установлен ранее.

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

  • Проверьте, является ли первый член «из».
  • Проверьте, является ли первый символ второго члена заглавной буквой.
  • Если оба они верны, подготовьтесь к сохранению только второго члена, создав Array с только вторым членом в нем.
  • Если третий член также начинается с заглавной буквы, добавьте третьего члена в Array .
  • Соедините Array с пробелом и сохраните его в Hash результатах, увеличивая значение этого ключа на единицу.

В итоге мы получаем результаты Hash заполненные парами ключ / значение. Ключи — это правильные существительные, которые мы нашли, а значения — количество раз, когда мы видели это имя в корпусе. Вот топ-10 после запуска этого кода по всему корпусу Brown:

 English: 22 Washington: 23 India: 23 New York: 26 Europe: 26 State: 35 American: 46 America: 61 God: 100 Af: 104 

За исключением «Af» (которое, если вы углубитесь в корпус, вы обнаружите, что это имя, данное функции для масштабных функций акселерометра в физике — как интересно!), Остальные результаты очень интересны — хотя какие выводы вы можете сделать от них целиком зависит от вас!

Исходный код

Исходный код для всего, что мы написали в этой статье, доступен в репозитории на GitHub: https://github.com/nathankleyn/ruby nlp . В частности, см. Файл для этой части серии по адресу https://github.com/nathankleyn/rubynlp/blob/master/examples/part_one.rb .

В следующий раз

В следующей части этой серии мы рассмотрим, как стать более умными с нашими n- граммами, исследуя цепочку Маркова. Это увлекательное применение математической вероятности позволяет нам генерировать псевдослучайный текст путем аппроксимации языковой модели.