用Rust构建一个跨平台的TFIDF文本摘要生成器

用Rust打造跨平台的TF-IDF文本摘要生成工具

在Rust中的跨平台自然语言处理

使用Rayon进行优化,用于C/C++、Android和Python的应用

Photo by Patrick Tomasso on Unsplash

Python生态系统中的NLP工具和实用程序已经大幅增长,使开发者从各个层次能够构建高质量的语言应用程序。Rust是NLP的一个较新引入,像HuggingFace等组织正在采用它构建供机器学习使用的软件包。

Hugging Face用Rust编写了一个新的机器学习框架,现在已开源!

最近,Hugging Face开源了一个重量级的机器学习框架Candle,这是一种与通常的Python不同的方法…

VoAGI.com

在本文中,我们将探讨如何使用TFIDF的概念构建一个文本摘要生成器。我们将首先直观地了解TFIDF摘要生成的原理,以及为什么Rust可能是一个好的语言来实现NLP管道,以及如何在C/C++、Android和Python等其他平台上使用我们的Rust代码。此外,我们将讨论如何使用Rayon进行并行计算来优化摘要生成任务。

这是GitHub项目:

GitHub – shubham0204 / tfidf-summarizer.rs:简单高效的跨平台TFIDF基于文本摘要生成器

Rust中的简单高效且跨平台的TFIDF基本文本摘要生成器- GitHub – shubham0204/tfidf-summarizer.rs…

github.com

让我们开始吧➡️

目录

  1. 动机
  2. 提取式和归纳式文本摘要生成
  3. 理解TFIDF的文本摘要生成
  4. Rust实现
  5. 与C的使用
  6. 未来展望
  7. 结论

动机

2019年,我使用相同的技术构建了一个文本摘要生成器,名为Text2Summary的Kotlin版本。它主要设计用于Android应用程序,作为一个旁项目,并且在所有计算中使用Kotlin。快进到2023年,我现在正在与C、C++和Rust代码库一起工作,并且已经在Android和Python中使用了这些本地语言构建的模块。

我选择在Rust中重新实现Text2Summary,因为它将作为一次很好的学习经验,并且还作为一个小巧、高效、方便处理大文本的文本摘要生成器。Rust是一种编译语言,具有智能借用和引用检查器,有助于开发人员编写无bug的代码。用Rust编写的代码可以与Java代码库集成通过jni,并转换为用于C/C++和Python的C头文件/库。

提取式和归纳式文本摘要生成

文本摘要生成是自然语言处理(NLP)中一个长期研究的问题。从文本中提取重要信息,并生成给定文本的摘要是文本摘要生成器需要解决的核心问题。解决方案属于两个类别,即提取式摘要生成和归纳式摘要生成。

理解自动文本摘要-1:抽取方法

我们如何自动摘要我们的文件?

towardsdatascience.com

在抽取式文本摘要中,短语或句子直接从句子中提取。我们可以使用评分函数对句子进行排名,并从中选择得分最高的句子作为摘要。与生成式摘要不同的是,摘要是从文本中选择的句子集合,因此避免了生成模型存在的问题。

  • 在抽取摘要中,文本的准确性得到保持,但由于选择的文本粒度仅限于句子,会有一定程度上的信息丢失的风险。如果某个信息分布在多个句子中,评分函数必须考虑包含这些句子的关系。
  • 生成式文本摘要需要更大的深度学习模型来捕捉语言的语义,并构建适当的文档到摘要的映射。训练此类模型需要庞大的数据集和较长的训练时间,从而严重超载计算资源。预训练模型可能解决训练时间较长和数据需求较多的问题,但仍然固有地偏向于它们训练的文本领域。
  • 抽取式方法可能具有不需要学习的无参数评分函数。它们属于无监督学习的范畴,非常有用,因为它们需要较少的计算,并且不偏向于文本领域。摘要在新闻文章和小说摘录上同样有效。

使用我们基于TFIDF的技术,我们不需要任何训练数据集或深度学习模型。我们的评分函数基于不同句子中单词的相对频率。

理解TFIDF的文本摘要

为了对每个句子进行排名,我们需要计算一个可以量化句子中所含信息量的分数。TF-IDF由两个术语组成- TF 表示术语频率,IDF表示逆文档频率。

从头开始在Python中创建TF(术语频率)-IDF(逆文档频率)

从头开始创建TF-IDF模型

towardsdatascience.com

我们假设每个句子由令牌(单词)组成

Expr 1: 用单词元组表示的句子S

每个句子S中每个词的术语频率定义为

Expr 2: k代表句子中的总单词数

在句子S中,每个词的逆文档频率定义为

Expr 3:逆文档频率表征词在其他句子中的出现次数

每个句子的分数是该句子中所有词的TF-IDF得分之和

Expr 4:决定句子S是否被包括在最终摘要中的每个句子的得分

重要性和直觉

正如您可能观察到的,对于在句子中较少出现的词,其词频就会较低。如果同一个词在其他句子中出现较少,那么它的IDF分数也会较高。因此,包含重复词(较高的TF)且这些词在该句子中更独特(较高的IDF)的句子将具有较高的TFIDF分数。

Rust实现

我们通过创建将给定文本转换为句子Vec的函数来实现我们的技术。这个问题被称为句子分词,它在文本中识别句子边界。使用Python的nltkpunkt句子分词器可以完成此任务,而且也存在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"];/// 将`text`转换为句子列表/// 它使用了一个著名的Rust版本的Punkt句子分词器:/// <`/`>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

接下来,我们创建一个函数,用于计算语料库中每个单词的频率。该方法将用于计算句子中每个单词的词频。将(单词, 频率)对存储在哈希表中,以便后续快速检索。

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表示为:// 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表示为:// 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分数的函数。为了计算每个句子的最终分数,从而确定其排名,我们必须计算句子中所有单词的TF-IDF分数之和。

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 ;         // 计算每个单词的TF-IDF分数        // 并将其添加到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( " " )}

使用人造纤维

对于较大的文本,我们可以使用一个流行的Rust包rayon-rs在多个CPU线程上并行执行一些操作。在上面的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++

要在C中使用Rust结构体和函数,我们可以使用cbindgen生成包含结构体/函数原型的C风格头文件。在生成头文件后,我们可以将Rust代码编译为C-based 动态或静态库,其中包含在头文件中声明的函数的实现。要生成C-based静态库,需要将Cargo.toml中的crate_type参数设置为staticlib

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

接下来,我们在ABI(应用二进制接口)中将summarizer的函数暴露出来,放置在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命令来构建静态库,生成的libsummarizer.a文件将放置在target目录下。

Android

使用Android的原生开发工具包(NDK),我们可以将Rust程序编译成armeabi-v7aarm64-v8a目标。我们需要使用Java Native Interface (JNI)编写特殊的接口函数,在src/lib.rs中可以找到这些函数。

Kotlin JNI调用原生代码

如何从Kotlin中调用原生代码。

matt-moore.medium.com

Python

使用Python的ctypes模块,我们可以加载共享库(.so.dll文件),并使用C兼容的数据类型来执行库中定义的函数。目前,这段代码在GitHub项目中还不可用,但很快将会提供。

Python绑定:从Python调用C或C++ – Real Python

Python绑定是什么?应该使用ctypes、CFFI还是其他工具?在这个逐步教程中,你将学到…

realpython.com

未来展望

这个项目可以在许多方面进行扩展和改进,我们将在下面进行讨论:

  1. 当前的实现需要nightly版本的Rust构建,这仅仅是因为一个依赖项punktpunkt是一个用于确定文本中句子边界的句子分词器,之后进行其他计算。如果可以使用稳定的Rust构建punkt,当前的实现将不再需要nightly版本的Rust。
  2. 添加新的指标来对句子进行排序,尤其是捕捉句子间的依赖关系。TFIDF并不是最准确的评分函数,也有其自身的局限性。构建句子图并使用它们为句子评分可以大大提高提取摘要的整体质量。
  3. 摘要生成器尚未与已知的数据集进行基准测试。经常使用Rouge得分 R1 , R2 RL来评估生成摘要的质量与标准数据集(如纽约时报数据集CNN每日邮报数据集)对比。对标准基准进行性能测试将为开发者提供更清晰、更可靠的实现方向。

结论

使用Rust构建自然语言处理实用程序具有显着优势,考虑到这门语言因其性能和未来承诺而在开发者中越来越受欢迎。希望本文对你有所帮助。请查看GitHub项目:

GitHub – shubham0204/tfidf-summarizer.rs:简单、高效且跨平台的基于TFIDF的文本摘要工具…

简单、高效且跨平台的基于TFIDF的文本摘要工具在Rust中 – GitHub – shubham0204/tfidf-summarizer.rs…

github.com

如果你觉得可以改进某些地方,可以考虑提出问题或拉取请求!继续学习,祝你有美好的一天。