Русский
Русский
English
Статистика
Реклама

Грамматики

Да хватит уже писать эти регулярки

08.06.2021 16:18:29 | Автор: admin

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


/^(?:((?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}(?:\.(?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}){0,})|("(?:((?:(?:([\u{1}-\u{8}\u{b}\u{c}\u{e}-\u{1f}\u{21}\u{23}-\u{5b}\u{5d}-\u{7f}])|(\\[\u{1}-\u{9}\u{b}\u{c}\u{e}-\u{7f}]))){0,}))"))@(?:((?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}(?:\.(?:[\w!#\$%&'\*\+\/=\?\^`\{\|\}~-]){1,}){0,}))$/gsu

Тут, правда, закралось несколько ошибок. Ну ничего, пофиксим в следующем релизе!


Шутки в сторону



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



А с внедрением новых фичей, они теряют и лаконичность:


/(?<слово>(?<буквица>\p{Script=Cyrillic})\p{Script=Cyrillic}+)/gimsu

У регулярок довольно развесистых синтаксис, который то и дело выветривается из памяти, что требует постоянное подсматривание в шпаргалку. Чего только стоят 5 разных способов экранирования:


/\t//\ci//\x09//\u0009//\u{9}/u

В JS у нас есть интерполяция строк, но как быть с регулярками?


const text = 'lol;)'// SyntaxError: Invalid regular expression: /^(lol;)){2}$/: Unmatched ')'const regexp = new RegExp( `^(${ text }){2}$` )

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


const VISA = /(?<type>4)\d{12}(?:\d{3})?/const MasterCard = /(?<type>5)[12345]\d{14}/// Invalid regular expression: /(?<type>4)\d{12}(?:\d{3})?|(?<type>5)[12345]\d{14}/: Duplicate capture group nameconst CardNumber = new RegExp( VISA.source + '|' + MasterCard.source )

Короче, писать их сложно, читать невозможно, а рефакторить вообще адски! Какие есть альтернативы?


Свои регулярки с распутным синтаксисом


Полностью своя реализация регулярок на JS. Для примера возьмём XRegExp:


  • API совместимо с нативным.
  • Можно форматировать пробелами.
  • Можно оставлять комментарии.
  • Можно расширять своими плагинами.
  • Нет статической типизации.
  • Отсутствует поддержка IDE.

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


Генераторы парсеров


Вы скармливаете им грамматику на специальном DSL, а они выдают вам JS код функции парсинга. Для примера возьмём PEG.js:


  • Наглядный синтаксис.
  • Каждая грамматика вещь в себе и не компонуется с другими.
  • Нет статической типизации генерируемого парсера.
  • Отсутствует поддержка IDE.
  • Минимум 2 кб в ужатопережатом виде на каждую грамматику.

Пример в песочнице.


Это решение более мощное, но со своими косяками. И по воробьям из этой пушки стрелять не будешь.


Билдеры нативных регулярок


Для примера возьмём TypeScript библиотеку $mol_regexp:


  • Строгая статическая типизация.
  • Хорошая интеграция с IDE.
  • Композиция регулярок с именованными группами захвата.
  • Поддержка генерации строки, которая матчится на регулярку.

Это куда более легковесное решение. Давайте попробуем сделать что-то не бесполезное..


Номера банковских карт


Импортируем компоненты билдера


Это либо функции-фабрики регулярок, либо сами регулярки.


const {    char_only, latin_only, decimal_only,    begin, tab, line_end, end,    repeat, repeat_greedy, from,} = $mol_regexp

Ну или так, если вы ещё используете NPM


import { $mol_regexp: {    char_only, decimal_only,    begin, tab, line_end,    repeat, from,} } from 'mol_regexp'

Пишем регулярки для разных типов карт


// /4(?:\d){12,}?(?:(?:\d){3,}?){0,1}/gsuconst VISA = from([    '4',    repeat( decimal_only, 12 ),    [ repeat( decimal_only, 3 ) ],])// /5[12345](?:\d){14,}?/gsuconst MasterCard = from([    '5',    char_only( '12345' ),    repeat( decimal_only, 14 ),])

В фабрику можно передавать:


  • Строку и тогда она будет заэкранирована.
  • Число и оно будет интерпретировано как юникод кодепоинт.
  • Другую регулярку и она будет вставлена как есть.
  • Массив и он будет трактован как последовательность выражений. Вложенный массив уже используется для указания на опциональность вложенной последовательности.
  • Объект означающий захват одного из вариантов с именем соответствующим полю объекта (далее будет пример).

Компонуем в одну регулярку


// /(?:(4(?:\d){12,}?(?:(?:\d){3,}?){0,1})|(5[12345](?:\d){14,}?))/gsuconst CardNumber = from({ VISA, MasterCard })

Строка списка карт


// /^(?:\t){0,}?(?:((?:(4(?:\d){12,}?(?:(?:\d){3,}?){0,1})|(5[12345](?:\d){14,}?))))(?:((?:\r){0,1}\n)|(\r))/gmsuconst CardRow = from(    [ begin, repeat( tab ), {CardNumber}, line_end ],    { multiline: true },)

Сам список карточек


const cards = `    3123456789012    4123456789012    551234567890123    5512345678901234`

Парсим текст регуляркой


for( const token of cards.matchAll( CardRow ) ) {    if( !token.groups ) {        if( !token[0].trim() ) continue        console.log( 'Ошибка номера', token[0].trim() )        continue    }    const type = ''        || token.groups.VISA && 'Карта VISA'        || token.groups.MasterCard && 'MasterCard'    console.log( type, token.groups.CardNumber )}

Тут, правда, есть небольшое отличие от нативного поведения. matchAll с нативными регулярками выдаёт токен лишь для совпавших подстрок, игнорируя весь текст между ними. $mol_regexp же для текста между совпавшими подстроками выдаёт специальный токен. Отличить его можно по отсутствию поля groups. Эта вольность позволяет не просто искать подстроки, а полноценно разбивать весь текст на токены, как во взрослых парсерах.


Результат парсинга


Ошибка номера 3123456789012Карта VISA 4123456789012Ошибка номера 551234567890123MasterCard 5512345678901234

Заценить в песочнице.


E-Mail


Регулярку из начала статьи можно собрать так:


const {    begin, end,    char_only, char_range,    latin_only, slash_back,    repeat_greedy, from,} = $mol_regexp// Логин в виде пути разделённом точкамиconst atom_char = char_only( latin_only, "!#$%&'*+/=?^`{|}~-" )const atom = repeat_greedy( atom_char, 1 )const dot_atom = from([ atom, repeat_greedy([ '.', atom ]) ])// Допустимые символы в закавыченном имени сендбоксаconst name_letter = char_only(    char_range( 0x01, 0x08 ),    0x0b, 0x0c,    char_range( 0x0e, 0x1f ),    0x21,    char_range( 0x23, 0x5b ),    char_range( 0x5d, 0x7f ),)// Экранированные последовательности в имени сендбоксаconst quoted_pair = from([    slash_back,    char_only(        char_range( 0x01, 0x09 ),        0x0b, 0x0c,        char_range( 0x0e, 0x7f ),    )])// Закавыченное имя сендборксаconst name = repeat_greedy({ name_letter, quoted_pair })const quoted_name = from([ '"', {name}, '"' ])// Основные части имейла: доменная и локальнаяconst local_part = from({ dot_atom, quoted_name })const domain = dot_atom// Матчится, если вся строка является имейломconst mail = from([ begin, local_part, '@', {domain}, end ])

Но просто распарсить имейл эка невидаль. Давайте сгенерируем имейл!


//  SyntaxError: Wrong param: dot_atom=foo..barmail.generate({    dot_atom: 'foo..bar',    domain: 'example.org',})

Упс, ерунду сморозил Поправить можно так:


// foo.bar@example.orgmail.generate({    dot_atom: 'foo.bar',    domain: 'example.org',})

Или так:


// "foo..bar"@example.orgmail.generate({    name: 'foo..bar',    domain: 'example.org',})

Погонять в песочнице.


Роуты


Представим, что сеошник поймал вас в тёмном переулке и заставил сделать ему "человекопонятные" урлы вида /snjat-dvushku/s-remontom/v-vihino. Не делайте резких движений, а медленно соберите ему регулярку:


const translit = char_only( latin_only, '-' )const place = repeat_greedy( translit )const action = from({ rent: 'snjat', buy: 'kupit' })const repaired = from( 's-remontom' )const rooms = from({    one_room: 'odnushku',    two_room: 'dvushku',    any_room: 'kvartiru',})const route = from([    begin,    '/', {action}, '-', {rooms},    [ '/', {repaired} ],    [ '/v-', {place} ],    end,])

Теперь подсуньте в неё урл и получите структурированную информацию:


// `/snjat-dvushku/v-vihino`.matchAll(route).next().value.groups{    action: "snjat",    rent: "snjat",    buy: "",    rooms: "dvushku",    one_room: "",    two_room: "dvushku",    any_room: "",    repaired: "",    place: "vihino",}

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


// /kupit-kvartiru/v-moskveroute.generate({    buy: true,    any_room: true,    repaired: false,    place: 'moskve',})

Если задать true, то значение будет взято из самой регулярки. А если false, то будет скипнуто вместе со всем опциональным блоком.


И пока сеошник радостно потирает руки предвкушая первое место в выдаче, незаметно достаньте телефон, вызовите полицию, а сами скройтесь в песочнице.


Как это работает?


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


// time.source == "((\d{2}):(\d{2}))"// time.groups == [ 'time', 'hours', 'minutes' ]const time = from({    time: [        { hours: repeat( decimal_only, 2 ) },        ':',        { minutes: repeat( decimal_only, 2 ) },    ],)

Наследуемся, переопределям exec и добавляем пост-процессинг результата с формированием в нём объекта groups вида:


{    time: '12:34',    hours: '12,    minutes: '34',}

И всё бы хорошо, да только если скомпоновать с нативной регуляркой, содержащей анонимные группы, но не содержащей имён групп, то всё поедет:


// time.source == "((\d{2}):(\d{2}))"// time.groups == [ 'time', 'minutes' ]const time = wrong_from({    time: [        /(\d{2})/,        ':',        { minutes: repeat( decimal_only, 2 ) },    ],)

{    time: '12:34',    hours: '34,    minutes: undefined,}

Чтобы такого не происходило, при композиции с обычной нативной регуляркой, нужно "замерить" сколько в ней объявлено групп и дать им искусственные имена "0", "1" и тд. Сделать это не сложно достаточно поправить регулярку, чтобы она точно совпала с пустой строкой, и посчитать число возвращённых групп:


new RegExp( '|' + regexp.source ).exec('').length - 1

И всё бы хорошо, да только String..match и String..matchAll клали шуруп на наш чудесный exec. Однако, их можно научить уму разуму, переопределив для регулярки методы Symbol.match и Symbol.matchAll. Например:


*[Symbol.matchAll] (str:string) {    const index = this.lastIndex    this.lastIndex = 0    while ( this.lastIndex < str.length ) {        const found = this.exec(str)        if( !found ) break        yield found    }    this.lastIndex = index}

И всё бы хорошо, да только тайпскрипт всё равно не поймёт, какие в регулярке есть именованные группы:


interface RegExpMatchArray {    groups?: {        [key: string]: string    }}

Что ж, активируем режим обезьянки и поправим это недоразумение:


interface String {    match< RE extends RegExp >( regexp: RE ): ReturnType<        RE[ typeof Symbol.match ]    >    matchAll< RE extends RegExp >( regexp: RE ): ReturnType<        RE[ typeof Symbol.matchAll ]    >}

Теперь TypeScript будет брать типы для groups из переданной регулярки, а не использовать какие-то свои захардкоженные.


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


Напутствие



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


Подробнее..

Сложности работы с ANTLR пишем грамматику Ruby

04.08.2020 10:19:03 | Автор: admin
image В Ростелеком-Солар мы разрабатываем статический анализатор кода на уязвимости и НДВ, который работает в том числе на деревьях разбора. Для их построения мы пользуемся оптимизированной версией ANTLR4 инструмента для разработки компиляторов, интерпретаторов и трансляторов.

В репозитории можно найти грамматики множества языков программирования. Однако в нем отсутствует грамматика Ruby, которую, по всей видимости, никто так и не реализовал. Есть только грамматика похожего самодельного языка, парсящая лишь простейшие случаи. Это неудивительно, ведь грамматику Ruby сложно реализовать, так как язык обладает нетривиальным синтаксисом. Но она очень пригодилась бы тем, кто, например, захочет написать свой язык и задумается, как это сделать. Или тем, кому нужно решить технические сложности, рассмотренные в нашей статье. Ну что же придется писать новую грамматику, чем прямо здесь и займемся.


В ANTLR предлагается разбивать анализ языка на лексер и парсер.

Лексер занимается тем, что формирует токены на основе заданных последовательностей символов из алфавита языка. Если последовательность символов подходит под определение нескольких токенов, выбирается наидлиннейший, а среди таких первый по приоритету (который задается порядком записи).

Парсер формирует предложения (законченные команды) языка с помощью токенов (также называемых терминальными символами), получаемых из лексера.

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

В данной статье будет опущена базовая реализация и будут рассмотрены лишь технические сложности написания.

Проблемы лексера


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

Интерполяция


Некоторые строки в Ruby допускают интерполяцию вставку произвольного кода внутрь с помощью синтаксиса #{code}. Например:

a = 3"Hello #{if a%2==1 then "Habr!" else "World!" end}"

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

DoubleQuote: '"' {++nestedStringLevel;}    -> pushMode(InterpolationString);

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

"Kappa #{    buf = ''    [1, 2, 3].each { |x| buf += x.to_s }    buf}"

Для этого заведем стек openedCurlyBracesInsideString. Итого внутри мода имеем токен:

StartInterpolation: '#{' {openedCurlyBracesInsideString.push(0);}    -> pushMode(DEFAULT_MODE);

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

OpenCurlyBracket:             '{' {    if (nestedStringLevel > 0 && !openedCurlyBracesInsideString.empty()) {        final int buf = openedCurlyBracesInsideString.pop();        openedCurlyBracesInsideString.push(buf + 1);    }};CloseCurlyBracket:            '}' {    if (nestedStringLevel > 0 && openedCurlyBracesInsideString.peek() <= 0) {       popMode();       openedCurlyBracesInsideString.pop();    } else {        if (!openedCurlyBracesInsideString.empty()) {            final int buf = openedCurlyBracesInsideString.pop();            openedCurlyBracesInsideString.push(buf - 1);        }    }};

%-нотации


В Ruby существует вдохновленный Perl дополнительный синтаксис написания строк, массивов строк и символов (который в Ruby не является символом в обычном понимании), регулярных выражений и шелл-команд. Синтаксис таких команд таков: %, следующий за ним опциональный идентификатор типа и символ-разделитель. Например: %w|a b c| массив из трех строк. Однако, также можно использовать в качестве разделителя парные скобки: {} [] () <>. Просто задать все возможные идентификаторы не выйдет тогда, например, последовательность

q = 35%q

будет распознаваться некорректно. Лексер просто съест самую длинную цепочку символов, создав токен %q.

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

StringArrayConstructorwToken: '%w' {canBeDelimiter()}?;

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

StartArrayConstructorNotInterpolated    : StringArrayConstructorwToken ( Brackets {setPairDelimiter();} | ~[(<{[] {setCharDelimiter();} ) {startStringMode(ArrayConstructorw);}fragment Brackets: '(' | '[' | '{' | '<';

где startStringMode utility-функция для переключения режима и поддержки вложенности.

public void startStringMode(final int mode) {    pushMode(mode);    ++nestedStringLevel;}

Контрпример: 5%q|1 парсящийся корректно лишь в контексте парсера, когда известно, что после 5-ти задания строки быть не может.

Можно было бы подумать, что достаточно найти парный разделитель с помощью заглядывания вперед, однако и на такой случай есть пример 5%q|1|1. Откуда становится ясно, что при разделении на лексер и парсер подобный случай распарсить невозможно.

Однако такое случается очень редко, так что сойдет \_()_/. Внутри режима же просто ждем разделитель.

ArraywWhitespace: WhitespaceAll                           -> skip;ArraywText:       ({!isDelimiter()}? ArraywTextFragment)+ -> type(StringPart);ArraywEnd:        . {nestedStringLevel--;}                -> type(HereDocEnd), popMode;

где type изменяет тип создаваемых токенов для удобства.

Деление или начало регулярного выражения


Синтаксис регулярного выражения таков /regexp/ (а также вышеупомянутая нотация с процентом). Возникает такая же проблема контекста парсера, как и в предыдущем пункте, для её смягчения создаем проверку

public boolean canBeRegex() {    return isPrevWS && " \t\r\u000B\f\b\n".indexOf((char) _input.LA(1)) == -1 || isPrevNL || isOp || prevNonWsType == StartInterpolation;}

и добавляем в токен

Divide:                       '/' {    if (canBeRegex()) {        startHereDoc(RegExp);    }};

Для пересчета переменных isOp, isPrevNL, isPrevWS также переопределяем emit-функцию итогового создания токена

@Overridepublic void emit(final Token token) {    final String txt = token.getText();    final int type = token.getType();    isPrevNL = type == NL;    isPrevWS = type == WS;    if (!isPrevWS && !isPrevNL && type != SingleLineComment && type != MultiLineComment) {        isOp = OPERATORS.contains(type);        prevNonWsChar = txt.charAt(txt.length() - 1);        prevNonWsType = type;    }    super.emit(token);}

где OPERATORS hashmap всех операторов.

Проблемы парсера


Пробельные символы


Довольно неприятным сюрпризом стало влияние пробелов на парсинг. Обычно они никак не сказываются на грамматике и просто-напросто удаляются из потока с помощью -> skip или перевода в другой канал. Однако ряд языков все же различает некоторые конструкции с их помощью.

Так, например, a+3 и a + 3 не могут быть вызовом функции без скобок, а а +3 может. Поэтому все правила парсера выглядят так (NL newline, WS whitespace):

    | expression WS* op=('+' | '-') (NL | WS)* expression

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

public class RemovingTokensListener implements ParseTreeListener {        private List<Integer> unwantedTokens;        ...        @Override        public void visitTerminal(final TerminalNode node) {            if (this.unwantedTokens.contains(node.getSymbol().getType())) {                ((ParserRuleContext) node.getParent().getRuleContext()).removeLastChild();            }        }}

Heredoc Лексер и парсер


Специальный синтаксис задания многострочных строк. Может быть таким

<<STRcontent line 1content line 2STR

или даже таким (интересно, что markdown не распознает синтаксис корректно).

value = 123print <<STR_WITH_INTERPOLATION, <<'STR_WITHOUT_INTERPOLATION'.stripcontent 1 and #{value}STR_WITH_INTERPOLATION     content 2 and #{value}STR_WITHOUT_INTERPOLATION

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

public final HeredocsHolder HEREDOCS = new HeredocsHolder();public static final class HereDocEntry {    public final String name;    public final HereDocType type;    public final boolean isInterpolated;    public int partsNumber;    public HereDocEntry(final String name, final HereDocType type, final boolean isInterpolated) {        this.name = name;        this.type = type;        this.isInterpolated = isInterpolated;        this.partsNumber = 0;    }}public static final class HeredocsHolder {    public final List<HereDocEntry> entries;    public int toProcess;    HeredocsHolder() {        this.entries = new ArrayList<>();        this.toProcess = 0;    }}

и будем добавлять их по мере поступления

StartHereDoc    : HereDocToken HereDocName {        heredocIdentifier = getHeredocIdentifier('\'');        setText(getText().trim());        keepHereDoc(HereDoc, false);    }    ;

public void keepHereDoc(final int mode, final boolean isInterpolated) {    HEREDOCS.entries.add(new HereDocEntry(heredocIdentifier, getHereDocType(), isInterpolated));    ++HEREDOCS.toProcess;    isFirstNL = true;}


Далее, увидев конец строки при ожидающих обработки heredoc-ах, вызовем функцию обработки.

NL:                           '\n' {    final int next = _input.LA(1);    if (HEREDOCS.toProcess > 0 && isFirstNL) {        startHereDocRoutine();        isFirstNL = false;        insideHeredoc = true;    }};

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

public void startHereDocRoutine() {    final int stopIdx = HEREDOCS.entries.size() - HEREDOCS.toProcess;    for (int i = HEREDOCS.entries.size() - 1; i >= stopIdx; --i) {        if (HEREDOCS.entries.get(i).isInterpolated) {            pushMode(HereDocInterpolated);        } else {            pushMode(HereDoc);        }    }    nestedStringLevel += HEREDOCS.toProcess;    currentHeredocIt = HEREDOCS.entries.listIterator(HEREDOCS.entries.size() - HEREDOCS.toProcess);    currentHeredoc = currentHeredocIt.next();}

Нужно перезаписать nextToken для выхода из режима и подсчета числа токенов каждого heredoc-а

@Overridepublic Token nextToken(){    final CommonToken token = (CommonToken)super.nextToken();    final int ttype = token.getType();    if (insideHeredoc && ttype == StartInterpolation) {        ++heredocTokensCount;    }    if (_mode == HereDoc || _mode == HereDocInterpolated) {        if (ttype == VarName) {            ++heredocTokensCount;        } else if (ttype == StringPart) {            ++heredocTokensCount;            final String txt = token.getText();            if (CheckHeredocEnd(txt)) {                token.setText(txt.trim());                token.setType(HereDocEnd);                nestedStringLevel--;                popMode();                currentHeredoc.partsNumber = heredocTokensCount;                if (currentHeredocIt.hasNext()) {                    currentHeredoc = currentHeredocIt.next();                }                heredocTokensCount = 0;                --HEREDOCS.toProcess;                if (_mode == DEFAULT_MODE) {                    insideHeredoc = false;                }            }        }    }    return token;}

Теперь займемся парсером.

С помощью @parser::members добавим в парсер: ссылку на лексер, узлы строк, куда будем переносить их контент, число узлов интерполяции (по аналогии с heredocTokensCount лексера), а также стек statement-ов с указанием необходимости обработки.

    private final RubyLexer lexer = (RubyLexer)_input.getTokenSource();    private final List<ParserRuleContext> CONTEXTS = new ArrayList<>();    private final List<Integer> heredocRulesCount = new ArrayList<>();    private final Stack<StatementEntry> statements = new Stack<>();    private static final class StatementEntry {        public boolean needProcess;        public int currentHeredoc;        StatementEntry() {            this.needProcess = false;            this.currentHeredoc = 0;        }    }

Модифицируем непосредственно единицу кода:

statement@init {    statements.push(new StatementEntry());}@after {    if (statements.peek().needProcess) {        processHeredocs($ctx);    }    statements.pop();}    : statementWithoutHeredocTail ({statements.peek().needProcess}? interpolatedStringPart* HereDocEnd {++statements.peek().currentHeredoc;})*    ;

@init код, который исполняется при входе парсера в правило, @after при выходе.

Стек необходим для отнесения heredoc-ов к нужному statement-у, т.к. внутри интерполяции могут быть новые.

Для того, чтобы правильно распознать случаи, где heredoc может быть обычным expression и где строку можно считать токенами подряд, а также сложные кейсы, когда конец строки будет лежать за текущим выражением, пишем такой вот код парсера:

string:...    | StartInterpolatedHereDoc (memberAccess* WS* NL interpolatedStringPart* HereDocEnd)? {        if ($ctx.HereDocEnd() == null) {            CONTEXTS.add($ctx);            heredocRulesCount.add(0);            statements.peek().needProcess = true;        } else {             lexer.HEREDOCS.entries.remove(0);        }    }...

Для самого же подсчета узлов интерполяции модифицируем код правила с контентом строки (+ 2 здесь нужно для подсчета токенов, открывающих и закрывающих интерполяцию)

interpolatedStringPartlocals[int nodesCount = 0]    : StringPart    | VarName    | StartInterpolation (WS* statement {++$nodesCount;})* (WS* rawStatement {++$nodesCount;})? WS* '}' {        final int cur = statements.peek().currentHeredoc;        if (cur < heredocRulesCount.size()) {            heredocRulesCount.set(cur, heredocRulesCount.get(cur) + $nodesCount + 2);        }    }    ;

где locals список локальных переменных (ссылаться на них нужно через $), а пробельные токены не считаются, т.к. удаляются во время построения дерева нашим listener-ом.

Теперь напишем саму функцию processHeredocs. Посчитаем, сколько узлов предстоит забрать

int heredocNodesCount = 0;for (int i = 0; i < CONTEXTS.size(); ++i) {    heredocNodesCount += lexer.HEREDOCS.entries.get(i).partsNumber;    heredocNodesCount += heredocRulesCount.get(i);}

Начиная с какого ребенка, начнем перекидывать контент строк по своим местам

int currentChild = ctx.getChildCount() - heredocNodesCount;

Подвесим контент к соответствующему узлу

for (int i = 0; i < CONTEXTS.size(); ++i) {    final RubyLexer.HereDocEntry entry = lexer.HEREDOCS.entries.remove(0);    final ParserRuleContext currentContext = CONTEXTS.get(i);    final int currentNodesCount = entry.partsNumber + heredocRulesCount.get(i);    for (int j = 0; j < currentNodesCount; ++j, ++currentChild) {        final ParseTree child = ctx.getChild(currentChild);        if (child instanceof TerminalNode) {            ((TerminalNodeImpl) child).setParent(currentContext);            currentContext.addChild((TerminalNodeImpl) child);        } else if (child instanceof ParserRuleContext) {            ((ParserRuleContext) child).setParent(currentContext);            currentContext.addChild((ParserRuleContext) child);        } else {            // parser failed            clear();            return;        }    }}

Очищаем сам узел, контексты heredoc-ов и список числа узлов интерполяции

for (int i = 0; i < heredocNodesCount; ++i) {    ctx.removeLastChild();}clear();

private void clear() {    CONTEXTS.clear();    heredocRulesCount.clear();}

Последним штрихом можно удалить ненужное промежуточное правило для обработки heredoc-ов statementWithoutHeredocTail, переподвешивая детей узла к его предку, с помощью того же listener-а

public class RemovingRulesListener implements ParseTreeListener {    private List<Integer> unwantedRules;    ...    @Override    public void exitEveryRule(final ParserRuleContext ctx) {        if (this.unwantedRules.contains(ctx.getRuleIndex())) {            final ParserRuleContext parentCtx =                    (ParserRuleContext) ctx.getParent().getRuleContext();            parentCtx.children.remove(ctx);            ctx.children.forEach(                    child -> {                        if (child instanceof RuleContext) {                            ((RuleContext) child).setParent(parentCtx);                            parentCtx.addChild((RuleContext) child);                        } else if (child instanceof TerminalNode) {                            ((TerminalNodeImpl) child).setParent(parentCtx);                            parentCtx.addChild((TerminalNodeImpl) child);                        }                    }            );        }    }}

Ambiguity


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

Суть заключается в том, что при входе a +a +a +a... на каждом шаге может быть как обычное сложение, так и вызов функции без аргументов (хотя и в таком случае Ruby требует отсутствия пробела после знака у первого аргумента), отчего по всей видимости возникает экспоненциальный рост хождений по графу предсказаний.

Однако просто-напросто запретить ANTLR пробел после унарного оператора в контексте не выйдет придется вручную переписывать леворекурсивный expression, разрешаемый автоматически для случая без аргументов. Полагаясь на то, что никто не пишет более тридцати слагаемых без причины, данная проблема отпадает.

Заключение


Экспериментально данная грамматика может распарсить 99% файлов.

Так, aws-sdk-ruby, содержащий 3024 ruby-файла, падает лишь на семи, fastlane, содержащий 1028, на 2-x, а Ruby on Rails c 2081, на 19-ти.

Однако все же есть принципиально бОльные моменты вроде heredoc-ов, входящих в expression

option(:sts_regional_endpoints,  default: 'legacy',  doc_type: String,  docstring: <<-DOCS) do |cfg|Passing in 'regional' to enable regional endpoint for STS for all supportedregions (except 'aws-global'), defaults to 'legacy' mode, using global endpointfor legacy regions.  DOCS  resolve_sts_regional_endpoints(cfg)end

или используемых как выражения, любых типов блоков

def test_group_by_with_order_by_virtual_count_attribute    expected = { "SpecialPost" => 1, "StiPost" => 2 }    actual = Post.group(:type).order(:count).limit(2).maximum(:comments_count)    assert_equal expected, actualend if current_adapter?(:PostgreSQLAdapter)

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

Автор: Федор Усов, разработчик Solar appScreener
Подробнее..

Как адаптировать языковые модели Kaldi? (со смешными животными)

24.05.2021 14:14:07 | Автор: admin


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


Привет! Приглашаю вас кушать блины и распознавать речь

Сейчас можно легко заставить компьютер распознавать обычную устную речь, благо, есть пакет vosk, который является человечной обёрткой (wrapperом) к предобученным моделям Kaldi. Alphacephei и Николай Шмырёв проделали колоссальное количество работы по продвижению опен-сорса в распознавании русскоязычной речи, и vosk, пожалуй, венец всего их труда. Большая модель vosk-ru для распознавания устной русской речи без всяких доработок может решать множество задач распознавания речи.

По умолчанию большая модель vosk-ru предназначена для распознавания обычных разговорных слов и синтаксических конструкций. Однако, когда появляется необходимость распознавать другие слова и другие языковые конструкции, которые не предусмотрены моделью vosk-ru по умолчанию, качество распознавания заметно ухудшается. Если таких конструкций немного, то можно выстроить соответствие между тем, что нужно распознать, и тем, что распознаётся на самом деле. Например, текущая модель vosk-model-ru-0.10 не умеет распознавать слово коронавирус, но распознает отдельные слова: корона и вирус. В подобных случаях нам будет предоставлен своеобразный ребус, который нам, со своей стороны, нужно будет решить программно. К сожалению, на ребусах далеко не уехать.

Собственно, как здорово, что все мы здесь сегодня собрались научиться адаптировать модель vosk-ru. Для этого существуют пути адаптации:



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



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

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

Бывает и обратная ситуация: мы знаем и ожидаем фразу определённой структуры от человека, в таком случае используются вручную построенные грамматики. Одна из распространённых разновидностей грамматик грамматика речевых команд, когда человек может сказать только одну из n фраз в определённый момент времени с одинаковой вероятностью.

Соответственно, в Kaldi есть два основных способа проектирования языковых моделей: ARPA LM и грамматика FST:



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


Мопс выделяет частоты в сигнале, на которых находится речь

СТРУКТУРА VOSK-MODEL-RU-0.10

Начнём работу с разбора того, как устроена модель для vosk. То, что нам НЕ понадобится при файн-тюнинге выделено курсивом.

vosk-model-ru-0.10
\__ am сокращённо от Acoustic Model. Содержит модель распознавания звуков (фонем)
\__ conf папка с файлами конфигураций для запуска модуля
\__ graph графы для описания вероятностей переходов от одной фонемы к другой. Содержит информацию о заученных переходах фонем, а также переходы с учётом языковой модели
\__ ivector папка с сохранёнными голосовыми слепками из обучающей выборки
\__ rescore n-граммная языковая модель для переопределения цепочек слов
\__ rnnlm языковая модель на основе рекуррентной нейронной сети для дополнительного переопределения цепочек слов
\__ decode.sh исполняемый файл для запуска моделей с помощью инструментов Kaldi
\__ decoder-test.scp, decoder-test.utt2spk служебные файлы для распознавания пробного файла
\__ decoder-test.wav пробный файл
\__ README документация


Когда мы хотим адаптировать vosk модель для конкретной задачи распознавания речи, наша главная цель корректно подменить файл ./graph/HCLG.fst. Но как именно подменить и какие файлы использовать для конечной генерации этого графа, целиком зависит от поставленной задачи. Примеры задач представлены выше, таким образом вы можете соотнести свою задачу с представленным пулом задач и понять, какие от вас требуются действия для эффективной адаптации.

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

Модификация словаря
Замена словаря
Модификация ЯМ
Замена ЯМ

Ну что ж, приступим?


Этот хороший мальчик готов адаптировать модель распознавания речи, а вы?

УСТАНОВКА KALDI

Прежде всего нам нужно поставить Kaldi на нашу рабочую машину (Linux или Mac). Благо, делается это весьма просто:

git clone https://github.com/kaldi-asr/kaldi.gitcd kaldi/tools/./extras/check_dependencies.shmake -j 4 # тут в качестве параметра указываете количество параллельных процессов при установкеcd ../src/./configure --sharedmake depend -j 4 # аналогичноmake -j 4 # аналогично

В результате установки на машине компилируются зависимости и непосредственно сам Kaldi. Если что-то идёт не так, смотрите логи, гуглите и обращайтесь за помощью в комментарии.

УСТАНОВКА KENLM

Затем нам нужно установить инструмент kenlm для построения статистических языковых моделей. Есть альтернативы для построения статистических языковых моделей, такие как SRILM, он поддерживает бОльшее количество видов сглаживаний для языковых моделей, но он сложнее при установке и использовании. Помимо всего прочего, kenlm можно использовать без ограничений для коммерческих приложений.

git clone https://github.com/kpu/kenlm.gitmkdir -p kenlm/buildcd kenlm/buildcmake ..make -j 4


НАСТРОЙКА ДИРЕКТОРИИ

Для того чтобы нам начать процедуру по адаптации модели, нужно организовать нашу рабочую директорию. Создайте папку в любом удобном месте, папка будет содержать модель vosk-а, на которую, в том числе, можно будет ссылаться из вашего Python-приложения.

# Создаём рабочую директориюmkdir your_asr_project/cd your_asr_project/# Копируем необходимые файлы из модели vosk-аcp -R /path/to/your/vosk-model-ru-0.10/am .cp -R /path/to/your/vosk-model-ru-0.10/conf/ .cp -R /path/to/your/vosk-model-ru-0.10/graph/ .cp -R /path/to/your/vosk-model-ru-0.10/ivector/ .# Копируем необходимые скрипты из рецептов Kaldicp -R /path/to/your/kaldi/egs/mini_librispeech/s5/steps/ .cp -R /path/to/your/kaldi/egs/mini_librispeech/s5/utils/ .cp -R /path/to/your/kaldi/egs/mini_librispeech/s5/path.sh .cp -R /path/to/your/kaldi/egs/mini_librispeech/s5/cmd.sh .

Рекомендуется брать скрипты из рецепта mini_librispeech, так как именно он изначально использовался для обучения vosk-model-ru-0.10.

НАСТРОЙКА ОКРУЖЕНИЯ

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

./path.sh
export KALDI_ROOT=/path/to/your/kaldi # Здесь указываете путь до вашего Kaldi[ -f $KALDI_ROOT/tools/env.sh ] && . $KALDI_ROOT/tools/env.shexport PATH=$PWD/utils/:$KALDI_ROOT/tools/openfst/bin:$PWD:$PATH[ ! -f $KALDI_ROOT/tools/config/common_path.sh ] && echo >&2 "The standard file $KALDI_ROOT/tools/config/common_path$. $KALDI_ROOT/tools/config/common_path.shexport LC_ALL=C# For now, don't include any of the optional dependenices of the main# librispeech recipe

Не забываем сделать этот файл исполняемым и выполняем его. Каждый раз, когда открывается консоль, нам необходимо запускать path.sh и выполнять адаптацию.

НАСТРОЙКА ОКРУЖЕНИЯ ДЛЯ KENLM

Чтобы kenlm также был доступен из вашей рабочей директории, нужно определить до него путь. Можно отдельно выполнять эту строку в командной строке или прописать в path.sh:

export PATH=$PATH:/path/to/your/kenlm/build/bin # Здесь указываете путь до вашего kenlm

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

export PATH=$PATH:/path/to/your/kaldi/src/lmbin

Итак, мы закончили настраивать наше окружение, пора приступать к самым важным шагам для того, чтобы сгенерировать новый итоговый граф ./graph/HCLG.fst.


Сколько можно настраиваться, давайте уже что-нибудь предметное делать!

КОНФИГУРАЦИЯ СЛОВАРЯ

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

При работе с Kaldi графом называется детерминированный конечный автомат (finite state transducer) в формате openfst. Можно выделить 3 основных графа, с которыми так или иначе приходится иметь дело при обучении и адаптации систем распознавания речи, основанных на Kaldi:
  1. L_disambig.fst граф лексикона, по своей сути фонетический словарь, закодированный в детерминированный конечный автомат.
  2. G.fst граф языковой модели, представляет собой закодированную в детерминированный конечный автомат языковую модель.
  3. HCLG.fst объединение графов лексикона, языковой модели и акустической модели.


Нашей задачей по умолчанию является восстановление графа лексикона (и затем графа языковой модели), который используется vosk-ом при создании итогового графа ./graph/HCLG.fst. Файл с графом HCLG.fst в папке graph поставляется вместе с моделью vosk-model-ru-0.10 по умолчанию.

Итак, для генерации графа лексикона нам нужно создать папку ./data/local/dict, в нее нужно будет добавить несколько файлов:

  1. data/local/dict/lexicon.txt словарь фонетических транскрипций
  2. data/local/dict/extra_questions.txt словарь фонетических вариантов
  3. data/local/dict/nonsilence_phones.txt список значимых фонем
  4. data/local/dict/optional_silence.txt список необязательных обозначений тишины
  5. data/local/dict/silence_phones.txt словарь обозначений тишины


Сейчас подробно разберём, что должно быть в каждом из указанных выше файлов. Начнём со словаря фонетических транскрипций. Словарь фонетических транскрипций был также указан ранее во вводной части. Повторюсь, в таком словаре через пробел указано сначала само слово, а затем поочерёдно фонемы, которые отражают произношение слова. Конкретно в реализации vosk-model-ru можно выделить несколько разновидностей фонем:

  • SIL, GBG неречевые звуки:
    • SIL обозначение тишины
    • GBG обозначение иных любых неречевых звуков
  • a0, e0, i0, безударные гласные
  • a1, e1, i1, ударные гласные
  • bj, dj, fj, мягкие парные согласные
  • c, ch, j, остальные непарные согласные.


Основа для этого словаря поставляется с моделью vosk-model-ru-0.10 в файле ./extra/db/ru.dic. В таком словаре через пробел указано сначала само слово, а затем поочерёдно фонемы, которые отражают произношение слова. Кроме непосредственного содержания этого словаря надо добавить две строки в начало ru.dic: !SIL и [unk]. Начало файла будет следующее:

./data/local/dict/lexicon.txt
!SIL SIL[unk] GBGа a0а a1а-а a0 a1а-а-а a0 a0 a1

Весь дальнейший файл аналогичен ./extra/db/ru.dic, добавлены только две строчки сверху. Изменённый файл нужно сохранить в ./data/local/dict/lexicon.txt.

Затем нужно определить файл extra_questions.txt, который описывает схожести среди разных фонем. Его нужно оформить следующим образом:

./data/local/dict/extra_questions.txt
a0 a1 b bj c ch d dj e0 e1 f fj g gj h hj i0 i1 j k kj l lj m mj n nj o0 o1 p pj r rj s sch sh sj t tj u0 u1 v vj y0 y1 z zh zjSIL GBG

Также нужно определить другие файлы, описывающие различные фонемы и категории, к которым эти фонемы относятся. ./data/local/dict/nonsilence_phones.txt сформирован на основе файла ./graph/phones.txt, но убрана нумерация после пробела и убраны постфиксы у фонем. С помощью этих же фонем описаны все слова (кроме !SIL и [unk]) в lexicon.txt, то есть это наш основной инструмент по описанию обыкновенных русскоязычных слов с точки зрения их произношения. После того как провели сортировку и убрали дубликаты, у нас получается файл ./data/local/dict/nonsilence_phones.txt, первые пять строк которого указаны ниже:

./data/local/dict/nonsilence_phones.txt
a0a1bbjc

Ну и наконец определяем наши мусорные звуки и звук тишины.

./data/local/dict/optional_silence.txt
SIL

./data/local/dict/silence_phones.txt
SILGBG

Следует обратить особое внимание на то, чтобы все строки были однообразно оформлены, чтобы были Linux-овские переносы строк "\n", чтобы все файлы были в кодировке UTF-8. После шагов, обозначенных выше, мы наконец можем выполнять шаги по адаптации нашей модели.


Читающий эту статью, кот и файлы для генерации графа L_disambig.fst


МОДИФИКАЦИЯ СЛОВАРЯ

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

git clone https://github.com/DinoTheDinosaur/russian_g2p_neuro.gitcd russian_g2p_neuro/python setup.py install

Этот модуль предобучен на ru.dic, поэтому он формирует новый словарь по образу и подобию изначального словаря для vosk-model-ru-0.10. Чтобы сгенерировать новые транскрипции для списка слов достаточно запустить команду:

generate_transcriptions extra/db/input.txt extra/db/output.dict

В input.txt перечислены в любом виде слова на кириллице (в том числе целые тексты с повторениями), а в output.dict формируется список всех этих слов с соответствующими транскрипциями. Результат output.dict можно совместить с данными из lexicon.txt и сформировать новый расширенный словарь:

mv data/local/dict/lexicon.txt extra/db/lexicon_old.txtcat extra/db/lexicon_old.txt extra/db/output.dict | sort | uniq > data/local/dict/lexicon.txt


ЗАМЕНА СЛОВАРЯ

Как и при модификации словаря, мы можем поменять lexicon.txt, но при этом заменить все исходные транскрипции. Обычно это нужно, если мы хотим уметь распознавать лишь те слова, которые мы определили. Такая ситуация возникает при реализации распознавания команд или в целом при использовании языковых моделей в формате грамматик.

Шаги по установке и использованию те же самые:

git clone https://github.com/DinoTheDinosaur/russian_g2p_neuro.gitcd russian_g2p_neuro/python setup.py installcd /path/to/your_asr_project/generate_transcriptions extra/db/input.txt extra/db/output.dict

Однако последний шаг отличается:

mv data/local/dict/lexicon.txt extra/db/lexicon_old.txtsed s/^/!SIL SIL\n[unk] GBG\n/ extra/db/output.dict > data/local/dict/lexicon.txt

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

ФОРМИРОВАНИЕ ГРАФА ЛЕКСИКОНА


Это не граф, это кот

Когда все файлы корректно сформированы, директория наконец-то готова к запуску скрипта utils/prepare_lang.sh из корневой директории вашего проекта по адаптации. Запуск данного скрипта создаст нужный нам граф лексикона под названием L_disambig.fst

utils/prepare_lang.sh --phone-symbol-table graph/phones.txt data/local/dict "[unk]" data/tmp/ data/dict/

Если при запуске скрипт возвращает ошибку, то стоит проверить свои файлы для построения лексикона на правильность. Если не удаётся сформировать данные файлы своими силами, то можно воспользоваться дампом директории локальной dict за дату 04/03/2021 по ссылке в google drive.

По итогу выполнения скрипта можно будет найти нужный нам L_disambig.fst в папке data/dict. После этого можно приступать к модификации и замене языковой модели.

ЗАМЕНА ЯЗКОВОЙ МОДЕЛИ НА N-ГРАММНУЮ

Работу над нашими языковыми моделями будем вести в новой директории ./data/local/lang. Если у вас есть тексты, по аналогии с которыми вы хотите распознавать какие-то фиксированные ключевые фразы, но при этом не хотите распознавать обычную спонтанную речь, то этот пункт для вас. Обычно имеет смысл использовать такой подход, если есть большой массив примеров команд и каких-то кодовых фраз и нет возможности прописать грамматику, которая бы предусмотрела все варианты.

Допустим, что корпус с вашими примерами реплик вы положили в ./extra/db/your.corpus. Начнём с того, что сформируем новую языковую модель с помощью установленного ранее kenlm:

lmplz -o 3 --limit_vocab_file graph/words.txt < extra/db/your.corpus > data/local/lang/lm.arpa

Проясним немного, что в этой команде обозначает каждый из параметров:

  1. -o order, то есть максимальный порядок словесных n-грамм, для которых мы подсчитываем вероятности
  2. --limit_vocab_file словарь, в соответствии с которым фильтруются входные данные. Мы будем использовать этот параметр, если мы не хотим добавлять новых слов в словарь. Если мы не используем этот параметр, то необходимо после построения языковой модели также модифицировать словарь и следовать пунктам, отмеченным
  3. -S 30% не указан, но можно добавить в случае если в системе не хватает памяти на расчёт модели.


По результату выполнения этой команды мы получим файл такого формата:

./data/local/lang/lm.arpa
\data\ngram 1=51515ngram 2=990559ngram 3=3056222\1-grams:-5.968162       [unk]   00       <s>     -2.2876017-1.5350189      </s>    0-2.3502047      а       -0.7859633-3.6979482      банки   -0.42208096-3.9146104      вторую  -0.46862456-2.0171714      в       -1.142168

Языковая модель в формате ARPA построена следующим образом:

  1. Вначале указана шапка \data\, в которой указано количество каждой n-граммы
  2. Затем по очереди указаны все униграммы, биграммы и т.п. с соответствующими им заголовками \1-grams, \2-grams и т.п.
  3. Перечисление n-грамм начинается со значения логарифма вероятности появления последовательности (-3.6979482)
  4. Затем через знак табуляции указана сама последовательность (униграмма банки)
  5. Через ещё один знак табуляции так называемый backoff weight (-0.42208096), который позволяет высчитывать вероятности для последовательностей, которые явным образом не представлены в языковой модели
  6. Заканчивается файл ARPA меткой \end\


Когда у нас готова наша языковая модель, нужно заменить все "&ltunk&gt" обозначения на "[unk]":

sed -i "s/<unk>/[unk]/g" data/local/lang/lm.arpa

Ну и наконец, когда у нас есть готовая ARPA модель, мы можем сгенерировать новый граф языковой модели G.fst и таким образом подготовиться к итоговому объединению всех результатов в HCLG.fst:

arpa2fst --disambig-symbol=#0 --read-symbol-table=data/dict/words.txt data/local/lang/lm.arpa graph/G.fst

В результате выполнения последней команды рядом с графом по умолчанию HCLG.fst мы положили новый граф языковой модели G.fst. Следующим и последним шагом мы генерируем новый итоговый граф HCLG.fst с помощью нового графа языковой модели G.fst.

МОДИФИКАЦИЯ N-ГРАММНОЙ ЯЗКОВОЙ МОДЕЛИ

Когда мы хотим распознавать спонтанную речь, и при этом добавить какие-то необычные речевые конструкции, то можно расширить нашу языковую модель. Благо, вместе с моделью vosk-model-ru-0.10 поставляются сжатые языковые модели в формате ARPA ./extra/db/ru-small.lm.gz и ./extra/db/ru.lm.gz, которые участвовали в формировании модели vosk-model-ru-0.10.

Аналогично предыдущему пункту, мы генерируем нашу новую lm.arpa и заменяем в ней символы "&ltunk&gt":

lmplz -o 4 --limit_vocab_file graph/words.txt < extra/db/your.corpus > data/local/lang/lm.arpased -i 's/<unk>/[unk]/g' data/local/lang/lm.arpa

Обратим ваше внимание, что здесь мы используем другое максимальный порядок n-грамм (параметр -o). Это мы делаем, чтобы продемонстрировать то, как можно объединить две языковых модели в одну, а объединять можно языковые модели только одинакового порядка. Рассмотрим те модели, которые мы имеем на данный момент:

  1. ./extra/db/ru-small.lm.gz 3-граммная ЯМ
  2. ./extra/db/ru.lm.gz 4-граммная ЯМ


Как вы могли догадаться, мы для примера будем объединять нашу модель с большой языковой моделью ru.lm. Для объединения языковых моделей порядка 4 можно воспользоваться следующим кодом merge_lms.py. Если же вы будете объединять свою модель порядка 3 с моделью ru-small.lm, то можно воспользоваться кодом, представленным в данной статье Kaldi ASR: Extending the ASpIRE model в пункте под названием Merging the input files.

Перед использованием извлечём архив с моделью:

gunzip /path/to/your/vosk-model-ru-0.10/extra/db/ru.lm.gz

Использование merge_lms.py из корневой директории проекта:

python utils/merge_lms.py /path/to/your/vosk-model-ru-0.10/extra/db/ru.lm data/local/lang/lm.arpa data/local/lang/lm_joint.arpa

Теперь результат объединения можно конвертировать в граф с помощью команды arpa2fst:

arpa2fst --disambig-symbol=#0 --read-symbol-table=data/dict/words.txt data/local/lang/lm_joint.arpa graph/G.fst

Аналогично предыдущему пункту, G.fst готов, остался последний шаг генерация HCLG.fst.


Братья L_disambig.fst и G.fst

ЗАМЕНА ЯЗКОВОЙ МОДЕЛИ НА ГРАММАТИКУ

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

./graph/G.txt
0 1 [unk] [unk]0 1 да да0 1 нет нет1 0.0

Эта грамматика служит способом выявления конкретных речевых событий команд и распознаёт только 3 команды:

  1. Слово да
  2. Слово нет
  3. Иное слово


События эти равновероятны, и все могут повторяться только один раз. Это пример очень простой грамматики, однако с помощью этого подхода можно задавать куда более сложные структуры. У конкретно такой грамматики 0 является начальной вершиной графа, 1 конечной, но могут быть также промежуточные вершины, может быть несколько конечных состояний, и также можно определять вероятности каждого перехода. Этот граф определяет переходы из начального состояния в конечное по нескольким возможным равновероятным ребрам: [unk], да и нет.

Чтобы сформировать наш уже знакомый и любимый G.fst, нужно преобразовать эту грамматику из текстового вида в бинарный:

fstcompile --isymbols=data/dict/words.txt --osymbols=data/dict/words.txt --keep_isymbols=false --keep_osymbols=false G.txt | fstarcsort --sort_type=ilabel > G.fst

Ура! Теперь и с помощью этого последнего способа мы смогли сгенерировать тот же самый G.fst. Осталось совсем чуть-чуть.

ФОРМИРОВАНИЕ ИТОГОВОГО ГРАФА

Наконец-то мы можем приступить к финальному и самому ответственному моменту: к генерации итогового графа. Делается это ровно одной строкой:

utils/mkgraph.sh --self-loop-scale 1.0 data/lang/ am/ graph/

Теперь ваше персонализированное распознавание речи готово! Достаточно лишь сослаться на вашу рабочую директорию при инициализации модели vosk-а:

from vosk import Modelmodel = Model("/path/to/your_asr_project/")

И далее уже в интерфейсе vosk-а реализовывать распознавание.


Вы заслужили

ПРОДОЛЖЕНИЕ СЛЕДУЕТ...

Надеюсь, статья для вас была полезной и увлекательной. Очень хочется, чтобы технология распознавания речи была несколько более доступной для желающих разобраться в этой теме. Давайте развивать опенсорс распознавания речи вместе! :)
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru