Построение кросс-платформенного текстового суммаризатора TFIDF на Rust

Создание кросс-платформенного текстового суммаризатора TFIDF на языке программирования Rust

Cross Platform NLP in Rust

Оптимизация с помощью Rayon с применением в C/C++, Android и Python

Фото: Патрик Томассо на Unsplash

Инструменты и утилиты NLP значительно выросли в экосистеме Python, позволяя разработчикам всех уровней создавать масштабные приложения для обработки языка высокого качества. Rust – это новое введение в NLP, с организациями, такими как HuggingFace, которые приняли его для создания пакетов машинного обучения.

Hugging Face написал новый фреймворк ML на Rust, и теперь он является открытым исходным кодом!

Недавно Hugging Face выпустил тяжелый фреймворк ML под названием Candle, и это отступление от обычного Python…

VoAGI.com

В этом блоге мы рассмотрим, как можно создать текстовый сумматор, используя концепцию TFIDF. Сначала мы поймем, как работает суммирование TFIDF и почему Rust может быть хорошим языком для реализации NLP-конвейеров и использования на других платформах, таких как C/C++, Android и Python. Кроме того, мы обсудим, как оптимизировать задачу суммирования с помощью параллельных вычислений с помощью Rayon.

Вот ссылка на GitHub-проект:

GitHub – shubham0204/tfidf-summarizer.rs: Простой, эффективный и кросс-платформенный сумматор текстов на основе TFIDF…

Простой, эффективный и кросс-платформенный сумматор текстов на основе TFIDF, написанный на Rust – GitHub – shubham0204/tfidf-summarizer.rs…

github.com

Приступим ➡️

Содержание

  1. Мотивация
  2. Извлекающее и абстрактное суммирование текста
  3. Понимание суммирования текста с помощью TFIDF
  4. Реализация на Rust
  5. Использование с C
  6. Перспективы
  7. Заключение

Мотивация

Я создал сумматор текста с использованием этой же техники еще в 2019 году с помощью Kotlin и назвал его Text2Summary. Он был в первую очередь разработан для Android-приложений в качестве побочного проекта и использовал Kotlin для всех вычислений. Перейдем к 2023 году: сейчас я работаю с кодами C, C++ и Rust и использовал модули, созданные на этих языках, в Android и Python.

Я решил переосмыслить Text2Summary на Rust, так как это будет отличным опытом обучения и также небольшой, эффективный и удобный сумматор текста, который может легко обрабатывать большие тексты. Rust – это компилируемый язык с интеллектуальными проверками заимствования и ссылок, которые помогают разработчикам писать безошибочный код. Код, написанный на Rust, может быть интегрирован с кодовыми базами Java через jni и преобразован в заголовки библиотеки C для использования в C/C++ и Python.

Извлекающее и абстрактное суммирование текста

Суммирование текста – это долго изучаемая проблема в обработке естественного языка (NLP). Извлечение важной информации из текста и составление сводки данного текста – основная проблема, которую должны решить сумматоры текста. Решения принадлежат двум категориям: извлекающее суммирование и абстрактное суммирование.

Понимание автоматической краткости текста – 1: экстрактивные методы

Как мы можем автоматически резюмировать наши документы?

towardsdatascience.com

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

  • В экстрактивной краткости текста сохраняется точность текста, но существует высокая вероятность потери некоторой информации, поскольку гранулярность выбираемого текста ограничивается только предложениями. Если информация распространяется на несколько предложений, функция оценки должна учитывать связь, содержащую эти предложения.
  • Абстрактная краткость текста требует более крупных моделей глубокого обучения для захвата семантики языка и создания соответствующего отображения документ-резюме. Обучение таких моделей требует огромных наборов данных и длительного времени обучения, что в свою очередь перегружает вычислительные ресурсы. Предварительно обученные модели могут решить проблему более длительного времени обучения и потребностей в данных, но они все равно имеют врожденную предвзятость к домену текста, на котором они обучены.
  • Экстрактивные методы могут иметь функции оценки, которые не зависят от параметров и не требуют обучения. Они относятся к режиму неконтролируемого обучения ML и полезны, так как требуют меньших вычислительных затрат и не имеют предвзятости к домену текста. Краткость может быть также эффективной как для новостных статей, так и для отрывков из романов.

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

Понимание краткости текста с использованием TFIDF

Для ранжирования каждого предложения нам необходимо вычислить оценку, которая бы количественно измеряла количество информации, содержащейся в предложении. TF-IDF состоит из двух терминов – TF, что означает частотность терма, и IDF, что обозначает обратную частотность документа.

TF(Term Frequency)-IDF(Inverse Document Frequency) c нуля на питоне.

Создание модели TF-IDF с нуля

towardsdatascience.com

Мы считаем, что каждое предложение состоит из токенов (слов),

Выражение 1: Предложение S представлено в виде кортежа слов

Частота терма каждого слова в предложении S определяется как,

Выражение 2: k представляет общее количество слов в предложении.

Обратная частотность документа каждого слова в предложении S определяется как,

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

Оценка каждого предложения является суммой оценок TF-IDF всех слов в этом предложении,

Выражение 4: Оценка каждого предложения S, которая определяет его включение в окончательное резюме.

Значимость и интуиция

Термин “частотность”, как вы могли заметить, будет меньше для слов, которые встречаются реже в предложении. Если то же самое слово имеет меньшую частотность в других предложениях, то оценка IDF также будет выше. Таким образом, предложение, содержащее повторяющиеся слова (более высокая TF), являющиеся более эксклюзивными только для этого предложения (более высокая IDF), будет иметь более высокую оценку TFIDF.

Реализация на Rust

Мы начинаем реализацию нашей техники, создавая функции, которые преобразуют данный текст в Vec предложений. Эта проблема относится к токенизации предложений, которая определяет границы предложений в тексте. С помощью пакетов Python, таких как nltk, для этой задачи доступен токенизатор предложений punkt, и также существует реализация на Rust для Punkt. rust-punkt больше не поддерживается, но мы все еще используем его здесь. Также написана функция, которая разделяет предложение на слова,

use punkt::{SentenceTokenizer, TrainingData};use punkt::params::Standard;static STOPWORDS: [ &str ; 127 ] = [ "i", "me", "my", "myself", "we", "our", "ours", "ourselves", "you",     "your", "yours", "yourself", "yourselves", "he", "him", "his", "himself", "she", "her", "hers", "herself",     "it", "its", "itself", "they", "them", "their", "theirs", "themselves", "what", "which", "who", "whom", "this",     "that", "these", "those", "am", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "having",      "do", "does", "did", "doing", "a", "an", "the", "and", "but", "if", "or", "because", "as", "until", "while", "of",      "at", "by", "for", "with", "about", "against", "between", "into", "through", "during", "before", "after", "above",     "below", "to", "from", "up", "down", "in", "out", "on", "off", "over", "under", "again", "further", "then", "once",       "here", "there", "when", "where", "why", "how", "all", "any", "both", "each", "few", "more", "most", "other",        "some", "such", "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very", "s", "t", "can",        "will", "just", "don", "should", "now" ] ;/// Превращает `текст` в список предложений/// Используется популярный токенизатор предложений Punkt на Rust: /// <`/`>https://github.com/ferristseng/rust-punkt<`/`>pub fn text_to_sentences( text: &str ) -> Vec<String> {    let english = TrainingData::english();    let mut sentences: Vec<String> = Vec::new() ;     for s in SentenceTokenizer::<Standard>::new(text, &english) {        sentences.push( s.to_owned() ) ;     }    sentences}/// Преобразует предложение в список слов (токенов)/// исключая стоп-слова в процессеpub fn sentence_to_tokens( sentence: &str ) -> Vec<&str> {    let tokens: Vec<&str> = sentence.split_ascii_whitespace().collect() ;     let filtered_tokens: Vec<&str> = tokens                                .into_iter()                                .filter( |token| !STOPWORDS.contains( &token.to_lowercase().as_str() ) )                                .collect() ;    filtered_tokens}

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

Предварительная обработка текста: удаление стоп-слов с использованием разных библиотек

Удобное руководство по удалению английских стоп-слов в Python!

towardsdatascience.com

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

use std::collections::HashMap;/// Для данного списка слов создаётся карта частот/// где ключами являются слова, а значениями являются частоты этих слов/// Этот метод будет использоваться для вычисления частоты терминов каждого слова/// присутствующего в предложенииpub fn get_freq_map<'a>( words: &'a Vec<&'a str> ) -> HashMap<&'a str,usize> {    let mut freq_map: HashMap<&str,usize> = HashMap::new() ;     for word in words {        if freq_map.contains_key( word ) {            freq_map                .entry( word )                .and_modify( | e | {                     *e += 1 ;                 } ) ;         }        else {            freq_map.insert( *word , 1 ) ;         }    }    freq_map}

Затем мы пишем функцию, которая вычисляет частоту терминов слов, присутствующих в предложении,

// Вычисление частоты терминов присутствующих в данном предложении (токенизированных)// Частота термина TF для токена 'w' выражается как,// TF(w) = (частота w в предложении) / (общее количество токенов в предложении)fn compute_term_frequency<'a>(    tokenized_sentence: &'a Vec<&str>) -> HashMap<&'a str,f32> {    let words_frequencies = Tokenizer::get_freq_map( tokenized_sentence ) ;    let mut term_frequency: HashMap<&str,f32> = HashMap::new() ;      let num_tokens = tokenized_sentence.len() ;     for (word , count) in words_frequencies {        term_frequency.insert( word , ( count as f32 ) / ( num_tokens as f32 ) ) ;     }    term_frequency}

Другая функция, которая вычисляет IDF, обратную частотность документа, для слов в токенизированном предложении,

// Вычисление обратной частотности документа токенов в данном предложении (токенизированных)// Обратная частотность документа IDF для токена 'w' выражается как,// IDF(w) = log( N / (Количество документов, в которых присутствует w) )fn compute_inverse_doc_frequency<'a>(    tokenized_sentence: &'a Vec<&str> ,    tokens: &'a Vec<Vec<&'a str>>) -> HashMap<&'a str,f32> {    let num_docs = tokens.len() as f32 ;     let mut idf: HashMap<&str,f32> = HashMap::new() ;     for word in tokenized_sentence {        let mut word_count_in_docs: usize = 0 ;         for doc in tokens {            word_count_in_docs += doc.iter().filter( |&token| token == word ).count() ;        }        idf.insert( word , ( (num_docs) / (word_count_in_docs as f32) ).log10() ) ;    }    idf}

Теперь мы добавили функции для вычисления оценок TF и IDF для каждого слова, присутствующего в предложении. Чтобы вычислить окончательную оценку для каждого предложения, которая также определит его ранг, необходимо вычислить сумму оценок TFIDF всех слов, присутствующих в предложении.

pub fn compute(     text: &str ,     reduction_factor: f32 ) -> String {    let sentences_owned: Vec<String> = Tokenizer::text_to_sentences( text ) ;     let mut sentences: Vec<&str> = sentences_owned                                            .iter()                                            .map( String::as_str )                                            .collect() ;     let mut tokens: Vec<Vec<&str>> = Vec::new() ;     for sentence in &sentences {        tokens.push( Tokenizer::sentence_to_tokens(sentence) ) ;     }    let mut sentence_scores: HashMap<&str,f32> = HashMap::new() ;        for ( i , tokenized_sentence ) in tokens.iter().enumerate() {        let tf: HashMap<&str,f32> = Summarizer::compute_term_frequency(tokenized_sentence) ;         let idf: HashMap<&str,f32> = Summarizer::compute_inverse_doc_frequency(tokenized_sentence, &tokens) ;         let mut tfidf_sum: f32 = 0.0 ;         // Вычисление оценки TFIDF для каждого слова        // и добавление ее в tfidf_sum        for word in tokenized_sentence {            tfidf_sum += tf.get( word ).unwrap() * idf.get( word ).unwrap() ;         }        sentence_scores.insert( sentences[i] , tfidf_sum ) ;     }    // Сортировка предложений по оценкам    sentences.sort_by( | a , b |         sentence_scores.get(b).unwrap().total_cmp(sentence_scores.get(a).unwrap()) ) ;     // Вычисление числа предложений, входящих в резюме    // и возвращение извлеченного резюме    let num_summary_sents = (reduction_factor * (sentences.len() as f32) ) as usize;    sentences[ 0..num_summary_sents ].join( " " )}

Использование Rayon

Для более крупных текстов мы можем выполнять некоторые операции параллельно, то есть на нескольких процессорных потоках, используя популярную крейт Rust rayon-rs. В функции compute выше мы можем выполнять следующие задачи параллельно:

  • Преобразование каждого предложения в токены и удаление стоп-слов
  • Вычисление суммы оценок TFIDF для каждого предложения

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

Arc гарантирует, что ссылаемый Mutex доступен для всех потоков, а сам Mutex позволяет только одному потоку получить доступ к объекту, обернутому в него. Вот еще одна функция par_compute, которая использует Rayon и выполняет вышеуказанные задачи параллельно:

pub fn par_compute(     text: &str ,     reduction_factor: f32 ) -> String {    let sentences_owned: Vec<String> = Tokenizer::text_to_sentences( text ) ;     let mut sentences: Vec<&str> = sentences_owned                                            .iter()                                            .map( String::as_str )                                            .collect() ;         // Токенизировать предложения параллельно с помощью Rayon    // Объявить потокобезопасный Vec<Vec<&str>>, чтобы хранить токенизированные предложения    let tokens_ptr: Arc<Mutex<Vec<Vec<&str>>>> = Arc::new( Mutex::new( Vec::new() ) ) ;     sentences.par_iter()             .for_each( |sentence| {                 let sent_tokens: Vec<&str> = Tokenizer::sentence_to_tokens(sentence) ;                 tokens_ptr.lock().unwrap().push( sent_tokens ) ;              } ) ;     let tokens = tokens_ptr.lock().unwrap() ;     // Вычислить оценки для предложений параллельно    // Объявить потокобезопасный Hashmap<&str,f32>, чтобы хранить оценки предложений    let sentence_scores_ptr: Arc<Mutex<HashMap<&str,f32>>> = Arc::new( Mutex::new( HashMap::new() ) ) ;     tokens.par_iter()          .zip( sentences.par_iter() )          .for_each( |(tokenized_sentence , sentence)| {        let tf: HashMap<&str,f32> = Summarizer::compute_term_frequency(tokenized_sentence) ;         let idf: HashMap<&str,f32> = Summarizer::compute_inverse_doc_frequency(tokenized_sentence, &tokens ) ;         let mut tfidf_sum: f32 = 0.0 ;                 for word in tokenized_sentence {            tfidf_sum += tf.get( word ).unwrap() * idf.get( word ).unwrap() ;         }        tfidf_sum /= tokenized_sentence.len() as f32 ;         sentence_scores_ptr.lock().unwrap().insert( sentence , tfidf_sum ) ;     } ) ;     let sentence_scores = sentence_scores_ptr.lock().unwrap() ;    // Сортировка предложений по оценкам    sentences.sort_by( | a , b |         sentence_scores.get(b).unwrap().total_cmp(sentence_scores.get(a).unwrap()) ) ;     // Вычислить количество предложений, включаемых в резюме    // и вернуть извлеченное резюме    let num_summary_sents = (reduction_factor * (sentences.len() as f32) ) as usize;    sentences[ 0..num_summary_sents ].join( ". " ) }

Кросс-платформенное использование

C и C++

Чтобы использовать структуры и функции Rust в C, мы можем использовать cbindgen для генерации заголовков в стиле C, содержащих прототипы структур/функций. После генерации заголовков мы можем скомпилировать код Rust в C-основанные динамические или статические библиотеки, которые содержат реализацию функций, объявленных в заголовочных файлах. Чтобы сгенерировать C-основанную статическую библиотеку, необходимо установить параметр crate_type в Cargo.toml равным staticlib,

[lib]name = "summarizer"crate_type = [ "staticlib" ]

В следующем шаге мы добавляем Foreign Function Interfaces (FFI), чтобы предоставить функции summeraizer в Application Binary Interface (ABI) в файле src/lib.rs,

/// функции, предоставляющие Rust-методы в виде C-интерфейсов/// Эти методы доступны через ABI (скомпилированный объектный код)mod c_binding {    use std::ffi::CString;    use crate::summarizer::Summarizer;    #[no_mangle]    pub extern "C" fn summarize( text: *const u8 , length: usize , reduction_factor: f32 ) -> *const u8 {        ...      }    #[no_mangle]    pub extern "C" fn par_summarize( text: *const u8 , length: usize , reduction_factor: f32 ) -> *const u8 {        ...    }}

Мы можем построить статическую библиотеку с помощью cargo build, и в каталоге target будет создан libsummarizer.a.

Android

С помощью Android Native Development Kit (NDK) мы можем скомпилировать программу на Rust для целей armeabi-v7a и arm64-v8a. Нам нужно написать специальные интерфейсные функции с Java Native Interface (JNI), которые можно найти в модуле android в файле src/lib.rs.

Kotlin JNI для нативного кода

Как вызвать нативный код из Kotlin.

matt-moore.medium.com

Python

С помощью модуля ctypes в Python мы можем загрузить разделяемую библиотеку (.so или .dll) и использовать совместимые с C типы данных для выполнения функций, определенных в библиотеке. Код на GitHub проекте не доступен, но в скором времени станет доступен.

Python Bindings: Вызов C или C++ из Python – Real Python

Что такое Python-привязки? Должны ли вы использовать ctypes, CFFI или другой инструмент? В этом пошаговом руководстве вы узнаете…

realpython.com

Перспективы на будущее

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

  1. Текущая реализация требует использования еженедельной сборки Rust, только из-за одной зависимости punkt. punkt – это токенизатор предложений, который необходим для определения границ предложений в тексте, после чего выполняются другие вычисления. Если punkt можно построить с использованием стабильной версии Rust, текущая реализация уже не будет требовать использования ночной версии Rust.
  2. Добавление новых метрик для ранжирования предложений, особенно тех, которые учитывают зависимости между предложениями. TFIDF не является наиболее точной функцией оценки и имеет свои ограничения. Построение графа предложений и использование его для ранжирования предложений значительно улучшит общее качество полученного резюме
  3. Суммаризатор не был протестирован на известных наборах данных. Rouge-оценки R1, R2 и RL часто используются для оценки качества сгенерированного резюме по стандартным наборам данных, таким как набор данных New York Times или набор данных CNN Daily Mail. Измерение производительности по стандартным показателям обеспечит разработчикам большую ясность и надежность в отношении реализации.

Заключение

Построение инструментов NLP с помощью Rust имеет значительные преимущества, учитывая все более популярность этого языка среди разработчиков благодаря его производительности и перспективам. Я надеюсь, что статья была полезной. Посмотрите проект на GitHub:

GitHub – shubham0204/tfidf-summarizer.rs: Простой, эффективный и кросс-платформенный суммаризатор текста на основе TFIDF в Rust…

Простой, эффективный и кросс-платформенный суммаризатор текста на основе TFIDF в Rust – GitHub – shubham0204/tfidf-summarizer.rs…

github.com

Если у вас есть предложения по улучшению, вы можете открыть issue или отправить pull request! Продолжайте учиться и желаю вам успешного дня.