Вычисление стихотворного размера

в 10:33, , рубрики: .net, C#, natural language processing, Алгоритмы, обработка естественного языка, поэзия, Программирование

Привет. Я последние пару лет играюсь с естественной речью на русском языке. Решил поделиться своим опытом по работе с поэзией. Будет две статьи: вот эта и про рифму (когда дойдут руки всё доделать).

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

Немного теории

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

Буря мглою небо кроет

| – | – | – | –

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

Моё море, прошу тебя, не выплюни меня на берег

– | | – – | – | – | – – – | – | –

Считать ли поэзией, например, верлибр — вопрос дискуссионный и выходящий за рамки данного текста. Как писал Томас Элиот: «Автор верлибра свободен во всём, если не считать необходимости создавать хорошие стихи».

Стихотворные размеры

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

Название размера

Схема

Пример

Хорей

| – | – | – | –

Буря мглою небо кроет

Ямб

– | – | – | – |

Товарищь верь, взойдёт она

Дактиль

| – – | – –

Тучки небесные, вечные странники

Амфибрахий

– | – – | –

Есть женщины в русских селеньях

Анапест

– – | – – |

На заре ты её не буди

Необязательные детали

Если зарываться в стихосложение далее, то в некоторых языках (но не русском) длительность, с которой вы произносите тот или иной гласный звук, является значимой и влияет на восприятие текста. Этот факт использует, например, гекзаметр — размер, которым написаны Илиада и Одиссея. С точки зрения русского языка он неотличим от дактиля, но на древнегреческом есть особенности в чередовании долгих и кратких слогов. Такие размеры в целом называются силлабо-метрическими.

Навскидку задача и правда выглядит простой: мы должны расставить ударения в исходной строке и сравнить её с одной из пяти схем. Где совпало — там и размер.

Ударения

Некоторое время назад я написал библиотеку Nestor на основе найдённого когда-то в сети словаря под авторством Михаила Хагена, который является переработанным словарём словоформ Зализняка. Эта библиотека по смыслу и по устройству похожа на pymorphy2, но написана на C# и содержит в себе ударения, что стало для меня ключевой причиной того, зачем я вообще её делал. В репозитории есть словарь, если вы захотите переписать её на свой любимый язык.

В русском языке есть омонимы с ударениями на разные слоги. Если человек написал с клавиатуры слово «замок», мы не знаем, что именно он имел ввиду: замок — средневековая крепость, или замок — устройство для блокировки двери. Заставлять пользователя размечать ударения вручную это примерно то же самое, что заставлять его самому назвать стихотворный размер — не наш путь. Кстати, при работе с навыками для голосовых ассистентов (Алиса, Салют, Маруся) формально у системы есть данные о том, на какой слог человек сделал ударение, но по факту эти данные не выдаются, и разработчику навыка просто приходит такое же обезличенное слово, как если бы ввод был текстовым.

Итак, у любого слова может быть некоторое число возможных ударений. Минимально 1 — когда ударение совершенно точно известно, и нет никаких других омонимов (частным случаем этого пункта являются слова с буквой ё). Максимально — по числу слогов в слове, когда ударение, например, вообще неизвестно, слово отсутствует в словаре, тогда стоит предположить, что ударным может быть любой слог.

Интересный факт про ударения

Всем известны слова с двумя ударениями, такие, как замок и замок. Я задумался, а бывает ли с тремя? Оказалось да, таких слов около пятнадцати. Вот, например, слово вывозите может иметь ударения на любом из первых трёх слогов:

  1. Вы сейчас вывозите себя в этой грязи, перестаньте!

  2. А по каким дням вы вывозите мусор?

  3. Эй, вы двое, вывозите отсюда все свои вещи!

Круто, да? Ещё пример такого слова: округа, может иметь ударение на любом из своих слогов. Можете сами поприкидывать варианты в качестве упражнения.

Слов более чем с тремя ударениями в словаре Хагена-Зализняка не нашлось.

В общем, первая сложность:

  • Возможное ударение в слове может приходиться на несколько слогов.

Односложные слова

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

Мороз и солнце; день чудесный!

Ещё ты дремлешь, друг прелестный —

<...>

Скользя по утреннему снегу,

Друг милый, предадимся бегу

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

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

Вот ямбом:

Рубился я в картишки,

Сел на пол сдал по три,

За ними были фишки,

А после — пузыри.

А вот амфибрахием:

Играем в картишки,

Сел на пол, сдал по три,

Потом будут фишки,

А дальше — посмотрим.

Прочитайте вслух, так будет понятнее. Да простят меня настоящие поэты.

Таким образом, вторая сложность:

  • Односложные слова могут быть и в ударной и в безударной позиции.

Алгоритм

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

Для начала разметим строку. Для каждого слова вернём массив StressType[], индексы в котором соответствуют номерам слогов в слове (начиная с нуля). А значения могу быть такими: «слог точно ударный», «слог точно безударный» и «слог может быть ударным».

public enum StressType
{
    StrictlyUnstressed,
    CanBeStressed,
    StrictlyStressed,
}

Слоги считаем по числу гласных. Для нуля гласных ответ пустой. Если в слове один слог, то сразу возвращаем для него CanBeStressed.

На вход в метод приходит string word.

int vCount = word.Count(NestorMorph.IsVowel); // количество гласных

if (vCount == 0)
{
	return Array.Empty<StressType>();
}

if (vCount == 1)
{
	return new[] { StressType.CanBeStressed };
}

Далее логика для многосложных слов. Если слово есть в нашем словаре, получаем все его ударения и складываем в HashSet номера ударных слогов (тут они начинаются с единицы).

var knownStressedVowelNumbers = new HashSet<int>();
            
Word[] wordInfos = _nestor.WordInfo(word); // все найденные в словаре лексемы
foreach (Word wordInfo in wordInfos)
{
	WordForm[] exactForms = wordInfo.ExactForms(word); // все омонимичные формы
	foreach (WordForm form in exactForms)
	{
		if (form.Stress > 0)
		{
			knownStressedVowelNumbers.Add(form.Stress);
		}
	}
}

Если известных ударений нет, то любой слог CanBeStressed.

if (knownStressedVowelNumbers.Count == 0)
{
    return Enumerable.Repeat(StressType.CanBeStressed, vCount).ToArray();
}

Если ударение одно, то оно StrictlyStressed, а все остальные слоги StrictlyUnstressed.

StressType[] finalStresses = Enumerable.Repeat(StressType.StrictlyUnstressed, vCount).ToArray();
if (knownStressedVowelNumbers.Count == 1)
{
	finalStresses[knownStressedVowelNumbers.First() - 1] = StressType.StrictlyStressed;
}

Если же ударений несколько, то все они CanBeStressed, а остальные слоги, соотвественно, StrictlyUnstressed.

else
{
	foreach (int knownStressNumber in knownStressedVowelNumbers)
	{
		finalStresses[knownStressNumber - 1] = StressType.CanBeStressed;
	}
}

return finalStresses;

Полный код метода GetPoeticStresses можно посмотреть в репозитории. Применив метод к каждому слову в строке, мы получим такой же массив ударений, но для строки.

Оценка близости

Для каждого размера заранее запишем маску StressType[], которая будет содержать только значения CanBeStressed и StrictlyUnstressed. Почему так? Потому что под ударной долей стихотворного размера может легко находиться безударный слог, если слово длинное, мы такой пример уже видели выше.

Скользя по утреннему снегу,

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

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

new []{ 
  StressType.StrictlyUnstressed, 
  StressType.CanBeStressed, 
  StressType.StrictlyUnstressed, 
  StressType.CanBeStressed, 
  StressType.StrictlyUnstressed, 
  StressType.CanBeStressed, 
  StressType.StrictlyUnstressed, 
  StressType.CanBeStressed, 
  StressType.StrictlyUnstressed
},

Далее мы слева направо сопоставим для каждого слога StressType исходной строки и StressType маски. Введём некоторое число dist, показывающее штраф в оценке близости (чем меньше dist тем ближе). Изначально равно нулю. Далее при сопоставлении слогов возможны такие варианты:

В маске слог CanBeStressed

В маске слог StrictlyUnstressed

В строке слог StrictlyStressed

Ударный слог в ударной доле, полное совпадение.

Не штрафуем.

Ударный слог не может попадать под безударную долю в размере.

+5 к штрафу

В строке слог CanBeStressed

Слог в строке может быть любым независимо от маски.

Не штрафуем.

Слог в строке может быть любым независимо от маски.

Не штрафуем.

В строке слог StrictlyUnstressed

В ударной доле размера находится безударный слог строки. Возможно, но не слишком хорошо.

+2 к штрафу

Безударный слог в безударной доле, полное совпадение.

Не штрафуем.

private const int WordToMaskMismatchPenalty = 5;
private const int MaskToWordMismatchPenalty = 2;

public int DistanceToFoot(Foot foot, IList<StressType> lineStresses)
{
	StressType[] mask = foot.GetMaskOfLength(lineStresses.Count);
	var dist = 0;
	for (var i = 0; i < mask.Length; i++)
	{
		StressType lineStress = lineStresses[i];
		StressType maskStress = mask[i];

		if (lineStress != StressType.CanBeStressed)
		{
			switch (lineStress)
			{
				case StressType.StrictlyStressed when maskStress == StressType.StrictlyUnstressed:
					dist += WordToMaskMismatchPenalty;
					break;
				
				case StressType.StrictlyUnstressed when maskStress == StressType.CanBeStressed:
					dist += MaskToWordMismatchPenalty;
					break;
			}
    }
	}

	return dist;
}

Финализация

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

Вычисление стихотворного размера - 1

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

Краевые случаи

UPD. Тут был пример из существующего стихотворения, где алгоритм лажает, но в комментариях мне справедливо указали на ошибку, и я нашёл баг в коде. Кто найдёт хороший краевой случай из реальной практики — пишите в комменты.

Понятно, что алгоритм ничего не сделает в тех местах, где мы дали ему слишком много свободы:

Вычисление стихотворного размера - 2

Реализация

Библиотека на C# доступна с исходным кодом, как часть Nestor.

Практическую реализацию я сделал в виде смартапа в системе Сбер Салют.

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

Спасибо, что дочитали. В следующей статье мы поговорим про рифму, там всё ещё хитрее.

Автор: Денис Пешехонов

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js