Этот текст, при его очевидной абсурдности и лишённости смысла, мог показаться вам смутно знакомым. Это начало поэмы «Москва – Петушки», в котором слова, принадлежащие одной части речи, перемешаны между собой в случайном порядке.

Насколько сложно в наш век всеобщего проникновения машинного обучения и NLP набросать такую игрушку? О, это очень легко.

Начнём с лёгкой (хехе) части — архитектурной. Для простоты напишем наш рандомизатор в виде консольной утилиты, принимающей оригинальный текст в stdin и печатающей результат в stdout. Вся логика нашей программы легко бьётся на четыре основных этапа:

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

Давайте сразу подготовим из этого каркас программы.

from dataclasses import dataclass
import sys
from typing import List


Tag = str


@dataclass
class TaggedToken:
    text: str
    tag: Tag


def tokenize_text(text: str) -> List[str]:
    ...


def tag_tokens(tokens: List[str]) -> List[TaggedToken]:
    ...


def shuffle_tokens(tokens: List[TaggedToken]) -> List[TaggedToken]:
    ...


def detokenize_tokens(tokens: List[TaggedToken]) -> str:
    ...


if __name__ == "__main__":
    input_text = "".join(sys.stdin)
    tokens = tokenize_text(input_text)
    tagged_tokens = tag_tokens(tokens)
    sys.stderr.write(f"Tagged tokens:\n{tagged_tokens!r}")
    shuffled_tokens = shuffle_tokens(tagged_tokens)
    result_text = detokenize_tokens(shuffled_tokens)
    print(result_text)

Хорошая структура – залог успеха.
 

Берём NLTK


Лёгким росчерком клавиатуры ставим в наш virtualenv NLTK — одну из самых популярных библиотек на Python для работы с естественными языками.

python3 -m pip install nltk

Токенизация текста — одна из самых простых задач NLP, так что заморачиваться нам здесь сильно не придётся:

import nltk


def tokenize_text(text: str) -> List[str]:
    return nltk.word_tokenize(text)

Давайте добавим тест и проверим, что вышло:

# tests/tokenizer_test.py

from mixer import tokenize_text


def test_tokenizer():
    assert tokenize_text("hello world") == ["hello", "world"]

Можно запустить прямо в интерфейсе PyCharm (переключите тестовый фреймворк на pytest) и увидеть, что всё работает как ожидается.

Тегировать токены в NLTK тоже крайне просто:

def tag_tokens(tokens: List[str]) -> List[TaggedToken]:
    return [
        TaggedToken(text=token, tag=tag)
        for token, tag in nltk.pos_tag(tokens)  # тут вся магия
    ]

 

Пишем алгоритм


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

Спойлер: некоторые слова лучше не перемешивать. Давайте заведём для них виртуальный тег и перемешивать принадлежащие ему токены не будем.

DONT_MIX_WORDS = {
    "a", "an", "the",
    "am", "is", "are", "been", "was", "were",
    "have", "had",
}
DONT_MIX_MARKER = "DONT_MIX"


def shuffle_tokens(tokens: List[TaggedToken]) -> List[TaggedToken]:
    tokens = [
        token if token.text not in DONT_MIX_WORDS else TaggedToken(text=token.text, tag=DONT_MIX_MARKER)
        for token in tokens
    ]
    tokens_by_tag: MutableMapping[Tag, List[TaggedToken]] = defaultdict(list)
    index_to_tag: MutableMapping[int, Tag] = {}
    index_to_subindex: MutableMapping[int, int] = {}
    for idx, token in enumerate(tokens):
        index_to_tag[idx] = token.tag
        index_to_subindex[idx] = len(tokens_by_tag[token.tag])
        tokens_by_tag[token.tag].append(token)
    for tag, curr_tokens in tokens_by_tag.items():
        if tag != DONT_MIX_MARKER:
            random.shuffle(curr_tokens)
    return [
        tokens_by_tag[index_to_tag[idx]][index_to_subindex[idx]]
        for idx in range(len(tokens))
    ]

Тест тут сочинить уже сложнее, но я на скорую руку придумал вот такой:

def test_shuffle():
    tokens = [TaggedToken(str(i), "TAG_1") for i in range(100)] + \
             [TaggedToken(str(i), "TAG_2") for i in range(100)]
    shuffled_tokens = shuffle_tokens(tokens)
    assert tokens != shuffled_tokens
    assert sorted(tokens[:100], key=repr) + sorted(tokens[100:], key=repr) == \
           sorted(shuffled_tokens[:100], key=repr) + sorted(shuffled_tokens[100:], key=repr)

С вероятностью порядка 2E-158 он упадёт. Потомки ругнутся на нас и наши чёртовы флапающие тесты, колонизируя туманность Андромеды.

Здесь, конечно, ещё надо потестировать обработку наших особенных слов и всякое корнеркейсы, но тестов мне и на работе достаточно пишется, пойдём дальше к делу.
 

Детокенизируем


Здесь мы возвращаемся к NLTK — собирать текст из токенов он тоже умеет!

from nltk.tokenize.treebank import TreebankWordDetokenizer


def detokenize_tokens(tokens: List[TaggedToken]) -> str:
    return TreebankWordDetokenizer().detokenize([token.text for token in tokens])

Наконец программу можно запустить! Я добавил в test_en.txt первый абзац статьи Википедии про одну восточноевропейскую сверхдержаву.

$ python3 mixer.py < test_en.txt
Europe, and the sixteen Europe, is a time spanning Earth Saint or Asia Europe . It is the largest language in the country, encompassing in million 146.2 inhabited kilometres, and covering more in million while Federation's Russian country world . Petersburg has of cultural nation nations, and has the most zones across any world with the land, in spoken native borders . they extends a capital than 17 one-eighth; and is the most sovereign nation of Northern, and the ninth-most Russian world in the language . Slavic, the country, is the largest city in Russia, over Moscow Russia is the country's populous country and populous centre . Russians are the largest Europe and European population; It speak square, the most eleven Eastern area, and the most second-largest spoken city of Slavic.

Уже хорошо. Есть, конечно, косяки – пробел перед точкой, да и надо немного поправить регистр. Пробел перед точкой я вырежу str.replaceом, а для учёта регистра сделаю простой фокус — скажу, что все слова, начинающиеся с большой буквы не после точки (в исходном тексте) — имена собственные, а с маленькой — нарицательные. Дальше поправлю регистр в соответствии с этим правилом.

def detokenize_tokens(tokens: List[TaggedToken], private_nouns: Set[str]) -> str:
    cased_tokens = []
    for prev_token, token in zip([TaggedToken(".", ".")] + tokens, tokens):
        if prev_token.text == ".":
            cased_tokens.append(token.text[0].upper() + token.text[1:])
        elif token.text.lower() in private_nouns:
            cased_tokens.append(token.text[0].lower() + token.text[1:])
        else:
            cased_tokens.append(token.text)
    result = TreebankWordDetokenizer().detokenize(cased_tokens)
    result = result.replace(" .", ".")
    return result

Есть другие теггеры


Давайте двинем к самому весёлому — русскому языку. В NLTK есть токейнайзер для русского языка, но качество его работы оставляет желать лучшего. Давайте прогоним его на уже известной нам поэме (перед этим нужно скачать ресурс для NLTK: python -c "import nltk; nltk.download('averaged_perceptron_tagger_ru')").

$ python3 mixer.py < test_ru.txt
Всех говорят. Вечер: разу, про ничего я видел с него, а сам ведь начала не видел, Сколько Кремль уже, качестве люди,) выпил как на –. Был с юг из конец Ото конца (с – вокруг опыту. В Савеловском для мест. Очень что как крутился тысячу и Вот севера ни слышал Москве, ни чтоб насквозь еще не проходил, декокта или только целый раз придумали по тех стакан, и не потому а вчера пьян напившись: я, и не знаю в зубровки, попало на запада восток раз, так и на Кремля увидел, что по разу утреннего Кремль похмелюги Все лучшего опять не вышел,

Видно, что плохо. Если копнуть, вылезает много недоброго. NLTK знает довольно мало о русском языке; например, практически ничего про падежи и склонения.

Но в статьях по тегированию русского языка сравниваются не с NLTK. Там сравниваются с TreeTagger – давайте и мы его подтянем.

Поскольку это не pure Python пакет, процесс резко усложняется. Ниже пишу, как скачать на MacOS:

# Скачиваем саму утилиту и вспомогательные скрипты
wget https://www.cis.uni-muenchen.de/~schmid/tools/TreeTagger/data/tree-tagger-MacOSX-3.2.3.tar.gz https://www.cis.uni-muenchen.de/~schmid/tools/TreeTagger/data/tagger-scripts.tar.gz
mkdir -p treetagger && tar -xzf tree-tagger-MacOSX-3.2.3.tar.gz --directory treetagger && tar -xzf tagger-scripts.tar.gz --directory treetagger
# Скачиваем поддержку русского языка
wget https://www.cis.uni-muenchen.de/~schmid/tools/TreeTagger/data/russian.par.gz
gunzip -c russian.par.gz > treetagger/lib/russian.par

Интерфейс нам предоставлен только консольный. Что поделать, будем запускать через subprocess:

def tokenize_and_tag_text(text: str) -> List[TaggedToken]:
    output = subprocess.run("cmd/tree-tagger-russian",
                            cwd="treetagger",
                            input=text.encode("utf-8"),
                            capture_output=True).stdout.decode("utf-8")
    result = []
    for line in output.strip().split("\n"):
        text, tag, _ = line.split("\t")
        result.append(TaggedToken(text=text, tag=tag))
    return result

Добавим выбор теггера в зависимости от аргументов командной строки:

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Shuffle words in text.")
    parser.add_argument("--tagger", type=str, default="nltk", choices=["nltk", "treetagger"])
    args = parser.parse_args(sys.argv)

    input_text = "".join(sys.stdin)
    if args.tagger == "nltk":
        tokens = tokenize_text(input_text)
        tagged_tokens = tag_tokens(tokens)
    elif args.tagger == "treetagger":
        tagged_tokens = tokenize_and_tag_text(input_text)
    private_nouns = calc_private_nouns_set(tagged_tokens)
    sys.stderr.write(f"Tagged tokens:\n{tagged_tokens!r}")
    shuffled_tokens = shuffle_tokens(tagged_tokens)
    result_text = detokenize_tokens(shuffled_tokens, private_nouns)
    print(result_text)

У него всё ещё есть проблемы с пунктуацией. Здесь уж я просто запретил перемешивать все пунктуационные токены.

Вот теперь хорошо.

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

Чем дольше текст — тем веселее перемешивание (и тем правильнее регистр у слов). Have fun.

Традиционно, весь код в покоммитном изложении на Github.

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


  1. quwarm
    23.07.2021 17:37

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

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


    1. saluev Автор
      23.07.2021 17:55
      +4

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


  1. Teplo_Kota
    23.07.2021 18:11
    +2

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


  1. sim31r
    24.07.2021 00:37
    -1

    Похоже чем-то на сервис Яндекса «Балабоба»

    https://habr.com/ru/news/t/563390/


  1. nikolay_karelin
    24.07.2021 08:00

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


  1. Alexey2005
    24.07.2021 13:40
    +3

    Токенизация текста — одна из самых простых задач 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-м километром. пока я делился с вами восторгом моего чувства, пока посвящал вас в тайны бытия, — меня тем временем рождали мятежную науку и декабризм… а когда они, наконец, рассветет! когда же взойдет заря моей тринадцатой пятницы!»


    1. qwert65
      26.07.2021 15:02

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


      1. Alexey2005
        27.07.2021 00:31
        +1

        Старая добрая цепочка Маркова, только не словарная, как в большинстве примеров, а символьная (т.е. основной единицей являются отдельные символы).
        В данном примере используется цепь 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;
        }


        1. qwert65
          27.07.2021 13:32

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

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

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


          1. Alexey2005
            27.07.2021 15:20

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

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


            1. nickolaym
              29.07.2021 17:40

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

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

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


        1. nickolaym
          29.07.2021 17:30

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

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

          ---

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

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

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

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