PNL multipiattaforma in Rust
Gli strumenti e le utilità della PNL sono cresciuti in gran parte nell’ecosistema Python, consentendo agli sviluppatori di tutti i livelli di creare app linguistiche di alta qualità su larga scala. Rust è una nuova introduzione alla PNL, con organizzazioni simili Abbracciare il viso adottandolo per creare pacchetti per l’apprendimento automatico.
In questo blog esploreremo come possiamo costruire un riassunto di testo utilizzando il concetto di TFIDF. Per prima cosa avremo un’intuizione su come funziona il riepilogo TFIDF, sul perché Rust potrebbe essere un buon linguaggio per implementare pipeline NLP e su come possiamo utilizzare il nostro codice Rust su altre piattaforme come C/C++, Android e Python. Inoltre, discutiamo di come possiamo ottimizzare l’attività di riepilogo con il calcolo parallelo Rayon.
Ecco il progetto GitHub:
Cominciamo ➡️
- Motivazione
- Riepilogo del testo estrattivo e astrattivo
- Comprendere il riepilogo del testo con TFIDF
- Implementazione di Rust
- Utilizzo con C
- Ambito futuro
- Conclusione
Avevo creato un riassunto del testo utilizzando la stessa tecnica, nel 2019, con Kotlin e ho chiamato Testo2Riepilogo. È stato progettato principalmente per le app Android, come progetto parallelo e utilizzava Kotlin per tutti i calcoli. Avanzando rapidamente al 2023, ora sto lavorando con basi di codice C, C++ e Rust e ho utilizzato moduli integrati in questi nativo linguaggi in Android e Python.
Ho scelto di reimplementare Text2Summary
in Rust, poiché sarebbe un’ottima esperienza di apprendimento e anche come un riepilogo di testo piccolo, efficiente e pratico in grado di gestire facilmente testi di grandi dimensioni. Rust è un linguaggio compilato con prestiti intelligenti e controlli di riferimento che aiuta gli sviluppatori a scrivere codice privo di bug. Il codice scritto in Rust può essere integrato con le basi di codice Java tramite jni
e convertito in intestazioni/librerie C per l’uso in C/C++ e Python.
Il riepilogo del testo è stato un problema a lungo studiato nell’elaborazione del linguaggio naturale (NLP). Estrarre informazioni importanti dal testo e generare un riassunto del testo fornito è il problema principale che i riepilogatori di testo devono risolvere. Le soluzioni appartengono a due categorie, vale a dire riepilogo estrattivo e riepilogo astrattivo.
Nel riepilogo estrattivo del testo, le frasi o le frasi derivano direttamente dalla frase. Possiamo classificare le frasi utilizzando una funzione di punteggio e selezionare le frasi più adatte dal testo considerando i loro punteggi. Invece di generare nuovo testo, come nel riassunto astrattivo, il riassunto è una raccolta di frasi selezionate dal testo, evitando così i problemi presentati dai modelli generativi.
- La precisione del testo viene mantenuta nel riepilogo estrattivo, ma esiste un’alta probabilità che alcune informazioni vadano perse poiché la granularità del testo selezionato è limitata solo alle frasi. Se un’informazione è distribuita su più frasi, la funzione di punteggio deve occuparsi della relazione che contiene quelle frasi.
- Il riepilogo astrattivo del testo richiede un modello di deep learning più ampio per acquisire la semantica del linguaggio e costruire un’appropriata mappatura documento-riepilogo. L’addestramento di tali modelli richiede enormi quantità di dati e tempi di addestramento più lunghi, che a loro volta sovraccaricano pesantemente le risorse di calcolo. I modelli preaddestrati potrebbero risolvere il problema dei tempi di addestramento e delle richieste di dati più lunghi, ma sono ancora intrinsecamente sbilanciati verso il dominio del testo su cui si sono formati.
- I metodi estrattivi possono avere funzioni di punteggio prive di parametri e che non richiedono alcun apprendimento. Rientrano nel regime di apprendimento non supervisionato del ML e sono utili poiché richiedono meno calcoli e non sono influenzati dal dominio del testo. Il riepilogo può essere altrettanto efficace sia per gli articoli di notizie che per i brani di romanzi.
Con la nostra tecnica basata su TFIDF, non richiediamo alcun set di dati di addestramento o modelli di deep learning. La nostra funzione di punteggio si basa sulle frequenze relative delle parole nelle diverse frasi.
Per classificare ciascuna frase, dobbiamo calcolare un punteggio che quantificherebbe la quantità di informazioni presenti all’interno della frase. TF-IDF è composto da due termini: TF, che sta per Frequenza dei termini e IDF che denota Frequenza inversa del documento.
Consideriamo che ogni frase sia fatta di token (parole),
La frequenza del termine di ogni parola, nella frase Sè definito come,
La frequenza del documento inverso di ciascuna parola, nella frase S, è definita come,
Il punteggio di ciascuna frase è la somma dei punteggi TFIDF di tutte le parole in quella frase,
Significato e intuizione
Il termine frequenza, come avrai osservato, sarebbe minore per le parole che sono più rare nella frase. Se la stessa parola è meno presente in altre frasi, anche il punteggio IDF è più alto. Pertanto, una frase che contiene parole ripetute (TF più alto) che sono più esclusive solo di quella frase (IDF più alto) avrà un punteggio TFIDF più alto.
Iniziamo ad implementare la nostra tecnica creando funzioni che convertono un dato testo in un file Vec
di frasi. Questo problema viene definito tokenizzazione della frase che identifica i confini della frase all’interno di un testo. Con pacchetti Python come nltk
IL punkt
per questa attività è disponibile il tokenizzatore di frasi ed esiste un port su Rust di Punto anche. rust-punkt
non viene più mantenuto, ma lo usiamo ancora qui. Viene scritta anche un’altra funzione che divide la frase in parole,
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" ) ;
/// Transform a `text` into a list of sentences
/// It uses the popular Punkt sentence tokenizer from a Rust port:
/// <`/`>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
}
/// Transforms the sentence into a list of words (tokens)
/// eliminating stopwords while doing so
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
}
Nello snippet sopra, rimuoviamo le stop-word, che sono parole comunemente presenti in una lingua e non hanno alcun contributo significativo al contenuto informativo del testo.
Successivamente, creiamo una funzione che calcola la frequenza di ogni parola presente nel corpus. Questo metodo verrà utilizzato per calcolare la frequenza dei termini di ciascuna parola presente in una frase. IL (word, freq)
la coppia è memorizzata in a Hashmap
per un recupero più rapido nelle fasi successive
use std::collections::HashMap;/// Given a list of words, build a frequency map
/// where keys are words and values are the frequencies of those words
/// This method will be used to compute the term frequencies of each word
/// present in a sentence
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
}
Successivamente, scriviamo la funzione che calcola la frequenza dei termini delle parole presenti in una frase,
// Compute the term frequency of tokens present in the given sentence (tokenized)
// Term frequency TF of token 'w' is expressed as,
// TF(w) = (frequency of w in the sentence) / (total number of tokens in the sentence)
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
}
Un’altra funzione che calcola l’IDF, la frequenza inversa del documento, per le parole in una frase tokenizzata,
// Compute the inverse document frequency of tokens present in the given sentence (tokenized)
// Inverse document frequency IDF of token 'w' is expressed as,
// IDF(w) = log( N / (Number of documents in which w appears) )
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
}
Ora abbiamo aggiunto funzioni per calcolare i punteggi TF e IDF di ogni parola presente in una frase. Per calcolare un punteggio finale per ogni frase, che ne determinerebbe anche il rango, dobbiamo calcolare la somma dei punteggi TFIDF di tutte le parole presenti in una frase.
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 ;
// Compute TFIDF score for each word
// and add it to tfidf_sum
for word in tokenized_sentence {
tfidf_sum += tf.get( word ).unwrap() * idf.get( word ).unwrap() ;
}
sentence_scores.insert( sentences(i) , tfidf_sum ) ;
}
// Sort sentences by their scores
sentences.sort_by( | a , b |
sentence_scores.get(b).unwrap().total_cmp(sentence_scores.get(a).unwrap()) ) ;
// Compute number of sentences to be included in the summary
// and return the extracted summary
let num_summary_sents = (reduction_factor * (sentences.len() as f32) ) as usize;
sentences( 0..num_summary_sents ).join( " " )
}
Utilizzando Rayon
Per testi più grandi, possiamo eseguire alcune operazioni in parallelo, ovvero su più thread della CPU, utilizzando un popolare crate di Rust rayon-rs
. Nel compute
funzione di cui sopra, possiamo eseguire le seguenti attività in parallelo,
- Convertire ogni frase in token e rimuovere le stop-word
- Calcolo della somma dei punteggi TFIDF per ciascuna frase
Questi compiti possono essere eseguiti indipendentemente su ciascuna frase e non hanno dipendenza da altre frasi, quindi possono essere parallelizzati. Per garantire la mutua esclusione mentre diversi thread accedono a un contenitore condiviso, utilizziamo Arc
(Puntatore conteggiato di riferimento atomico) E Mutex
che è la primitiva di sincronizzazione di base per garantire l’accesso atomico.
Arc
garantisce che quanto riferito Mutex
è accessibile a tutti i thread e il file Mutex
stesso consente solo a un singolo thread di accedere all’oggetto in esso racchiuso. Ecco un’altra funzione par_compute
che utilizza Rayon ed esegue i compiti sopra menzionati in parallelo,
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() ; // Tokenize sentences in parallel with Rayon
// Declare a thread-safe Vec<Vec<&str>> to hold the tokenized sentences
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() ;
// Compute scores for sentences in parallel
// Declare a thread-safe Hashmap<&str,f32> to hold the sentence scores
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() ;
// Sort sentences by their scores
sentences.sort_by( | a , b |
sentence_scores.get(b).unwrap().total_cmp(sentence_scores.get(a).unwrap()) ) ;
// Compute number of sentences to be included in the summary
// and return the extracted summary
let num_summary_sents = (reduction_factor * (sentences.len() as f32) ) as usize;
sentences( 0..num_summary_sents ).join( ". " )
}
C e C++
Per utilizzare le strutture e le funzioni di Rust in C, possiamo usare cbindgen
per generare intestazioni in stile C contenenti i prototipi di struttura/funzione. Durante la generazione delle intestazioni, possiamo compilare il codice Rust su base C librerie dinamiche o statiche che contengono l’implementazione delle funzioni dichiarate nei file header. Per generare una libreria statica basata su C, dobbiamo impostare il file crate_type
parametro dentro Cargo.toml
A staticlib
,
(lib)
name = "summarizer"
crate_type = ( "staticlib" )
Successivamente, aggiungiamo FFI per esporre le funzioni del riepilogo nell’ABI (interfaccia binaria dell’applicazione) In src/lib.rs
,
/// functions exposing Rust methods as C interfaces
/// These methods are accessible with the ABI (compiled object code)
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 costruire la libreria statica con cargo build
E libsummarizer.a
verrà generato nel file target
directory.
Androide
Con Kit di sviluppo nativo di Android (NDK)possiamo compilare il programma Rust per armeabi-v7a
E arm64-v8a
obiettivi. Dobbiamo scrivere funzioni di interfaccia speciali con Java Native Interface (JNI), che possono essere trovate nel file android
modulo dentro src/lib.rs
.
Pitone
Con Python ctypes
modulo, 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 GitHub, ma lo sarà presto.
Il progetto può essere esteso e migliorato in molti modi, di cui parleremo di seguito:
- L’attuale implementazione richiede il
nightly
build di Rustsolo a causa di una singola dipendenzapunkt
.punkt
è un tokenizzatore di frasi necessario per determinare i confini della frase nel testo, dopo di che vengono effettuati altri calcoli. Sepunkt
può essere costruito con Rust stabile, l’attuale implementazione non lo richiederà piùnightly
Ruggine. - Aggiunta di metriche più recenti per classificare le frasi, in particolare quelle che catturano le dipendenze tra le frasi. TFIDF non è la funzione di punteggio più accurata e presenta i suoi limiti. Costruire grafici delle frasi e utilizzarli per assegnare punteggi alle frasi ha migliorato notevolmente la qualità complessiva del riassunto estratto.
- Il riepilogo non è stato confrontato con un set di dati noto. Rouge segna
R1
,R2
ERL
sono spesso utilizzati per valutare la qualità del riepilogo generato rispetto a set di dati standard come il Set di dati del New York Times o il Set di dati della posta giornaliera della CNN. Misurare le prestazioni rispetto ai benchmark standard fornirà agli sviluppatori maggiore chiarezza e affidabilità nei confronti dell’implementazione.
Costruire utilità PNL con Rust presenta vantaggi significativi, considerando la crescente popolarità del linguaggio tra gli sviluppatori grazie alle sue prestazioni e alle promesse future. Spero che l’articolo fosse informato. Dai un’occhiata al progetto GitHub:
Potresti prendere in considerazione l’apertura di un problema o di una richiesta pull se ritieni che qualcosa possa essere migliorato! Continua a imparare e buona giornata.
Fonte: towardsdatascience.com