Кому может быть полезна эта статья?

Извращенцам делающим NLP на Java? Или может быть для обучения?

Хотя зачем эти оправдания? Весь код был написан because we can.

Под катом мы рассмотрим как превращать числа вида "Двенадцать тысяч шестьсот пятьдесят девять целых четыре миллионных" в форму вроде 12 659, 000 004.

Русский язык обладает встроенными алиасами для некоторых чисел. Их мы будем с переводить в последовательность обычных чисел. Для этого составим словарь псевдонимов:

0 ноль нуль
1 один
2 два
3 три
4 четыре
5 пять
6 шесть
7 семь
8 восемь
9 девять
11 одиннадцать
12 двенадцать дюжина
13 тринадцать
14 четырнадцать
15 пятнадцать
16 шестнадцать
17 семнадцать
18 восемнадцать
19 девятнадцать
20 двадцать
30 тридцать
40 сорок
50 пятьдесят
60 шестьдесят
70 семьдесят
80 восемьдесят
90 девяносто
200 двести
300 триста
400 четыреста
500 пятьсот
600 шестьсот
700 семьсот
800 восемьсот
900 девятьсот
0.00000000001 стомиллиардный
0.0000000001 десятимиллиардный
0.000000001 миллиардный
0.00000001 стомиллионный
0.0000001 десятимиллионный
0.000001 миллионный
0.00001 стотысячный
0.0001 десятитысячный
0.001 тысячный
0.01 сотый
0.1 десятый
10 десять
100 сто
1000 тысяча
1000000 миллион
1000000000 миллиард
1000000000000 триллион
1000000000000000 квадриллион
1000000000000000000 квинтиллион
1000000000000000000000 секстиллион
1000000000000000000000000 септиллион
1000000000000000000000000000 октиллион

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

{}.javaClass.getResourceAsStream("/dictionary")!!
  .bufferedReader()
  .readLines()
  .flatMap { line ->
    val aliases = line.split(' ')
    val number = aliases.first().toDouble()
    aliases.drop(1).map { Pair(it, number) }
  }.toMap()

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

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

val integerPart = mutableListOf<Double>()
val fractionalPart = mutableListOf<Double>()
var currentPart = integerPart
for (token in words) {
  if (integerPart.isNotEmpty() && token.lowercase() in separators) {
    currentPart = fractionalPart
    continue
  }
  val number =
    lookupForMeanings(token)
      .run {
        firstOrNull { it.partOfSpeech == Numeral || it.partOfSpeech == OrdinalNumber }
          ?: getOrNull(0)
      }
      ?.lemma
      ?.toString()
      ?.let(numbers::get)
  if (number != null) {
    currentPart += number
    continue
  }
  if (currentPart.isNotEmpty()) {
    break
  }
}

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

private fun List<Double>.join(): Double {
  var tokensSum = 0.0
  var previousToken = first()
  for (currToken in drop(1)) {
    if (currToken > previousToken) {
      previousToken *= currToken
    } else {
      tokensSum += previousToken
      previousToken = currToken
    }
  }
  return tokensSum + previousToken
}

Пришло время тестов нашей чудо-библиотеки!

@Test
fun parseRussianDouble() {
  assertThat("Двенадцать тысяч шестьсот пятьдесят девять целых четыре миллионных".parseRussianDouble())
    .isEqualTo(12659.000004)

  assertThat("Десять тысяч четыреста тридцать четыре".parseRussianDouble())
    .isEqualTo(10434.0)

  assertThat("Двенадцать целых шестьсот пятьдесят девять тысячных".parseRussianDouble())
    .isEqualTo(12.659)

  assertThat("Ноль целых пятьдесят восемь сотых".parseRussianDouble())
    .isEqualTo(0.58)

  assertThat("Сто тридцать пять".parseRussianDouble())
    .isEqualTo(135.0)
}

Если вам интересно, как сделать, чтобы метод .parseToRussianDouble появился для всех строк в вашем Kotlin (или Java) проекте, то вам нужно просто подключить пару строчек в вашей системе сборки:
https://jitpack.io/#demidko/chisla/2021.10.30

В качестве демонстрации еще одной возможности библиотеки приведу кусочек кода:

"Я хотел передать ему сто тридцать пять яблок".parseRussianDouble()
// 135

Исходный код библиотеки доступен на GitHub.

Критика, вопросы, пожелания, принимаются в issues или в комментариях под статьей.

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


  1. putnik
    30.10.2021 20:46
    +3

    К сожалению, всё сильно сложнее, чем хотелось бы думать. И количество возможных случаев и конфликтов с их распознаванием таково, что простым скриптом это не распарсить. Просто несколько примеров «на подумать», наверняка не всё вспомню:

    1. Дроби бывают не только десятичные. Есть может быть одна пятая и одна двадцать пятая.

    2. У некоторых дробей есть нетипичные названия: половина, треть, четверть и т. п.

    3. Да и не только у дробей: пара, тройка, дюжина.

    4. Числительные бывают порядковыми. Можно сказать, что этот вопрос легко решается за счёт морфологии, но точно ли ваш код правильно различит слова «десятый [по порядку]» и «десятая [часть]»?

    5. Для степеней десятки существует короткая и длинная шкалы со своими именованиями чисел.

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


    1. Reformat Автор
      31.10.2021 06:18
      +1

      Согласен. Этот код покрывает самые простые случаи. Однако справедливости ради, единственный, претендующий на стандарт аналог, ICU, тоже не умеет парсить сложные дроби, а дроби без указания степени вообще отбрасывает.

      val ru = Locale("ru", "ru")
      val icu = RuleBasedNumberFormat(ru, SPELLOUT)
      println(icu.parse("одна двадцатая")) // 1
      println(icu.parse("пятьдесят девять целых тридцать две")) // 59


  1. qdb
    31.10.2021 02:02

    где тут ML (machine learning)? может быть, вы хотели сказать NLP (natural language processing)?


    1. Reformat Автор
      31.10.2021 06:13

      Точно!