PNL multiplataforma en Rust
Optimización con Rayon y soporte para C/C++, Android, Python
Las herramientas y utilidades de PNL han crecido en gran medida en el ecosistema Python, lo que permite a los desarrolladores de todos los niveles crear aplicaciones de lenguaje de alta calidad a escala. Rust es una introducción más nueva a la PNL, con organizaciones como AbrazosCara adoptándolo para crear paquetes para el aprendizaje automático.
Hugging Face ha escrito un nuevo marco de aprendizaje automático en Rust, ¡ahora de código abierto!
En este blog, exploraremos cómo podemos crear un resumen de texto utilizando el concepto de TFIDF. Primero, tendremos una intuición sobre cómo funciona el resumen TFIDF y por qué Rust podría ser un buen lenguaje para implementar canalizaciones de PNL y cómo podemos usar nuestro código Rust en otras plataformas como C/C++, Android y Python. Además, discutimos cómo podemos optimizar la tarea de resumen con computación paralela con Seda artificial.
Aquí está el proyecto GitHub:
Empecemos ➡️
Contenido
- Motivación
- Resumen de texto extractivo y abstractivo
- Comprender el resumen de texto con TFIDF
- Implementación de óxido
- Uso con C
- Alcance futuro
- Conclusión
Motivación
Creé un resumidor de texto usando la misma técnica, allá por 2019, con Kotlin y llamé Texto2Resumen. Fue diseñado principalmente para aplicaciones de Android, como un proyecto paralelo y utilizó Kotlin para todos los cálculos. Avance rápido hasta 2023, ahora estoy trabajando con bases de código C, C++ y Rust y he usado módulos integrados en estos nativo Idiomas en Android y Python.
Elegí volver a implementar Text2Summary en Rust, ya que serviría como una gran experiencia de aprendizaje y también como un resumen de texto pequeño, eficiente y práctico que puede manejar textos grandes fácilmente. Rust es un lenguaje compilado con verificadores de referencias y préstamos inteligentes que ayuda a los desarrolladores a escribir código sin errores. El código escrito en Rust se puede integrar con bases de código Java a través de jni y convertir a encabezados/bibliotecas de C para su uso en C/C++ y Python.
Resumen de texto extractivo y abstractivo
El resumen de texto ha sido un problema estudiado durante mucho tiempo en el procesamiento del lenguaje natural (PNL). Extraer información importante del texto y generar un resumen del texto dado es el problema central que los resumidores de texto deben resolver. Las soluciones pertenecen a dos categorías, a saber, resumen extractivo y resumen abstractivo.
Comprensión del resumen automático de texto-1: métodos extractivos
En el resumen de texto extractivo, las frases u oraciones se derivan directamente de la oración. Podemos clasificar oraciones usando una función de puntuación y elegir las oraciones más adecuadas del texto considerando sus puntuaciones. En lugar de generar texto nuevo, como en el resumen abstractivo, el resumen es una colección de oraciones seleccionadas del texto, evitando así los problemas que presentan los modelos generativos.
- La precisión del texto se mantiene en el resumen extractivo, pero existe una alta probabilidad de que se pierda parte de la información ya que la granularidad del texto seleccionado solo se limita a oraciones. Si una información se distribuye en varias oraciones, la función de puntuación debe ocuparse de la relación que contiene esas oraciones.
- El resumen de texto abstracto requiere un modelo de aprendizaje profundo más amplio para capturar la semántica del lenguaje y construir un mapeo apropiado entre documento y resumen. Entrenar estos modelos requiere enormes conjuntos de datos y un tiempo de entrenamiento más largo, lo que a su vez sobrecarga enormemente los recursos informáticos. Los modelos previamente entrenados podrían resolver el problema de los tiempos de entrenamiento más prolongados y las demandas de datos, pero todavía están inherentemente sesgados hacia el dominio del texto en el que se entrenaron.
- Los métodos extractivos pueden tener funciones de puntuación que están libres de parámetros y no requieren ningún aprendizaje. Se incluyen en el régimen de aprendizaje no supervisado de ML y son útiles porque requieren menos cálculo y no están sesgados hacia el dominio del texto. El resumen puede ser igualmente eficaz tanto en artículos de noticias como en extractos novedosos.
Con nuestra técnica basada en TFIDF, no requerimos ningún conjunto de datos de entrenamiento ni modelos de aprendizaje profundo. Nuestra función de puntuación se basa en las frecuencias relativas de palabras en diferentes oraciones.
Comprender el resumen de texto con TFIDF
Para clasificar cada oración, necesitamos calcular una puntuación que cuantifique la cantidad de información presente en la oración. TF-IDF se compone de dos términos: TF, que significa Frecuencia del término y IDF que denota Frecuencia de documento inversa.
TF (Frecuencia de términos) -IDF (Frecuencia de documentos inversa) desde cero en Python.
Consideramos que cada oración está hecha de tokens (palabras),
La frecuencia de términos de cada palabra, en la oración. SSe define como,
La frecuencia del documento inverso de cada palabra, en la oración S, se define como,
La puntuación de cada oración es la suma de las puntuaciones TFIDF de todas las palabras de esa oración,
Importancia e intuición
El término frecuencia, como habrás observado, sería menor para las palabras que son más raras en la oración. Si la misma palabra tiene menos presencia en otras oraciones, entonces la puntuación IDF también es mayor. Por lo tanto, una oración que contiene palabras repetidas (TF más alto) que son más exclusivas solo de esa oración (IDF más alto) tendrá una puntuación TFIDF más alta.
Implementación de óxido
Comenzamos a implementar nuestra técnica creando funciones que convierten un texto determinado en un conjunto de oraciones. Este problema se conoce como tokenización de oraciones, que identifica los límites de las oraciones dentro de un texto. Con paquetes de Python como nltk, el tokenizador de oraciones punkt está disponible para esta tarea y existe un puerto Rust de punto también. punto de óxido Ya no se mantiene, pero todavía lo usamos aquí. También se escribe otra función que divide la oración en palabras,
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
}
En el fragmento anterior, eliminamos las palabras vacías, que son palabras que aparecen comúnmente en un idioma y no tienen una contribución significativa al contenido informativo del texto.
Preprocesamiento de texto: detenga la eliminación de palabras usando diferentes bibliotecas
A continuación, creamos una función que calcula la frecuencia de cada palabra presente en el corpus. Este método se utilizará para calcular la frecuencia de los términos de cada palabra presente en una oración. El par (palabra, frecuencia) se almacena en un mapa hash para una recuperación más rápida en etapas posteriores
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
}
A continuación, escribimos la función que calcula el término frecuencia de las palabras presentes en una oración,
// 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
}
Otra función que calcula el IDF, la frecuencia inversa del documento, para palabras en una oración tokenizada,
// 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
}
Ahora hemos agregado funciones para calcular las puntuaciones TF e IDF de cada palabra presente en una oración. Para calcular una puntuación final para cada oración, que también determinaría su clasificación, tenemos que calcular la suma de las puntuaciones TFIDF de todas las palabras presentes en una oración.
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( " " )
}
Usando rayón
Para textos más grandes, podemos realizar algunas operaciones en paralelo, es decir, en múltiples subprocesos de CPU utilizando una caja Rust popular. rayón-rs . En la función de cálculo anterior, podemos realizar las siguientes tareas en paralelo,
- Convertir cada oración en tokens y eliminar palabras vacías
- Calcular la suma de las puntuaciones TFIDF para cada oración
Estas tareas se pueden realizar de forma independiente en cada oración y no dependen de otras oraciones, por lo tanto, se pueden paralelizar. Para garantizar la exclusión mutua mientras diferentes subprocesos acceden a un contenedor compartido, utilizamos Arco (puntero contado de referencia atómica) y exclusión mutua que es una primitiva de sincronización básica para garantizar el acceso atómico.
Arc garantiza que el Mutex referido sea accesible para todos los subprocesos, y el Mutex en sí permite que solo un subproceso acceda al objeto envuelto en él. Aquí hay otra función par_compute, que usa Rayon y realiza las tareas mencionadas anteriormente en paralelo,
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( ". " )
}
Uso multiplataforma
C y C++
Para usar estructuras y funciones de Rust en C, podemos usar cbindgen para generar encabezados estilo C que contengan los prototipos de estructuras/funciones. Al generar los encabezados, podemos compilar el código Rust en formato C. bibliotecas dinámicas o estáticas que contienen la implementación de las funciones declaradas en los archivos de encabezado. Para generar una biblioteca estática basada en C, necesitamos configurar el tipo_caja parámetro en Cargo.toml a staticlib,
[lib]
name = "summarizer"
crate_type = [ "staticlib" ]
A continuación, agregamos FFI para exponer las funciones del resumidor en la ABI (interfaz binaria de la aplicación) en 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 {
...
}
}
Podemos construir la biblioteca estática con cargo build y se generará libsummarizer.a en el directorio de destino.
Androide
Con Kit de desarrollo nativo de Android (NDK), podemos compilar el programa Rust para objetivos armeabi-v7a y arm64-v8a. Necesitamos escribir funciones de interfaz especiales con Java Native Interface (JNI), que se puede encontrar en el módulo de Android en src/lib.rs.
Pitón
Con el módulo ctypes de Python, podemos cargar una biblioteca compartida ( .so o .dll ) y usar los tipos de datos compatibles con C para ejecutar las funciones definidas en la biblioteca. El código no está disponible en el proyecto GitHub, pero lo estará pronto.
Enlaces de Python: llamar a C o C++ desde Python – Real Python
Alcance futuro
El proyecto se puede ampliar y mejorar de muchas maneras, que analizaremos a continuación:
- La implementación actual requiere la construcción nocturna de Rust, solo debido a un único punto de dependencia. punkt es un tokenizador de oraciones que se requiere para determinar los límites de las oraciones en el texto, tras lo cual se realizan otros cálculos. Si punkt se puede construir con Rust estable, la implementación actual ya no requerirá Rust nocturno.
- Agregar métricas más nuevas para clasificar oraciones, especialmente las que capturan dependencias entre oraciones. TFIDF no es la función de puntuación más precisa y tiene sus propias limitaciones. Crear gráficos de oraciones y usarlos para calificar oraciones ha mejorado enormemente la calidad general del resumen extraído.
- El resumidor no se ha comparado con un conjunto de datos conocido. puntuaciones rojas R1, R2 y rl se utilizan con frecuencia para evaluar la calidad del resumen generado frente a conjuntos de datos estándar como el Conjunto de datos del New York Times o el Conjunto de datos de correo diario de CNN. Medir el rendimiento frente a puntos de referencia estándar proporcionará a los desarrolladores más claridad y confiabilidad en la implementación.
Conclusión
Crear utilidades de PNL con Rust tiene ventajas significativas, considerando la creciente popularidad del lenguaje entre los desarrolladores debido a su rendimiento y sus promesas futuras. Espero que el artículo haya sido bien informado. Eche un vistazo al proyecto GitHub:
¡Puede considerar abrir un problema o una solicitud de extracción si cree que algo se puede mejorar! Sigue aprendiendo y que tengas un buen día por delante.
Creación de un resumen de texto TFIDF multiplataforma en Rust fue publicado originalmente en Hacia la ciencia de datos en Medium, donde las personas continúan la conversación resaltando y respondiendo a esta historia.