Как стать автором
Обновить

Комментарии 12

Единственный вопрос — зачем? Цель всегда должна быть в любой статье. Иначе получаем «статья для статьи».

Вы пишете, что это некая «игрушка». Но я могу смотреть на это только как на placeholder text generator (генератор бессмысленного текста-заполнителя по типу lorem ipsum). Других применений не вижу.

Иногда я делаю вещи просто чтобы мне было весело.

Депутата сделали, теперь министра начинайте.

Советую SpaCy попробовать ;)

Токенизация текста — одна из самых простых задач NLP
Для английского языка — возможно, вот только мне ещё ни разу не встречался русскоязычный токенизатор, который опознавал бы части речи и падежи/склонения с менее чем 1 ошибкой на 1000 слов.
Генератор абсурда за пять минут
ИМХО, самый простой генератор абсурда выглядит
примерно так
from collections import *
import io
from random import random

def train_char_lm(fname, order=4):
    with io.open(fname, encoding="utf-8") as f:
        data = f.read().lower()

    lm = defaultdict(Counter)
    pad = "~" * order
    data = pad + data
    for i in range(len(data)-order):
        history, char = data[i:i+order], data[i+order]
        lm[history][char]+=1
    def normalize(counter):
        s = float(sum(counter.values()))
        return [(c,cnt/s) for c,cnt in counter.items()]
    outlm = {hist:normalize(chars) for hist, chars in lm.items()}
    return outlm

def generate_letter(lm, history, order):
        history = history[-order:]
        dist = lm[history]
        x = random()
        for c,v in dist:
            x = x - v
            if x <= 0: return c

def generate_text(lm, order, nletters=1000,history=None):
	if (history is None):
		history = "~" * order
	else:
		history=("~"*order)+history
		history=history[-order:]
	out = []
	for i in range(nletters):
		c = generate_letter(lm, history, order)
		history = history[-order:] + c
		out.append(c)
	return "".join(out)

ORDER_NUM = 13
lm = train_char_lm("Moskva-Petushki.txt", order=ORDER_NUM)

print(generate_text(lm, ORDER_NUM))

Он очень прост, общий принцип объясняется на пальцах за несколько минут, а сам алгоритм может быть реализован на любом языке программирования без внешних зависимостей менее чем за час.
При этом на малых объёмах обучающих данных (менее 1Мб текста) ни одна нейросеть и близко не стояла по качеству текстогенерации (им всем требуется куда больше текста).
Выдаёт примерно такое
«помолитесь, ангелы, за меня. да будет светел мой путь, да не преткнусь о камень, да увижу город, по которому столько томился. а пока — вы уж простите меня — пока присмотрите за моим чемоданчиком, если я отлучался, — когда они от меня отлетели? в районе кучино? так. значит, украли между кучино и 43-м километром. пока я делился с вами восторгом моего чувства, пока посвящал вас в тайны бытия, — меня тем временем рождали мятежную науку и декабризм… а когда они, наконец, рассветет! когда же взойдет заря моей тринадцатой пятницы!»

добрый день! если Вас не затруднит, не могли бы объяснить общий принцип на пальцах?) если честно, в питоне не разбираюсь от слова совсем, а суть интересна.

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

На первом этапе происходит построение модели. Для этого берётся обучающий текст, переводится в lowercase (можно и без этого, но тогда текста понадобится больше, «Москва-Петушки» довольно короткое произведение). Затем создаётся пустой словарь (структура, в которую можно добавлять пары ключ-значение, причём ключ символьный, а в качестве значения выступают списки).
Берём самое начала текста, первые 13 его символов:
"все говорят: "

Ищем эту строку в словаре. Её там нет, т.к. словарь пуст. Значит, добавляем ключ с этой строкой в качестве названия и пустым списком в качестве значения:
dict={
 "все говорят: " : []
}

Сейчас список возле ключа пуст, но в дальнейшем он будет заполняться структурами вида символ-количество:
listItemType={
 sym:char,
 count:int
}

Смотрим, какой символ идёт в тексте после нашего исходного фрагмента. Это символ «к». Добавляем его в список, в качестве счётчика (count) поставив 1. Словарь будет выглядеть так:
dict={
 "все говорят: " : ["к":1]
}

Далее сдвигаем точку старта на 1 символ, смотрим следующую строку в 13 символов, это:
"се говорят: к"

Точно так же ищем в словаре. Её там нет, добавляем вместе со списком и следующим символом:
dict={
 "все говорят: " : ["к":1]
 "се говорят: к" : ["р":1]
}

Продолжаем двигаться вперёд в цикле:
dict={
 "все говорят: " : ["к":1]
 "се говорят: к" : ["р":1]
 "е говорят: кр" : ["е":1]
}

Теперь допустим, что рано или поздно какой-то кусок строки встретился повторно. В этом случае он найдётся в словаре, и мы смотрим, что за символ идёт после него. Допустим, на этот раз встретился «у». Добавляем в список у этого значения:
dict={
 "все говорят: " : ["к":1]
 "се говорят: к" : ["р":1]
 "е говорят: кр" : ["е":1, "у":1]
//other values
//...
}

Если после того же куска строки встретился тот же символ, просто увеличиваем счётчик этого символа:
dict={
 "все говорят: " : ["к":1]
 "се говорят: к" : ["р":1]
 "е говорят: кр" : ["е":1, "у":2]
//other values
//...
}

В итоге, когда мы так пробежимся по всему тексту, у нас будет словарь, где после каждого кусочка текста указаны те символы, которые после него могут встретиться, и в каком количестве они встречаются. Вот так:
dict={
 "все говорят: " : ["к":1, "о":10]
 "се говорят: к" : ["р":1]
 "е говорят: кр" : ["е":45, "у":2, "а":21, "ы":1, "о":17]
//other values
//...
}

Теперь мы можем вычислить вероятности того, что после данного кусочка встретится тот или иной символ. Для этого надо суммировать общее количество:
"е говорят: кр" : ["е":45, "у":2, "а":21, "ы":1, "о":17]
sum=45+2+21+1+17=86

после чего каждое значение делим на эту сумму, получаем дробные вероятности:
"е":(45/86=0.52), "y":(2/86=0.02), ...

Модель готова! Чтобы не строить повторно при следующем запуске программы, её можно сбросить на диск. Для генерации текста берём в качестве начального фрагмента любую строчку из словаря, и в цикле начинаем дописывать к ней символ за символом. Каждый раз берём «хвост» нашего сгенерированного текста длиною 13 символов, ищем этот ключ в словаре, и в соответствующем списке там лежат те символы, которые могут встречаться в тексте после данного фрагмента, и вероятности их появления. Далее просто генерируем рандомный символ из списка с соответствующим распределением вероятностей.

Этому алгоритму можно скормить любой структурированный набор символов. Не только текст, но и например программный код. Я скормил ему полный текст библиотеки jQuery, и на выходе он мне сгенерировал такое:
Сгенерированный код
/*!
 * jquery javascript library v3.6.0
 * https://js.foundation/
 *
 * date: 2021-02-16
 */
( function( global, factory ) {

	"use strict";

	if ( typeof selector === "string" ) {
			matched = [],
			targets = typeof selectors !== "string" ) {
			gotoend = clearqueue;
			clearqueue = type;
					}
					return function( elem, options, i );
				} );
				return elem.selectedindex = -1;
				}
				return tween;
		} ]
	},

	tweener: function( name, hook ) {
		object[ flag ] = true;
	} );
	return object;
}

невероятно подробное объяснение, спасибо большое! есть только один момент, не до конца понятный:

Для генерации текста берём в качестве начального фрагмента любую строчку из словаря

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

Или редактировать, или выбирать по эвристикам. Например, отбирать только то, что начинается с пробела. Или то, что начинается с комбинации «точка-пробел», а потом, когда текст будет сгенерирован, начальную точку отбрасывать. Нейросети в общем-то страдают от той же проблемы — там текст тоже приходится причёсывать, набрасывая поверх выхлопа разного рода фильтры.

П.С.: Вообще, очень многие задачи, которые сейчас решаются нейросетями, раньше выполняли при помощи куда более простых алгоритмов. Просто на удивление простых. Даже алгоритм синтеза речи из текстовой строки укладывали в 4Кб-демки времён DOS. И хоть звучало очень механически, зато работало безо всяких облаков и тонн внешних зависимостей, да и сам код синтезатора занимал лишь пару сотен строчек на чистом C.

Нейросеть выгодно отличается тем, что её легко бездумно масштабировать. Добавили новые фичи - увеличили размерность входного и/или выходного вектора, обучили, работает! Добавили новые образцы - обучили, работает! Плохо работает - увеличили размерность скрытых слоёв, обучили, - работает!

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

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

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

Конечно, для бредогенератора можно поиграться, уменьшив порядок - в какой-то момент цепочка перестанет следить за контекстом и начнёт выдавать несогласованные по падежам/лицам словосочетания (потому что окончания выпадут из текущего окна). А потом станет куражиться в творчестве длинных слов.

---

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

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

Этот подход использовался в распознавании речи - произведения моделей произношения букв (триграммы), слов из букв и словосочетаний из слов (n-граммы, где n тоже равно три - этого хватало). Можно поискать по аббревиатурам HMM, LVCSR.

Программируется довольно несложно, но, если не аккуратничать, то люто жрёт память. Я этим занимался лет семь назад.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий