Costruire un riepilogatore di testo TFIDF multi-piattaforma in Rust

Creare un generatore di riepilogo di testo TFIDF multi-piattaforma in Rust

NLP cross-platform in Rust

Ottimizzazione con Rayon con utilizzo in C/C++, Android e Python

Foto di Patrick Tomasso su Unsplash

Gli strumenti e le utilità NLP sono cresciuti notevolmente nell’ecosistema Python, consentendo agli sviluppatori di tutti i livelli di creare app linguistiche di alta qualità su larga scala. Rust è una nuova introduzione al NLP, con organizzazioni come HuggingFace che lo adottano per costruire pacchetti per l’apprendimento automatico.

Hugging Face ha scritto un nuovo framework ML in Rust, ora open source!

Recentemente, Hugging Face ha reso open source un framework ML pesante, Candle, che è una deviazione dal solito Python…

VoAGI.com

In questo blog, esploreremo come possiamo costruire un riassuntore di testi utilizzando il concetto di TFIDF. Inizieremo con un’intuizione su come funziona la sintesi TFIDF e perché Rust potrebbe essere un buon linguaggio per implementare pipeline NLP e come possiamo utilizzare il nostro codice Rust su altre piattaforme come C/C++, Android e Python. Inoltre, discuteremo di come ottimizzare il compito di sintesi con l’elaborazione parallela con Rayon.

Ecco il progetto GitHub:

GitHub – shubham0204/tfidf-summarizer.rs: Sintetizzatore di testo basato su TFIDF semplice, efficiente e multipiattaforma…

Sintetizzatore di testo basato su TFIDF semplice, efficiente e multipiattaforma in Rust – GitHub – shubham0204/tfidf-summarizer.rs…

github.com

Iniziamo ➡️

Contenuti

  1. Motivazione
  2. Sintesi estrattiva ed astrattiva del testo
  3. Comprensione della sintesi del testo con TFIDF
  4. Implementazione in Rust
  5. Utilizzo con C
  6. Prospettive future
  7. Conclusioni

Motivazione

Ho costruito un riassuntore di testi utilizzando la stessa tecnica nel 2019, con Kotlin e chiamato Text2Summary. È stato progettato principalmente per le app Android, come progetto collaterale, e ha utilizzato Kotlin per tutti i calcoli. Avanti veloce al 2023, ora sto lavorando con codici sorgente C, C++ e Rust e ho utilizzato moduli creati in questi linguaggi nativi in Android e Python.

Ho scelto di reimplementare Text2Summary in Rust, perché sarebbe servito come un’ottima esperienza di apprendimento e anche come un riassuntore di testi piccolo, efficiente e pratico che può gestire facilmente testi di grandi dimensioni. Rust è un linguaggio compilato con checker intelligenti per il controllo di ownership e reference che aiutano gli sviluppatori a scrivere codice privo di errori. Il codice scritto in Rust può essere integrato con codice Java attraverso jni e convertito in intestazioni/librerie C per l’uso in C/C++ e Python.

Sintesi estrattiva ed astrattiva del testo

La sintesi del testo è un problema studiato da tempo nel processing del linguaggio naturale (NLP). L’estrazione di informazioni importanti dal testo e la generazione di un riassunto del testo dato è il problema centrale che i riassuntori di testo devono risolvere. Le soluzioni appartengono a due categorie, ovvero la sintesi estrattiva e la sintesi astratta.

Comprensione della sintesi automatica del testo-1: Metodi di estrazione

Come possiamo riassumere automaticamente i nostri documenti?

towardsdatascience.com

Nella sintesi estrattiva del testo, le frasi o le frasi vengono derivate direttamente dalla frase. Possiamo ordinare le frasi utilizzando una funzione di punteggio e scegliere le frasi più adatte dal testo considerando i loro punteggi. Invece di generare un nuovo testo, come nella sintesi astrattiva, il riassunto è una collezione di frasi selezionate dal testo, evitando così i problemi che i modelli generativi presentano.

  • La precisione del testo viene mantenuta nella sintesi estrattiva, ma c’è un’alta probabilità che alcune informazioni vengano perse poiché la granularità del testo selezionato è limitata alle frasi. Se una parte delle informazioni si trova in più frasi, la funzione di punteggio deve tener conto della relazione che contiene quelle frasi.
  • La sintesi astrattiva del testo richiede un modello di apprendimento profondo più grande per catturare la semantica del linguaggio e creare un adeguato mapping del documento-riassunto. Addestrare tali modelli richiede enormi set di dati e un tempo di addestramento più lungo che a sua volta sovraccarica pesantemente le risorse di calcolo. I modelli preaddestrati potrebbero risolvere il problema dei tempi di addestramento più lunghi e delle richieste di dati, ma sono ancora intrinsecamente inclini al dominio del testo su cui sono stati addestrati.
  • I metodi estrattivi possono avere funzioni di punteggio prive di parametri e non richiedere alcun apprendimento. Rientrano nel regime di apprendimento non supervisionato del ML e sono utili poiché richiedono minori calcoli e non sono inclini al dominio del testo. La sintesi potrebbe essere altrettanto efficiente su articoli di notizie così come su estratti di romanzi.

Con la nostra tecnica basata su TF-IDF, non richiediamo alcun set di dati di addestramento o modelli di apprendimento profondo. La nostra funzione di punteggio si basa sulle frequenze relative delle parole tra diverse frasi.

Comprensione della sintesi del testo con TF-IDF

Per classificare ogni frase, è necessario calcolare un punteggio che quantifichi la quantità di informazioni presenti all’interno della frase. TF-IDF comprende due termini: TF, che sta per Frequenza del Termine, e IDF che denota Frequenza Inversa dei Documenti.

TF(Frequenza del Termine)-IDF(Frequenza Inversa dei Documenti) da zero in python.

Creazione di un modello TF-IDF da zero

towardsdatascience.com

Riteniamo che ogni frase sia composta da token (parole),

Espressione 1: Frase S rappresentata come una tupla di parole

La frequenza del termine di ogni parola, nella frase S, è definita come,

Espressione 2: k rappresenta il numero totale di parole nella frase.

La frequenza inversa dei documenti di ogni parola, nella frase S, è definita come,

Espressione 3: La frequenza inversa dei documenti quantifica l'occorrenza della parola in altre frasi.

Il punteggio di ogni frase è la somma dei punteggi TF-IDF di tutte le parole in quella frase,

Espressione 4: Il punteggio di ogni frase S che determina la sua inclusione nel riassunto finale.

Significato e Intuito

Come avrete osservato, la frequenza dei termini sarebbe minore per le parole più rare nella frase. Se la stessa parola ha una presenza minore in altre frasi, il punteggio IDF è anche più alto. Pertanto, una frase che contiene parole ripetute (TF più alto) che sono più esclusive solo per quella frase (IDF più alto) avrà un punteggio TFIDF più alto.

Implementazione in Rust

Iniziamo a implementare la nostra tecnica creando funzioni che convertano un dato testo in un Vec di frasi. Questo problema è chiamato tokenizzazione delle frasi e identifica i limiti delle frasi all’interno di un testo. Con pacchetti Python come nltk, il tokenizzatore di frasi punkt è disponibile per questo compito, e esiste anche una versione in Rust di Punkt. Non viene più mantenuto rust-punkt, ma lo usiamo ancora qui. È stata anche scritta un’altra funzione che divide la frase in parole,

use punkt::{SentenceTokenizer, TrainingData};use punkt::params::Standard;static STOPWORDS: [ &str ; 127 ] = [ "io", "me", "il mio", "me stesso", "noi", "il nostro", "nostri", "noi stessi", "tu",     "il tuo", "tuoi", "te stesso", "voi", "vostro", "tuoi", "voi stessi", "lui", "lui", "suo", "se stesso", "lei", "lei", "suo", "se stessa",     "esso", "suo", "se stesso", "loro", "loro", "loro", "loro stessi", "che cosa", "che", "chi", "chi", "questo",     "quello", "questi", "quelli", "io", "è", "sono", "era", "erano", "essere", "sono stato", "essere",      "fare", "fa", "ha fatto", "facendo", "un", "uno", "il", "e", "ma", "se", "o", "perché", "come", "fino a quando", "mentre", "del",      "a", "da", "per", "con", "circa", "contro", "fra", "in", "attraverso", "durante", "prima", "dopo", "sopra",     "sotto", "a", "da", "su", "giù", "in", "fuori", "su", "fuori", "sopra", "sotto", "di nuovo", "ulteriormente", "allora", "una volta",       "qui", "là", "quando", "dove", "perché", "come", "tutto", "nessuno/a", "entrambi/e", "ogni", "poche", "altre",        "alcuni", "tale", "nessuno", "nessun", "non", "solo", "proprio/a", "così", "rispetto", "troppo", "molto", "s", "t", "può",        "volere", "appena", "non", "dovrebbe", "ora" ] ;/// Trasforma un `testo` in una lista di frasi/// Utilizza il popolare tokenizzatore di frasi Punkt da una porta Rust: /// <`/`>https://github.com/ferristseng/rust-punkt<`/`>pub fn testo_in_frasi( testo: &str ) -> Vec<String> {    let inglese = TrainingData::inglese();    let mut frasi: Vec<String> = Vec::new() ;    for s in SentenceTokenizer::<Standard>::nuovo(testo, &inglese) {        frasi.push( s.to_owned() ) ;    }    frasi}/// Trasforma la frase in una lista di parole (token)/// eliminando le stopwords durante la procedura.pub fn frase_in_token( frase: &str ) -> Vec<&str> {    let token: Vec<&str> = frase.split_ascii_whitespace().collect() ;    let token_filtrati: Vec<&str> = token                                .into_iter()                                .filter( |token| !STOPWORDS.contains( &token.to_lowercase().as_str() ) )                                .collect() ;    token_filtrati}

Nel frammento precedente, rimuoviamo le stop-word, che sono parole comuni in una lingua e non hanno un contributo significativo al contenuto informativo del testo.

Pre-elaborazione del testo: eliminazione delle stop-word utilizzando diverse librerie

Una guida pratica sulla rimozione delle stop words inglesi in Python!

towardsdatascience.com

Innanzitutto, creiamo una funzione che calcola la frequenza di ogni parola presente nel corpus. Questo metodo verrà utilizzato per calcolare la frequenza del termine di ogni parola presente in una frase. La coppia (parola, freq) viene memorizzata in una Hashmap per un recupero più veloce nelle fasi successive

use std::collections::HashMap;/// Dato un elenco di parole, costruisci una mappa di frequenza/// dove le chiavi sono le parole e i valori sono le frequenze di quelle parole/// Questo metodo sarà utilizzato per calcolare le frequenze dei termini di ogni parola/// presente in una frasepub fn get_freq_map<'a>( parole: &'a Vec<&'a str> ) -> HashMap<&'a str,usize> {    let mut freq_map: HashMap<&str,usize> = HashMap::new() ;     for parola in parole {        if freq_map.contains_key( parola ) {            freq_map                .entry( parola )                .and_modify( | e | {                     *e += 1 ;                 } ) ;         }        else {            freq_map.insert( *parola , 1 ) ;         }    }    freq_map}

In seguito, scriviamo la funzione che calcola la frequenza del termine delle parole presenti in una frase,

// Calcola la frequenza del termine dei token presenti nella frase fornita (tokenizzata)// La frequenza del termine TF del token 'w' è espressa come,// TF(w) = (frequenza di w nella frase) / (numero totale di token nella frase)fn compute_term_frequency<'a>(    frase_tokenizzata: &'a Vec<&str>) -> HashMap<&'a str,f32> {    let frequenze_parole = Tokenizer::get_freq_map( frase_tokenizzata ) ;    let mut frequenza_termine: HashMap<&str,f32> = HashMap::new() ;      let num_token = frase_tokenizzata.len() ;     for (parola , count) in frequenze_parole {        frequenza_termine.insert( parola , ( count as f32 ) / ( num_token as f32 ) ) ;     }    frequenza_termine}

Un’altra funzione che calcola l’IDF, inverse document frequency, per le parole in una frase tokenizzata,

// Calcola l'inverse document frequency dei token presenti nella frase fornita (tokenizzata)// L'inverse document frequency IDF del token 'w' è espressa come,// IDF(w) = log( N / (Numero di documenti in cui w appare) )fn compute_inverse_doc_frequency<'a>(    frase_tokenizzata: &'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 parola in frase_tokenizzata {        let mut conteggio_parole_in_documenti: usize = 0 ;         for doc in tokens {            conteggio_parole_in_documenti += doc.iter().filter( |&token| token == parola ).count() ;        }        idf.insert( parola , ( (num_docs) / (conteggio_parole_in_documenti as f32) ).log10() ) ;    }    idf}

Ora abbiamo aggiunto le funzioni per calcolare i punteggi TF e IDF di ogni parola presente in una frase. Per calcolare un punteggio finale per ogni frase, che determinerà anche il suo rank, dobbiamo calcolare la somma dei punteggi TFIDF di tutte le parole presenti in una frase.

pub fn compute(     testo: &str ,     fattore_di_riduzione: f32 ) -> String {    let frasi_possedute: Vec<String> = Tokenizer::testo_in_frasi( testo ) ;     let mut frasi: Vec<&str> = frasi_possedute                                            .iter()                                            .map( String::as_str )                                            .collect() ;     let mut tokens: Vec<Vec<&str>> = Vec::new() ;     for frase in &frasi {        tokens.push( Tokenizer::frase_in_token(frase) ) ;     }    let mut punteggi_frase: HashMap<&str,f32> = HashMap::new() ;        for ( i , frase_tokenizzata ) in tokens.iter().enumerate() {        let tf: HashMap<&str,f32> = Summarizer::compute_term_frequency(frase_tokenizzata) ;         let idf: HashMap<&str,f32> = Summarizer::compute_inverse_doc_frequency(frase_tokenizzata, &tokens) ;         let mut tfidf_sum: f32 = 0.0 ;         // Calcola il punteggio TFIDF per ogni parola        // e aggiungilo a tfidf_sum        for parola in frase_tokenizzata {            tfidf_sum += tf.get( parola ).unwrap() * idf.get( parola ).unwrap() ;         }        punteggi_frase.insert( frasi[i] , tfidf_sum ) ;     }    // Ordina le frasi in base ai loro punteggi    frasi.sort_by( | a , b |         punteggi_frase.get(b).unwrap().total_cmp(punteggi_frase.get(a).unwrap()) ) ;     // Calcola il numero di frasi da includere nel riepilogo    // e restituisci il riepilogo estratto    let num_frase_riepilogo = (fattore_di_riduzione * (frasi.len() as f32) ) as usize;    frasi[ 0..num_frase_riepilogo ].join( " " )}

Utilizzare il Rayon

Per testi più lunghi, possiamo eseguire alcune operazioni in parallelo, cioè su più thread della CPU utilizzando una popolare crate Rust chiamata rayon-rs. Nella funzione compute sopra, possiamo eseguire le seguenti attività parallelamente,

  • Convertire ogni frase in token e rimuovere le stop-word
  • Calcolare la somma dei punteggi TFIDF per ogni frase

Queste attività possono essere eseguite indipendentemente su ogni frase e non dipendono da altre frasi, quindi possono essere parallelizzate. Per garantire l’esclusione reciproca mentre diversi thread accedono a un contenitore condiviso, utilizziamo Arc (puntatore atomico con conteggio dei riferimenti) e Mutex, che è la primitiva di sincronizzazione di base per garantire l’accesso atomico.

Arc garantisce che il Mutex riferito sia accessibile a tutti i thread e il Mutex stesso consente a un solo thread di accedere all’oggetto incapsulato al suo interno. Ecco un’altra funzione chiamata par_compute, che utilizza il Rayon e esegue le attività sopra menzionate in modo parallelo,

pub fn par_compute(     testo: &str ,     fattore_riduzione: f32 ) -> String {    let frasi_possedute: Vec<String> = Tokenizer::testo_in_frasiparola( testo ) ;     let mut frasi: Vec<&str> = frasi_possedute                                            .iter()                                            .map( String::as_str )                                            .collect() ;         // Tokenizza le frasi in parallelo con Rayon    // Dichiarare un thread-safe Vec<Vec<&str>> per contenere le frasi tokenizzate    let puntatori_token: Arc<Mutex<Vec<Vec<&str>>>> = Arc::new( Mutex::new( Vec::new() ) ) ;     frasi.par_iter()             .for_each( |frase| {                 let sent_tokeni: Vec<&str> = Tokenizer::frase_in_token( frase ) ;                 puntatori_token.lock().unwrap().push( sent_tokeni ) ;              } ) ;     let token = puntatori_token.lock().unwrap() ;     // Calcola i punteggi delle frasi in parallelo    // Dichiarare uno thread-safe Hashmap<&str,f32> per contenere i punteggi delle frasi    let puntatori_punteggio: Arc<Mutex<HashMap<&str,f32>>> = Arc::new( Mutex::new( HashMap::new() ) ) ;     token.par_iter()          .zip( frasi.par_iter() )          .for_each( |(frase_tokenizzata , frase)| {        let tf: HashMap<&str,f32> = Summarizer::calcola_frequenza_termine(frase_tokenizzata) ;         let idf: HashMap<&str,f32> = Summarizer::calcola_inverse_frequenza_documento(frase_tokenizzata, &token ) ;         let mut somma_tfidf: f32 = 0.0 ;                 for parola in frase_tokenizzata {            somma_tfidf += tf.get( parola ).unwrap() * idf.get( parola ).unwrap() ;         }        somma_tfidf /= frase_tokenizzata.len() as f32 ;         puntatori_punteggio.lock().unwrap().insert( frase , somma_tfidf ) ;     } ) ;     let punteggi_frase = puntatori_punteggio.lock().unwrap() ;    // Ordina le frasi in base ai loro punteggi    frasi.sort_by( | a , b |         punteggi_frase.get(b).unwrap().total_cmp(punteggi_frase.get(a).unwrap()) ) ;     // Calcola il numero di frasi da includere nel riassunto    // e restituisci il riassunto estratto    let num_frase_riassunto = (fattore_riduzione * (frasi.len() as f32) ) as usize;    frasi[ 0..num_frase_riassunto ].join( ". " ) }

Utilizzo multi-piattaforma

C e C++

Per utilizzare le strutture e le funzioni Rust in C, possiamo utilizzare cbindgen per generare intestazioni in stile C contenenti i prototipi delle strutture/funzioni. Generando le intestazioni, possiamo compilare il codice Rust in librerie dinamiche o statiche basate su C che contengono l’implementazione delle funzioni dichiarate nei file di intestazione. Per generare una libreria statica basata su C, è necessario impostare il parametro crate_type in Cargo.toml su staticlib,

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

Successivamente, aggiungiamo gli FFI per esporre le funzioni del summarizer nell’ABI (interfaccia binaria dell’applicazione) in src/lib.rs,

/// funzioni che espongono i metodi Rust come interfacce C/// Questi metodi sono accessibili con l'ABI (codice oggetto compilato)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 {        ...    }}

Possiamo creare la libreria statica con cargo build e libsummarizer.a verrà generato nella cartella target.

Android

Con il kit di sviluppo nativo di Android (NDK), possiamo compilare il programma Rust per i target armeabi-v7a e arm64-v8a. Dobbiamo scrivere funzioni di interfaccia speciali con l’interfaccia nativa di Java (JNI), che si trovano nel modulo android in src/lib.rs.

Kotlin JNI per il codice nativo

Come chiamare il codice nativo da Kotlin.

matt-moore.medium.com

Python

Con il modulo ctypes di Python, possiamo caricare una libreria condivisa ( .so o .dll ) e utilizzare i tipi di dati compatibili con C per eseguire le funzioni definite nella libreria. Il codice non è disponibile nel progetto su GitHub, ma sarà presto disponibile.

Python Bindings: Chiamare C o C++ da Python – Real Python

Cosa sono i legami di Python? Dovresti usare ctypes, CFFI o un altro strumento? In questo tutorial passo-passo, otterrai…

realpython.com

Futuri sviluppi

Il progetto può essere esteso e migliorato in molti modi, che discuteremo di seguito:

  1. L’implementazione attuale richiede la versione nightly di Rust, solo a causa di una singola dipendenza punkt. punkt è un tokenizer di frasi che è necessario per determinare i confini delle frasi nel testo, a seguito del quale vengono effettuati altri calcoli. Se punkt può essere compilato con Rust stabile, l’implementazione attuale non richiederà più Rust nightly.
  2. Aggiunta di nuove metriche per ordinare le frasi, in particolare quelle che catturano le dipendenze tra frasi. TFIDF non è la funzione di punteggio più accurata e ha le sue limitazioni. La creazione di grafi di frasi e l’utilizzo di essi per valutare le frasi hanno notevolmente migliorato la qualità complessiva del riassunto estratto.
  3. Il summarizer non è stato testato su un dataset noto. I punteggi Rouge R1 , R2 e RL vengono spesso utilizzati per valutare la qualità del riassunto generato rispetto a dataset standard come il dataset del New York Times o il dataset di CNN Daily mail. La misurazione delle prestazioni rispetto ai benchmark standard fornirà agli sviluppatori una maggiore chiarezza e affidabilità nell’implementazione.

Conclusion

Costruire utility NLP con Rust ha significativi vantaggi, considerando la crescente popolarità del linguaggio tra gli sviluppatori grazie alle sue prestazioni e alle promesse future. Spero che l’articolo sia stato informativo. Dai un’occhiata al progetto su GitHub:

GitHub – shubham0204/tfidf-summarizer.rs: Semplice, efficiente e cross-platform text summarizer basato su TFIDF in Rust…

Semplice, efficiente e cross-platform text summarizer basato su TFIDF in Rust – GitHub – shubham0204/tfidf-summarizer.rs…

github.com

Potresti valutare l’apertura di una segnalazione o un pull request se ritieni che qualcosa possa essere migliorato! Continua ad imparare e buona giornata.