Capítulo 4. Reconocimiento multilingüe de entidades con nombre

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Hasta ahora en este libro hemos aplicado transformadores para resolver tareas de PLN en corpus ingleses, pero ¿qué haces cuando tus documentos están escritos en griego, swahili o klingon? Un enfoque consiste en buscar en el Hugging Face Hub un modelo lingüístico preentrenado adecuado y ajustarlo a la tarea en cuestión. Sin embargo, estos modelos preentrenados suelen existir sólo para lenguas de "altos recursos" como el alemán, el ruso o el mandarín, en las que se dispone de mucho texto web para el preentrenamiento. Otro reto común surge cuando tu corpus es multilingüe: mantener múltiples modelos monolingües en producción no será nada divertido ni para ti ni para tu equipo de ingenieros.

Afortunadamente, existe una clase de transformadores multilingües que vienen al rescate. Al igual que BERT, estos modelos utilizan el modelado lingüístico enmascarado como objetivo del preentrenamiento, pero se entrenan conjuntamente en textos de más de cien lenguas. Al realizar el preentrenamiento en corpus enormes de muchas lenguas, estos transformadores multilingües permiten la transferencia multilingüe sin necesidad de entrenamiento adicional, lo que significa que un modelo perfeccionado en una lengua puede aplicarse a otras sin necesidad de entrenamiento adicional. Esto también hace que estos modelos sean muy adecuados para el "cambio de código", en el que un hablante alterna entre dos o más lenguas o dialectos en el contexto de una misma conversación.

En este capítulo exploraremos cómo un único modelo transformador llamado XLM-RoBERTa (introducido en el Capítulo 3)1 para realizar el reconocimiento de entidades con nombre (NER) en varios idiomas. Como vimos en el Capítulo 1, el NER es una tarea habitual de la PLN que identifica entidades como personas, organizaciones o lugares en un texto. Estas entidades pueden utilizarse para diversas aplicaciones, como obtener información de los documentos de una empresa, aumentar la calidad de los motores de búsqueda o, simplemente, crear una base de datos estructurada a partir de un corpus.

Para este capítulo vamos a suponer que queremos realizar una NER para un cliente con sede en Suiza, donde hay cuatro lenguas nacionales (con el inglés sirviendo a menudo de puente entre ellas). Empecemos por conseguir un corpus multilingüe adecuado para este problema.

Nota

Latransferencia a cero o aprendizaje a cero suele referirse a la tarea de entrenar un modelo en un conjunto de etiquetas y luego evaluarlo en un conjunto diferente de etiquetas. En el contexto de los transformadores, el aprendizaje de tiro cero también puede referirse a situaciones en las que un modelo lingüístico como el GPT-3 se evalúa en una tarea posterior en la que ni siquiera se ha afinado.

El conjunto de datos

En este capítulo utilizaremos un subconjunto de la prueba de referencia Cross-lingual TRansfer Evaluation of Multilingual Encoders (XTREME) llamado WikiANN o PAN-X.2 Este conjunto de datos consta de artículos de Wikipedia en muchos idiomas, incluidos los cuatro idiomas más hablados en Suiza: Alemán (62,9%), francés (22,9%), italiano (8,4%) e inglés (5,9%). Cada artículo está anotado con las etiquetas LOC (lugar), PER (persona) y ORG (organización) en elformato "dentro-fuera-del-comienzo" (IOB2). En este formato, un prefijo B- indica el comienzo de una entidad, y los tokens consecutivos que pertenecen a la misma entidad reciben un prefijo I-. Una etiqueta O indica que el token no pertenece a ninguna entidad. Por ejemplo, la siguiente frase:

Jeff Dean es informático de Google en California

se etiquetarían en formato IOB2, como se muestra en la Tabla 4-1.

Tabla 4-1. Ejemplo de secuencia anotada con entidades con nombre
Fichas Jeff Decano es a ordenador científico en Google en California
Etiquetas B-PER I-PER O O O O O B-ORG O B-LOC

Para cargar uno de los subconjuntos de PAN-X en XTREME, necesitaremos saber qué configuración de conjunto de datos pasar a la función load_dataset(). Siempre que trabajes con un conjunto de datos que tenga varios dominios, puedes utilizar la función get_dataset_config_names() para averiguar qué subconjuntos están disponibles:

from datasets import get_dataset_config_names

xtreme_subsets = get_dataset_config_names("xtreme")
print(f"XTREME has {len(xtreme_subsets)} configurations")
XTREME has 183 configurations

Vaya, ¡son muchas configuraciones! Reduzcamos la búsqueda buscando sólo las configuraciones que empiecen por "PAN":

panx_subsets = [s for s in xtreme_subsets if s.startswith("PAN")]
panx_subsets[:3]
['PAN-X.af', 'PAN-X.ar', 'PAN-X.bg']

Vale, parece que hemos identificado la sintaxis de los subconjuntos PAN-X: cada uno tiene un sufijo de dos letras que parece ser uncódigo de idioma ISO 639-1. Esto significa que para cargar el corpus alemán, pasamos el código de al argumento name de load_dataset() de la siguiente manera:

from datasets import load_dataset

load_dataset("xtreme", name="PAN-X.de")

Para crear un corpus suizo realista, tomaremos muestras de los corpus alemán (de), francés (fr), italiano (it) e inglés (en) de PAN-X según sus proporciones habladas. Esto creará un desequilibrio lingüístico que es muy común en los conjuntos de datos del mundo real, donde la adquisición de ejemplos etiquetados en una lengua minoritaria puede ser costosa debido a la falta de expertos en el dominio que dominen esa lengua. Este conjunto de datos desequilibrado simulará una situación habitual cuando se trabaja en aplicaciones multilingües, y veremos cómo podemos construir un modelo que funcione en todas las lenguas.

Para hacer un seguimiento de cada lengua, vamos a crear undefaultdict en Python que almacene el código de la lengua como clave y un corpus PAN-X de tipo DatasetDict como valor:

from collections import defaultdict
from datasets import DatasetDict

langs = ["de", "fr", "it", "en"]
fracs = [0.629, 0.229, 0.084, 0.059]
# Return a DatasetDict if a key doesn't exist
panx_ch = defaultdict(DatasetDict)

for lang, frac in zip(langs, fracs):
    # Load monolingual corpus
    ds = load_dataset("xtreme", name=f"PAN-X.{lang}")
    # Shuffle and downsample each split according to spoken proportion
    for split in ds:
        panx_ch[lang][split] = (
            ds[split]
            .shuffle(seed=0)
            .select(range(int(frac * ds[split].num_rows))))

Aquí hemos utilizado el método shuffle() para asegurarnos de que no sesgamos accidentalmente nuestras divisiones de conjuntos de datos, mientras que select()nos permite reducir la muestra de cada corpus según los valores de fracs. Echemos un vistazo a cuántos ejemplos tenemos por idioma en los conjuntos de entrenamiento accediendo al atributo Dataset.num_rows:

import pandas as pd

pd.DataFrame({lang: [panx_ch[lang]["train"].num_rows] for lang in langs},
             index=["Number of training examples"])
de fr it en
Número de ejemplos de entrenamiento 12580 4580 1680 1180

Por diseño, tenemos más ejemplos en alemán que en todas las demás lenguas juntas, así que lo utilizaremos como punto de partida para realizar una transferencia cruzada de cero al francés, italiano e inglés. Veamos uno de los ejemplos delcorpus alemán:

element = panx_ch["de"]["train"][0]
for key, value in element.items():
    print(f"{key}: {value}")
langs: ['de', 'de', 'de', 'de', 'de', 'de', 'de', 'de', 'de', 'de', 'de', 'de']
ner_tags: [0, 0, 0, 0, 5, 6, 0, 0, 5, 5, 6, 0]
tokens: ['2.000', 'Einwohnern', 'an', 'der', 'Danziger', 'Bucht', 'in', 'der',
'polnischen', 'Woiwodschaft', 'Pommern', '.']

Como en nuestros encuentros anteriores con los objetos Dataset, las claves de nuestro ejemplo corresponden a los nombres de las columnas de una tabla Flecha, mientras que los valores denotan las entradas de cada columna. En concreto, vemos que la columnaner_tags corresponde a la correspondencia de cada entidad con un ID de clase. Esto es un poco críptico para el ojo humano, así que vamos a crear una nueva columna con las conocidas etiquetas LOC, PER y ORG. Para ello, lo primero que hay que observar es que nuestro objeto Dataset tiene un atributo featuresque especifica los tipos de datos subyacentes asociados a cada columna:

for key, value in panx_ch["de"]["train"].features.items():
    print(f"{key}: {value}")
tokens: Sequence(feature=Value(dtype='string', id=None), length=-1, id=None)
ner_tags: Sequence(feature=ClassLabel(num_classes=7, names=['O', 'B-PER',
'I-PER', 'B-ORG', 'I-ORG', 'B-LOC', 'I-LOC'], names_file=None, id=None),
length=-1, id=None)
langs: Sequence(feature=Value(dtype='string', id=None), length=-1, id=None)

La clase Sequence especifica que el campo contiene una lista de características, que en el caso de ner_tags corresponde a una lista de característicasClassLabel. Seleccionemos esta característica del conjunto de entrenamiento de la siguiente manera:

tags = panx_ch["de"]["train"].features["ner_tags"].feature
print(tags)
ClassLabel(num_classes=7, names=['O', 'B-PER', 'I-PER', 'B-ORG', 'I-ORG',
'B-LOC', 'I-LOC'], names_file=None, id=None)

Podemos utilizar el método ClassLabel.int2str() que encontramos en elCapítulo 2 para crear una nueva columna en nuestro conjunto de entrenamiento con nombres de clase para cada etiqueta. Utilizaremos el método map()para devolver un dict con la clave correspondiente al nombre de la nueva columna y el valor como un list de nombres de clases:

def create_tag_names(batch):
    return {"ner_tags_str": [tags.int2str(idx) for idx in batch["ner_tags"]]}

panx_de = panx_ch["de"].map(create_tag_names)

Ahora que tenemos nuestras etiquetas en formato legible por humanos, veamos cómo se alinean los tokens y las etiquetas del primer ejemplo del conjunto de entrenamiento:

de_example = panx_de["train"][0]
pd.DataFrame([de_example["tokens"], de_example["ner_tags_str"]],
['Tokens', 'Tags'])
0 1 2 3 4 5 6 7 8 9 10 11
Fichas 2.000 Einwohnern un der Danziger Bucht en der polnischen Woiwodschaft Pomerania .
Etiquetas O O O O B-LOC I-LOC O O B-LOC B-LOC I-LOC O

La presencia de las etiquetas LOC tiene sentido, ya que la frase "2.000 Einwohnern an der Danziger Bucht in der polnischen Woiwodschaft Pommern" significa "2.000 habitantes en la bahía de Gdansk, en la voivodía polaca de Pomerania" en español, y la bahía de Gdansk es una bahía del mar Báltico, mientras que "voivodía" corresponde a un estado de Polonia.

Como comprobación rápida de que no tenemos ningún desequilibrio inusual en las etiquetas, calculemos las frecuencias de cada entidad en cada división:

from collections import Counter

split2freqs = defaultdict(Counter)
for split, dataset in panx_de.items():
    for row in dataset["ner_tags_str"]:
        for tag in row:
            if tag.startswith("B"):
                tag_type = tag.split("-")[1]
                split2freqs[split][tag_type] += 1
pd.DataFrame.from_dict(split2freqs, orient="index")
ORG LOC POR
validación 2683 3172 2893
prueba 2573 3180 3071
tren 5366 6186 5810

Esto tiene buena pinta: las distribuciones de las frecuencias de PER, LOC y ORGson más o menos las mismas para cada división, por lo que los conjuntos de validación y prueba deberían proporcionar una buena medida de la capacidad de generalización de nuestro etiquetador NER. A continuación, veamos algunos transformadores multilingües populares y cómo pueden adaptarse para abordar nuestra tarea NER.

Transformadores multilingües

Los transformadores multilingües implican arquitecturas y procedimientos de entrenamiento similares a los de sus homólogos monolingües, con la salvedad de que el corpus utilizado para el preentrenamiento consta de documentos en muchas lenguas. Una característica destacable de este enfoque es que, a pesar de no recibir información explícita para diferenciar entre las lenguas, las representaciones lingüísticas resultantes son capaces de generalizar bien entrelenguas para una variedad de tareas posteriores. En algunos casos, esta capacidad de realizar transferencias entre lenguas puede producir resultados competitivos con los de los modelos monolingües, ¡lo que evita la necesidad de entrenar un modelo por lengua!

Para medir el progreso de la transferencia multilingüe para la NER, se suelen utilizar los conjuntos de datosCoNLL-2002 yCoNLL-2003 como referencia para el inglés, el holandés, el español y el alemán. Este punto de referencia consiste en artículos de noticias anotados con las mismas categorías LOC,PER y ORG que PAN-X, pero contiene una etiqueta adicionalMISC para entidades diversas que no pertenecen a los tres grupos anteriores. Los modelos de transformadores multilingües suelen evaluarse de tres formas distintas:

en

Ajústalos con los datos de entrenamiento en inglés y luego evalúalos con el conjunto de pruebas de cada idioma.

each

Afina y evalúa en datos de prueba monolingües para medirel rendimiento por idioma.

all

Ajústalos en todos los datos de entrenamiento para evaluarlos en todos en el conjunto de pruebas de cada idioma.

Adoptaremos una estrategia de evaluación similar para nuestra tarea NER, pero primero tenemos que seleccionar un modelo para evaluar. Uno de los primeros transformadores multilingües fue mBERT, que utiliza la misma arquitectura y objetivo de preentrenamiento que BERT, pero añade artículos de Wikipedia de muchas lenguas al corpus de preentrenamiento. Desde entonces, mBERT ha sido sustituido por XLM-RoBERTa (o XLM-R para abreviar), así que ése es el modelo que consideraremos en este capítulo.

Como vimos en el Capítulo 3, XLM-R sólo utiliza MLM como objetivo de preentrenamiento para 100 lenguas, pero se distingue por el enorme tamaño de su corpus de preentrenamiento en comparación con sus predecesores: volcados de Wikipedia para cada lengua y 2,5 terabytes de datos Common Crawl de la web. Este corpus es varios órdenes de magnitud mayor que los utilizados en modelos anteriores y proporciona un aumento significativo de la señal para las lenguas con pocos recursos, como el birmano y el suajili, donde sólo existe un pequeño número de artículos de Wikipedia.

La parte RoBERTa del nombre del modelo se refiere al hecho de que el enfoque de preentrenamiento es el mismo que el de los modelos monolingües RoBERTa. Los desarrolladores de RoBERTa mejoraron varios aspectos de BERT, en particular eliminando la tarea de predicción de la siguiente frase.3 XLM-R también abandona las incrustaciones lingüísticas utilizadas en XLM y utiliza SentencePiece para tokenizar directamente los textos sin procesar.4 Además de su naturaleza multilingüe, una diferencia notable entre XLM-R y RoBERTa es el tamaño de los vocabularios respectivos: ¡250.000 tokens frente a 55.000!

XLM-R es una gran elección para tareas NLU multilingües. En la siguiente sección, exploraremos cómo puede tokenizar eficazmente en muchos idiomas.

Una mirada más cercana a la tokenización

En lugar de utilizar un tokenizador WordPiece, XLM-R utiliza un tokenizador llamado SentencePiece que se entrena con el texto en bruto de las cien lenguas. Para hacerte una idea de cómo es SentencePiece en comparación con WordPiece, vamos a cargar los tokenizadores BERT y XLM-R de la forma habitual con nlpt_pin01 Transformers:

from transformers import AutoTokenizer

bert_model_name = "bert-base-cased"
xlmr_model_name = "xlm-roberta-base"
bert_tokenizer = AutoTokenizer.from_pretrained(bert_model_name)
xlmr_tokenizer = AutoTokenizer.from_pretrained(xlmr_model_name)

Al codificar una pequeña secuencia de texto también podemos recuperar los tokens especiales que cada modelo utilizó durante el preentrenamiento:

text = "Jack Sparrow loves New York!"
bert_tokens = bert_tokenizer(text).tokens()
xlmr_tokens = xlmr_tokenizer(text).tokens()
BERT [CLS] Jack Spa ##fila le encanta Nuevo York ¡! [SEP] Ninguno
XLM-R <s> ▁Jack ▁Spar fila ▁amor s ▁Nuevo ▁York ¡! </s>

Aquí vemos que en lugar de los tokens [CLS] y [SEP] que utiliza BERT para las tareas de clasificación de frases, XLM-R utiliza <s> y <\s> para denotar el inicio y el final de una secuencia. Estos tokens se añaden en la etapa final de la tokenización, como veremos a continuación.

La tubería del tokenizador

Hasta ahora hemos tratado la tokenización como una única operación que transforma cadenas en enteros que podemos pasar a través del modelo. Esto no es del todo exacto, y si miramos más de cerca veremos que en realidad se trata de una cadena de procesamiento completa que suele constar de cuatro pasos, como se muestra en la Figura 4-1.

Tokenizer pipeline
Figura 4-1. Pasos del proceso de tokenización

Veamos más detenidamente cada paso del procesamiento e ilustremos su efecto con la frase de ejemplo "¡A Jack Sparrow le encanta Nueva York!":

Normalización

Este paso corresponde al conjunto de operaciones que aplicas a una cadena sin procesar para hacerla más "limpia". Entre las operaciones más comunes están la eliminación de los espacios en blanco y de los caracteres acentuados. La normalización Unicode es otra operación de normalización común que aplican muchos tokenizadores para hacer frente al hecho de que a menudo existen varias formas de escribir el mismo carácter. Esto puede hacer que dos versiones de la "misma" cadena (es decir, con la misma secuencia de caracteres abstractos) parezcan diferentes; los esquemas de normalización Unicode como NFC, NFD, NFKC y NFKD sustituyen las diversas formas de escribir el mismo carácter por formas estándar. Otro ejemplo de normalización son las minúsculas. Si se espera que el modelo sólo acepte y utilice caracteres en minúsculas, esta técnica puede utilizarse para reducir el tamaño del vocabulario que necesita. Tras la normalización, nuestra cadena de ejemplo quedaría como "¡a jack sparrow le encanta nueva york!".

Pretokenización

Este paso divide un texto en objetos más pequeños que dan un límite superior a lo que serán tus tokens al final del entrenamiento. Una buena forma de verlo es que el pretokenizador dividirá tu texto en "palabras", y tus tokens finales serán partes de esas palabras. En las lenguas que lo permiten (inglés, alemán y muchas lenguas indoeuropeas), las cadenas suelen dividirse en palabras a partir de los espacios en blanco y la puntuación. Por ejemplo, este paso podría transformar nuestro["jack", "sparrow", "loves", "new", "york", "!"]. A continuación, estas palabras son más sencillas de dividir en subpalabras con los algoritmos de Codificación de Pares de Bytes (BPE) o Unigrama en el siguiente paso de la cadena. Sin embargo, la división en "palabras" no siempre es una operación trivial y determinista, ni siquiera una operación que tenga sentido. Por ejemplo, en lenguas como el chino, el japonés o el coreano, agrupar símbolos en unidades semánticas como palabras indoeuropeas puede ser una operación no determinista con varios grupos igualmente válidos. En este caso, podría ser mejor no pretokenizar el texto y utilizar en su lugar una biblioteca específica de la lengua para la pretokenización.

Modelo de tokenizador

Una vez normalizados y pretokenizados los textos de entrada, el tokenizador aplica a las palabras un modelo de división de subpalabras. Esta es la parte del proceso que debe entrenarse con tu corpus (o que ya se ha entrenado si utilizas un tokenizador preentrenado). La función del modelo es dividir las palabras en subpalabras para reducir el tamaño del vocabulario e intentar reducir el número de tokens fuera de vocabulario. Existen varios algoritmos de tokenización de subpalabras, como BPE, Unigram y WordPiece. Por ejemplo, nuestro ejemplo en ejecución podría parecerse a [jack, spa, rrow, loves, new, york, !] después de aplicar el modelo de tokenización. Observa que en este punto ya no tenemos una lista de cadenas, sino una lista de números enteros (ID de entrada); para mantener el ejemplo ilustrativo, hemos conservado las palabras pero hemos eliminado las comillas para indicar la transformación.

Postprocesado

Este es el último paso del proceso de tokenización, en el que se pueden aplicar algunas transformaciones adicionales a la lista de tokens, por ejemplo, añadir tokens especiales al principio o al final de la secuencia de entrada de índices de tokens. Por ejemplo, un tokenizador estilo BERT añadiría tokens de clasificación y separadores:[CLS, jack, spa, rrow, loves, new, york, !, SEP]. Esta secuencia (recuerda que será una secuencia de números enteros, no los tokens que ves aquí) puede introducirse en el modelo.

Volviendo a nuestra comparación de XLM-R y BERT, ahora entendemos que SentencePiece añade <s> y <\s> en lugar de [CLS] y [SEP] en el paso de postprocesamiento (como convención, seguiremos utilizando [CLS] y [SEP] en las ilustraciones gráficas). Volvamos al tokenizador SentencePiece para ver qué lo hace especial.

El tokenizador de frases

El tokenizador de SentencePiece se basa en un tipo de segmentación de subpalabras llamadoUnigrama y codifica cada texto de entrada como una secuencia de caracteres Unicode. Esta última característica es especialmente útil para los corpus multilingües, ya que permite a SentencePiece ser agnóstico respecto a los acentos, la puntuación y el hecho de que muchos idiomas, como el japonés, no tienen caracteres de espacio en blanco. Otra característica especial de SentencePiece es que a los espacios en blanco se les asigna el símbolo Unicode U+2581, o el carácter ▁, también llamado carácter de cuarto de bloque inferior. Esto permite a SentencePiece destokenizar una secuencia sin ambigüedades y sin depender de pretokenizadores específicos del idioma. En nuestro ejemplo de la sección anterior, por ejemplo, podemos ver que WordPiece ha perdido la información de que no hay espacios en blanco entre "York" y "!". En cambio, SentencePiece conserva los espacios en blanco en el texto tokenizado para que podamos volver a convertirlo al texto sin procesar sin ambigüedades:

"".join(xlmr_tokens).replace(u"\u2581", " ")
'<s> Jack Sparrow loves New York!</s>'

Ahora que entendemos cómo funciona SentencePiece, veamos cómo podemos codificar nuestro sencillo ejemplo en una forma adecuada para el NER. Lo primero que hay que hacer es cargar el modelo preentrenado con un cabezal de clasificación de tokens. Pero en lugar de cargar este cabezal directamente desdenlpt_pin01 Transformers, ¡lo construiremos nosotros mismos! Si profundizamos en la API de nlpt_pin01Transformers, podremos hacerlo en unos pocos pasos.

Transformadores para el Reconocimiento de Entidades Nombradas

En el Capítulo 2 vimos que, para la clasificación de textos, el BERT utiliza el token especial [CLS] para representar una secuencia completa de texto. A continuación, esta representación se hace pasar por una capa totalmente conectada o densa para obtener la distribución de todos los valores discretos de la etiqueta, como se muestra en la Figura 4-2.

Architecture of a transformer encoder for classification.
Figura 4-2. Ajuste fino de un transformador basado en codificador para la clasificación de secuencias

El BERT y otros transformadores que sólo codifican adoptan un enfoque similar para el NER, salvo que la representación de cada token de entrada individual se introduce en la misma capa totalmenteconectada para dar como salida la entidad del token. Por este motivo, la NER se suele plantear como una tarea de clasificación de tokens. El proceso se parece al diagrama de laFigura 4-3.

Architecture of a transformer encoder for named entity recognition. The wide linear layer shows that the same linear layer is applied to all hidden states.
Figura 4-3. Ajuste fino de un transformador basado en codificador para el reconocimiento de entidades con nombre

Hasta aquí todo bien, pero ¿cómo debemos tratar las subpalabras en una tarea de clasificación de tokens? Por ejemplo, el nombre de pila "Christa" dela Figura 4-3 está tokenizado en las subpalabras "Chr" y "##ista", así que ¿a cuál o cuáles debe asignarse la etiqueta B-PER?

En el artículo del BERT5 los autores asignaron esta etiqueta a la primera subpalabra ("Chr" en nuestro ejemplo) e ignoraron la subpalabra siguiente ("##ista"). Ésta es la convención que adoptaremos aquí, e indicaremos las subpalabras ignoradas con IGN. Más adelante podremos propagar fácilmente la etiqueta predicha de la primera subpalabra a las subpalabras siguientes en el paso de postprocesamiento. También podríamos haber optado por incluir la representación de la subpalabra "##ista" asignándole una copia de la etiquetaB-LOC, pero esto viola el formato IOB2.

Afortunadamente, todos los aspectos de la arquitectura que hemos visto en BERT se trasladan a XLM-R, ya que su arquitectura se basa en RoBERTa, ¡que es idéntica a BERT! A continuación veremos cómonlpt_pin01 Transformers admite muchas otras tareas con pequeñas modificaciones.

La anatomía de la clase modelo de Transformers

nlpt_pin01 Transformers está organizado en torno a clases dedicadas a cada arquitectura y tarea. Las clases modelo asociadas a las distintas tareas se nombran según la convención<ModelName>For<Task>, o AutoModelFor<Task> cuando se utilizan las clases AutoModel.

Sin embargo, este enfoque tiene sus limitaciones, y para motivar a profundizar en la API de Transformadoresnlpt_pin01, considera el siguiente escenario. Supón que tienes una gran idea para resolver un problema de PNL que te ronda la cabeza desde hace tiempo con un modelo de transformador. Así que organizas una reunión con tu jefe y, con una presentación en PowerPoint ingeniosamente elaborada, le planteas que podrías aumentar los ingresos de tu departamento si por fin consigues resolver el problema. Impresionado con tu vistosa presentación y tu palabrería sobre los beneficios, tu jefe accede generosamente a darte una semana para construir una prueba de concepto. Contento con el resultado, te pones a trabajar de inmediato. Enciendes tu GPU y abres un cuaderno. Ejecutasfrom transformers import BertForTaskXY (ten en cuenta que TaskXY es la tarea imaginaria que te gustaría resolver) y se te escapa el color de la cara cuando el temido color rojo llena tu pantalla:ImportEr⁠ror:​ can⁠not import name BertForTaskXY. ¡Oh, no, no hay ningún modelo BERT para tu caso de uso! ¡¿Cómo vas a completar el proyecto en una semana si tienes que implementar todo el modelo tú mismo?! ¿Por dónde empezar siquiera?

¡Que no cunda el pánico! nlpt_pin01 Transformers está diseñado para que puedas ampliar fácilmente los modelos existentes para tu caso de uso específico. Puedes cargar los pesos de modelos preentrenados, y tienes acceso a funciones de ayuda específicas para cada tarea. Esto te permite construir modelos personalizados para objetivos específicos con muy poca sobrecarga. En esta sección, veremos cómo podemos implementar nuestro propio modelo personalizado.

Cuerpos y cabezas

El concepto principal que hace que los Transformadores nlpt_pin01 sean tan versátiles es la división de la arquitectura en un cuerpo y una cabeza(como vimos en el Capítulo 1). Ya hemos visto que cuando pasamos de la tarea de preentrenamiento a la tarea posterior, tenemos que sustituir la última capa del modelo por una que sea adecuada para la tarea. Esta última capa se denomina cabeza del modelo; es la parte específica de la tarea. El resto del modelo se denomina cuerpo; incluye las capas de incrustación de tokens y de transformadores que son agnósticas a la tarea. Esta estructura se refleja también en el código de los Transformadores denlpt_pin01: el cuerpo de un modelo se implementa en una clase como BertModel o GPT2Model que devuelve los estados ocultos de la última capa. Los modelos específicos de una tarea, como BertForMaskedLM o BertForSequenceClassification, utilizan el modelo base y añaden la cabeza necesaria sobre los estados ocultos, como se muestra en la Figura 4-4.

bert-body-head
Figura 4-4. La clase BertModel sólo contiene el cuerpo del modelo, mientras que las clases BertFor<Task> combinan el cuerpo con una cabeza dedicada a una tarea determinada

Como veremos a continuación, esta separación de cuerpos y cabezas nos permite construir una cabeza personalizada para cualquier tarea y simplemente montarla sobre un modelo preentrenado.

Crear un modelo personalizado para la clasificación de tokens

Hagamos el ejercicio de construir un cabezal de clasificación de tokens personalizado para XLM-R. Dado que XLM-R utiliza la misma arquitectura de modelos que RoBERTa, utilizaremos RoBERTa como modelo base, pero aumentado con ajustes específicos para XLM-R. Ten en cuenta que se trata de un ejercicio educativo para mostrarte cómo construir un modelo personalizado para tu propia tarea. Para la clasificación de tokens, ya existe una clase XLMRobertaForTokenClassificationque puedes importar denlpt_pin01 Transformers. Si quieres, puedes pasar a la siguiente sección y utilizar simplemente ésa.

Para empezar, necesitamos una estructura de datos que represente a nuestro etiquetador XLM-R NER. En principio, necesitaremos un objeto de configuración para inicializar el modelo y una función forward() para generar las salidas. Sigamos adelante y construyamos nuestra clase XLM-R para la clasificación de tokens:

import torch.nn as nn
from transformers import XLMRobertaConfig
from transformers.modeling_outputs import TokenClassifierOutput
from transformers.models.roberta.modeling_roberta import RobertaModel
from transformers.models.roberta.modeling_roberta import RobertaPreTrainedModel

class XLMRobertaForTokenClassification(RobertaPreTrainedModel):
    config_class = XLMRobertaConfig

    def __init__(self, config):
        super().__init__(config)
        self.num_labels = config.num_labels
        # Load model body
        self.roberta = RobertaModel(config, add_pooling_layer=False)
        # Set up token classification head
        self.dropout = nn.Dropout(config.hidden_dropout_prob)
        self.classifier = nn.Linear(config.hidden_size, config.num_labels)
        # Load and initialize weights
        self.init_weights()

    def forward(self, input_ids=None, attention_mask=None, token_type_ids=None,
                labels=None, **kwargs):
        # Use model body to get encoder representations
        outputs = self.roberta(input_ids, attention_mask=attention_mask,
                               token_type_ids=token_type_ids, **kwargs)
        # Apply classifier to encoder representation
        sequence_output = self.dropout(outputs[0])
        logits = self.classifier(sequence_output)
        # Calculate losses
        loss = None
        if labels is not None:
            loss_fct = nn.CrossEntropyLoss()
            loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
        # Return model output object
        return TokenClassifierOutput(loss=loss, logits=logits,
                                     hidden_states=outputs.hidden_states,
                                     attentions=outputs.attentions)

La página config_class garantiza que se utilicen los ajustes estándar de XLM-R cuando inicializamos un nuevo modelo. Si quieres cambiar los parámetros por defecto, puedes hacerlo sobrescribiendo los parámetros por defecto en la configuración. Con el método super() llamamos a la función de inicialización de la clase RobertaPreTrainedModel. Esta clase abstracta se encarga de la inicialización o carga de los pesos preentrenados. A continuación, cargamos el cuerpo de nuestro modelo, que es RobertaModel, y lo ampliamos con nuestra propia cabeza de clasificación, formada por una capa de abandono y una capa estándar de avance. Observa que establecemos add_​pool⁠ing_layer=False para asegurarnos de que se devuelven todos los estados ocultos y no sólo el asociado al token [CLS]. Por último, inicializamos todos los pesos llamando al métodoinit_weights() que heredamos de RobertaPreTrainedModel, que cargará los pesos preentrenados para el cuerpo del modelo e inicializará aleatoriamente los pesos de nuestra cabeza de clasificación de tokens.

Lo único que queda por hacer es definir lo que debe hacer el modelo en un pase hacia delante con un método forward(). Durante el pase hacia delante, los datos se introducen primero en el cuerpo del modelo. Hay varias variables de entrada, pero las únicas que necesitamos por ahora son input_ids yattention_mask. A continuación, el estado oculto, que forma parte de la salida del cuerpo del modelo, se alimenta a través de las capas de abandono y clasificación. Si también proporcionamos etiquetas en el paso hacia delante, podemos calcular directamente la pérdida. Si hay una máscara de atención, tenemos que hacer un poco más de trabajo para asegurarnos de que sólo calculamos la pérdida de las fichas desenmascaradas. Por último, envolvemos todas las salidas en un objeto TokenClassifierOutput que nos permite acceder a los elementos de una tupla con el nombre familiar de los capítulos anteriores.

Con sólo implementar dos funciones de una clase sencilla, podemos construir nuestro propio modelo de transformador personalizado. Y como heredamos dePreTrainedModel, ¡obtenemos acceso instantáneo a todas las útiles utilidades del Transformadornlpt_pin01, comofrom_pretrained()! Veamos cómo podemos cargar pesos preentrenados en nuestro modelo personalizado.

Cargar un modelo personalizado

Ahora estamos listos para cargar nuestro modelo de clasificación de tokens. Necesitaremos proporcionar alguna información adicional además del nombre del modelo, incluidas las etiquetas que utilizaremos para etiquetar cada entidad y el mapeo de cada etiqueta a un ID y viceversa. Toda esta información puede derivarse de nuestra variable tags, que como objeto ClassLabel tiene un atributo names que podemos utilizar para derivar el mapeo:

index2tag = {idx: tag for idx, tag in enumerate(tags.names)}
tag2index = {tag: idx for idx, tag in enumerate(tags.names)}

Almacenaremos estas asignaciones y el atributo tags.num_classesen el objeto AutoConfig que encontramos en el Capítulo 3. Si pasas argumentos de palabra clave al método from_pretrained(), anularás los valores por defecto:

from transformers import AutoConfig

xlmr_config = AutoConfig.from_pretrained(xlmr_model_name,
                                         num_labels=tags.num_classes,
                                         id2label=index2tag, label2id=tag2index)

La clase AutoConfig contiene el plano de la arquitectura de un modelo. Cuando cargamos un modelo conAutoModel.from_pretrained(model_ckpt)el archivo de configuración asociado a ese modelo se descarga automáticamente. Sin embargo, si queremos modificar algo como el número de clases o los nombres de las etiquetas, podemos cargar primero la configuración con los parámetros que queremos personalizar.

Ahora, podemos cargar los pesos del modelo como de costumbre con la función from_pretrained()con el argumento adicional config. Ten en cuenta que no hemos implementado la carga de pesos preentrenados en nuestra clase de modelo personalizada; la obtenemos gratuitamente al heredar de RobertaPreTrainedModel:

import torch

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
xlmr_model = (XLMRobertaForTokenClassification
              .from_pretrained(xlmr_model_name, config=xlmr_config)
              .to(device))

Para comprobar rápidamente que hemos inicializado correctamente el tokenizador y el modelo, probemos las predicciones con nuestra pequeña secuencia de entidades conocidas:

input_ids = xlmr_tokenizer.encode(text, return_tensors="pt")
pd.DataFrame([xlmr_tokens, input_ids[0].numpy()], index=["Tokens", "Input IDs"])
0 1 2 3 4 5 6 7 8 9
Fichas <s> ▁Jack ▁Spar fila ▁amor s ▁Nuevo ▁York ¡! </s>
ID de entrada 0 21763 37456 15555 5161 7 2356 5753 38 2

Como puedes ver aquí, los tokens de inicio <s> y fin </s> reciben los ID 0 y 2,respectivamente.

Por último, tenemos que pasar las entradas al modelo y extraer las predicciones tomando el argmax para obtener la clase más probable por token:

outputs = xlmr_model(input_ids.to(device)).logits
predictions = torch.argmax(outputs, dim=-1)
print(f"Number of tokens in sequence: {len(xlmr_tokens)}")
print(f"Shape of outputs: {outputs.shape}")
Number of tokens in sequence: 10
Shape of outputs: torch.Size([1, 10, 7])

Aquí vemos que los logits tienen la forma[batch_size, num_tokens, num_tags], y a cada token se le asigna un logit entre las siete posibles etiquetas NER. Enumerando sobre la secuencia, podemos ver rápidamente lo que predice el modelo preentrenado:

preds = [tags.names[p] for p in predictions[0].cpu().numpy()]
pd.DataFrame([xlmr_tokens, preds], index=["Tokens", "Tags"])
0 1 2 3 4 5 6 7 8 9
Fichas <s> ▁Jack ▁Spar fila ▁amor s ▁Nuevo ▁York ¡! </s>
Etiquetas O I-LOC B-LOC B-LOC O I-LOC O O I-LOC B-LOC

Como era de esperar, nuestra capa de clasificación de tokens con pesos aleatorios deja mucho que desear; ¡afinemos con algunos datos etiquetados para mejorarla! Antes de hacerlo, vamos a envolver los pasos anteriores en una función de ayuda para su uso posterior:

def tag_text(text, tags, model, tokenizer):
    # Get tokens with special characters
    tokens = tokenizer(text).tokens()
    # Encode the sequence into IDs
    input_ids = xlmr_tokenizer(text, return_tensors="pt").input_ids.to(device)
    # Get predictions as distribution over 7 possible classes
    outputs = model(input_ids)[0]
    # Take argmax to get most likely class per token
    predictions = torch.argmax(outputs, dim=2)
    # Convert to DataFrame
    preds = [tags.names[p] for p in predictions[0].cpu().numpy()]
    return pd.DataFrame([tokens, preds], index=["Tokens", "Tags"])

Antes de entrenar el modelo, también tenemos que tokenizar las entradas y preparar las etiquetas. Lo haremos a continuación.

Tokenización de textos para NER

Ahora que hemos establecido que el tokenizador y el modelo pueden codificar un solo ejemplo, nuestro siguiente paso es tokenizar todo el conjunto de datos para poder pasarlo al modelo XLM-R para su ajuste. Como vimos en elCapítulo 2, nlpt_pin01Datasets proporciona una forma rápida de tokenizar un objeto Dataset con la operaciónmap(). Para conseguirlo, recuerda que primero tenemos que definir una función con la firma mínima:

function(examples: Dict[str, List]) -> Dict[str, List]

donde examples equivale a una porción de un Dataset, por ejemplo,panx_de['train'][:10]. Como el tokenizador XLM-R devuelve los ID de entrada para las entradas del modelo, sólo tenemos que aumentar esta información con la máscara de atención y los ID de etiqueta que codifican la información sobre qué token está asociado a cada etiqueta NER.

Siguiendo el enfoque adoptado enla documentación de nlpt_pin01Transformers, veamos cómo funciona con nuestro único ejemplo alemán, recogiendo primero las palabras y etiquetas como listas ordinarias:

words, labels = de_example["tokens"], de_example["ner_tags"]

A continuación, tokenizamos cada palabra y utilizamos el argumento is_split_into_words para indicar al tokenizador que nuestra secuencia de entrada ya ha sido dividida en palabras:

tokenized_input = xlmr_tokenizer(de_example["tokens"], is_split_into_words=True)
tokens = xlmr_tokenizer.convert_ids_to_tokens(tokenized_input["input_ids"])
pd.DataFrame([tokens], index=["Tokens"])
0 1 2 3 4 5 6 ... 18 19 20 21 22 23 24
Fichas <s> ▁2.000 ▁Einwohner n ▁an ▁der ▁Dan ... schaft ▁Po mmer n . </s>

En este ejemplo podemos ver que el tokenizador ha dividido "Einwohnern" en dos subpalabras, "▁Einwohner" y "n". Como seguimos la convención de que sólo "▁Einwohner" debe asociarse a la etiqueta B-LOC, necesitamos una forma de enmascarar las representaciones de las subpalabras después de la primera subpalabra. Afortunadamente, tokenized_inputes una clase que contiene una función word_ids() que puede ayudarnos a conseguirlo:

word_ids = tokenized_input.word_ids()
pd.DataFrame([tokens, word_ids], index=["Tokens", "Word IDs"])
0 1 2 3 4 5 6 ... 18 19 20 21 22 23 24
Fichas <s> ▁2.000 ▁Einwohner n ▁an ▁der ▁Dan ... schaft ▁Po mmer n . </s>
Identificadores de palabras Ninguno 0 1 1 2 3 4 ... 9 10 10 10 11 11 Ninguno

Aquí podemos ver que word_ids ha asignado a cada subpalabra el índice correspondiente de la secuencia words, de modo que a la primera subpalabra, "▁2.000", se le asigna el índice 0, mientras que a "▁Einwohner" y "n" se les asigna el índice 1 (ya que "Einwohnern" es la segunda palabra dewords). También podemos ver que los tokens especiales como <s> y <\s> se asignan a None. Fijemos -100 como etiqueta para estas palabras especiales y las subpalabras que deseamos enmascarar durante el entrenamiento:

previous_word_idx = None
label_ids = []

for word_idx in word_ids:
    if word_idx is None or word_idx == previous_word_idx:
        label_ids.append(-100)
    elif word_idx != previous_word_idx:
        label_ids.append(labels[word_idx])
    previous_word_idx = word_idx

labels = [index2tag[l] if l != -100 else "IGN" for l in label_ids]
index = ["Tokens", "Word IDs", "Label IDs", "Labels"]

pd.DataFrame([tokens, word_ids, label_ids, labels], index=index)
0 1 2 3 4 5 ... 19 20 21 22 23 24
Fichas <s> ▁2.000 ▁Einwohner n ▁an ▁der ... ▁Po mmer n . </s>
Identificadores de palabras Ninguno 0 1 1 2 3 ... 10 10 10 11 11 Ninguno
Identificadores de etiqueta -100 0 0 -100 0 0 ... 6 -100 -100 0 -100 -100
Etiquetas IGN O O IGN O O ... I-LOC IGN IGN O IGN IGN
Nota

¿Por qué elegimos -100 como ID para enmascarar las representaciones de subpalabras? La razón es que en PyTorch la clase de pérdida de entropía cruzada torch.nn.CrossEntropyLoss tiene un atributo llamado ignore_index cuyo valor es -100. Este índice se ignora durante el entrenamiento, así que podemos utilizarlo para ignorar los tokens asociados asubpalabras consecutivas.

¡Y ya está! Podemos ver claramente cómo los ID de las etiquetas se alinean con los tokens, así que ampliemos esto a todo el conjunto de datos definiendo una única función que envuelva toda la lógica:

def tokenize_and_align_labels(examples):
    tokenized_inputs = xlmr_tokenizer(examples["tokens"], truncation=True,
                                      is_split_into_words=True)
    labels = []
    for idx, label in enumerate(examples["ner_tags"]):
        word_ids = tokenized_inputs.word_ids(batch_index=idx)
        previous_word_idx = None
        label_ids = []
        for word_idx in word_ids:
            if word_idx is None or word_idx == previous_word_idx:
                label_ids.append(-100)
            else:
                label_ids.append(label[word_idx])
            previous_word_idx = word_idx
        labels.append(label_ids)
    tokenized_inputs["labels"] = labels
    return tokenized_inputs

Ahora tenemos todos los ingredientes que necesitamos para codificar cada división, así que escribamos una función sobre la que podamos iterar:

def encode_panx_dataset(corpus):
    return corpus.map(tokenize_and_align_labels, batched=True,
                      remove_columns=['langs', 'ner_tags', 'tokens'])

Aplicando esta función a un objeto DatasetDict, obtenemos un objetoDataset codificado por división. Utilicemos esto para codificar nuestro corpus alemán:

panx_de_encoded = encode_panx_dataset(panx_ch["de"])

Ahora que tenemos un modelo y un conjunto de datos, tenemos que definir una métrica de rendimiento.

Medidas de rendimiento

Evaluar un modelo NER es similar a evaluar un modelo de clasificación de texto, y es habitual informar de los resultados de precisión, recuperación ypuntuación F1. La única sutileza es que todas las palabras de una entidad deben predecirse correctamente para que una predicción se cuente como correcta. Afortunadamente, existe una ingeniosa biblioteca llamadaseqeval que está diseñada para este tipo de tareas. Por ejemplo, dadas algunas etiquetas NER y predicciones de modelos, podemos calcular las métricas mediante la función classification_report() de seqeval:

from seqeval.metrics import classification_report

y_true = [["O", "O", "O", "B-MISC", "I-MISC", "I-MISC", "O"],
          ["B-PER", "I-PER", "O"]]
y_pred = [["O", "O", "B-MISC", "I-MISC", "I-MISC", "I-MISC", "O"],
          ["B-PER", "I-PER", "O"]]
print(classification_report(y_true, y_pred))
              precision    recall  f1-score   support

        MISC       0.00      0.00      0.00         1
         PER       1.00      1.00      1.00         1

   micro avg       0.50      0.50      0.50         2
   macro avg       0.50      0.50      0.50         2
weighted avg       0.50      0.50      0.50         2

Como vemos, seqeval espera las predicciones y etiquetas como listas de listas, en las que cada lista corresponde a un único ejemplo de nuestros conjuntos de validación o prueba. Para integrar estas métricas durante el entrenamiento, necesitamos una función que pueda tomar las salidas del modelo y convertirlas en las listas que espera seqeval. Lo siguiente hace el truco asegurándose de que ignoramos los ID de etiqueta asociados a las subpalabras subsiguientes:

import numpy as np

def align_predictions(predictions, label_ids):
    preds = np.argmax(predictions, axis=2)
    batch_size, seq_len = preds.shape
    labels_list, preds_list = [], []

    for batch_idx in range(batch_size):
        example_labels, example_preds = [], []
        for seq_idx in range(seq_len):
            # Ignore label IDs = -100
            if label_ids[batch_idx, seq_idx] != -100:
                example_labels.append(index2tag[label_ids[batch_idx][seq_idx]])
                example_preds.append(index2tag[preds[batch_idx][seq_idx]])

        labels_list.append(example_labels)
        preds_list.append(example_preds)

    return preds_list, labels_list

Equipados con una métrica de rendimiento, podemos pasar a entrenar realmente el modelo.

Ajuste de XLM-RoBERTa

Ahora tenemos todos los ingredientes para ajustar nuestro modelo. Nuestra primera estrategia consistirá en afinar nuestro modelo base en el subconjunto alemán de PAN-X y luego evaluar su rendimiento multilingüe en francés, italiano e inglés. Como de costumbre, utilizaremos los Transformadoresnlpt_pin01 Trainer para manejar nuestro bucle de entrenamiento, así que primero tenemos que definir los atributos de entrenamiento utilizando la clase TrainingArguments:

from transformers import TrainingArguments

num_epochs = 3
batch_size = 24
logging_steps = len(panx_de_encoded["train"]) // batch_size
model_name = f"{xlmr_model_name}-finetuned-panx-de"
training_args = TrainingArguments(
    output_dir=model_name, log_level="error", num_train_epochs=num_epochs,
    per_device_train_batch_size=batch_size,
    per_device_eval_batch_size=batch_size, evaluation_strategy="epoch",
    save_steps=1e6, weight_decay=0.01, disable_tqdm=False,
    logging_steps=logging_steps, push_to_hub=True)

Aquí evaluamos las predicciones del modelo en el conjunto de validación al final de cada época, ajustamos el decaimiento del peso y fijamossave_steps en un número grande para desactivar el punto de control y acelerar así el entrenamiento.

Este también es un buen momento para asegurarnos de que hemos iniciado sesión en el Hub Cara Abrazada (si estás trabajando en un terminal, puedes ejecutar el comando huggingface-cli login en su lugar):

from huggingface_hub import notebook_login

notebook_login()

También tenemos que indicar a Trainer cómo calcular las métricas en el conjunto de validación, así que aquí podemos utilizar la función align_predictions() que definimos antes para extraer las predicciones y las etiquetas en el formato que necesita seqeval para calcularla puntuación F1:

from seqeval.metrics import f1_score

def compute_metrics(eval_pred):
    y_pred, y_true = align_predictions(eval_pred.predictions,
                                       eval_pred.label_ids)
    return {"f1": f1_score(y_true, y_pred)}

El último paso es definir un intercalador de datos para que podamos rellenar cada secuencia de entrada con la mayor longitud de secuencia de un lote.nlpt_pin01 Transformers proporciona un intercalador de datos dedicado para la clasificación de fichas que rellenará las etiquetas junto con las entradas:

from transformers import DataCollatorForTokenClassification

data_collator = DataCollatorForTokenClassification(xlmr_tokenizer)

Es necesario rellenar las etiquetas porque, a diferencia de una tarea de clasificación de texto, las etiquetas también son secuencias. Un detalle importante aquí es que las secuencias de etiquetas se rellenan con el valor -100, que, como hemos visto, es ignorado por las funciones de pérdida de PyTorch.

Entrenaremos varios modelos a lo largo de este capítulo, así que evitaremos inicializar un nuevo modelo para cada Trainer creando un método model_init(). Este método carga un modelo no entrenado y se llama al principio de la llamada a train():

def model_init():
    return (XLMRobertaForTokenClassification
            .from_pretrained(xlmr_model_name, config=xlmr_config)
            .to(device))

Ahora podemos pasar toda esta información junto con los conjuntos de datos codificados a Trainer:

from transformers import Trainer

trainer = Trainer(model_init=model_init, args=training_args,
                  data_collator=data_collator, compute_metrics=compute_metrics,
                  train_dataset=panx_de_encoded["train"],
                  eval_dataset=panx_de_encoded["validation"],
                  tokenizer=xlmr_tokenizer)

y luego ejecuta el bucle de entrenamiento como se indica a continuación y envía el modelo final al Hub:

trainer.train() trainer.push_to_hub(commit_message="Training completed!")
Época Pérdida de entrenamiento Pérdida por validación F1
1 0.2652 0.160244 0.822974
2 0.1314 0.137195 0.852747
3 0.0806 0.138774 0.864591

Estas puntuaciones F1 son bastante buenas para un modelo NER. Para confirmar que nuestro modelo funciona como esperábamos, probémoslo con la traducción al alemán de nuestro sencillo ejemplo:

text_de = "Jeff Dean ist ein Informatiker bei Google in Kalifornien"
tag_text(text_de, tags, trainer.model, xlmr_tokenizer)
0 1 2 3 4 5 ... 8 9 10 11 12 13
Fichas <s> ▁Jeff ▁De un ▁ist ▁ein ... ▁bei ▁Google ▁in ▁Kaliforni en </s>
Etiquetas O B-PER I-PER I-PER O O ... O B-ORG O B-LOC I-LOC O

¡Funciona! Pero nunca debemos confiar demasiado en el rendimiento basándonos en un solo ejemplo. En lugar de eso, deberíamos realizar una investigación adecuada y exhaustiva de los errores del modelo. En la siguiente sección exploraremos cómo hacerlo para la tarea NER.

Análisis de errores

Antes de profundizar en los aspectos multilingües de XLM-R, dediquemos un minuto a investigar los errores de nuestro modelo. Como vimos en el Capítulo 2, un análisis exhaustivo de los errores de tu modelo es uno de los aspectos más importantes a la hora de entrenar y depurar transformadores (y modelos de aprendizaje automático en general). Hay varios modos de fallo en los que puede parecer que el modelo funciona bien, mientras que en la práctica tiene algunos fallos graves. Algunos ejemplos en los que puede fallar el entrenamiento son:

  • Podríamos enmascarar accidentalmente demasiadas fichas y enmascarar también algunas de nuestras etiquetas para obtener una caída de pérdidas realmente prometedora.

  • La función compute_metrics() puede tener un error que sobrestime el rendimiento real.

  • Podríamos incluir la clase cero o entidad O en NER como clase normal, lo que sesgaría mucho la precisión y lapuntuación F1, ya que es la clase mayoritaria por un amplio margen.

Cuando el modelo funciona mucho peor de lo esperado, observar los errores puede aportar información útil y revelar fallos que serían difíciles de detectar con sólo mirar el código. E incluso si el modelo funciona bien y no hay errores en el código, el análisis de errores sigue siendo una herramienta útil para comprender los puntos fuertes y débiles del modelo. Son aspectos que siempre debemos tener en cuenta cuando implementamos un modelo en un entorno de producción.

Para nuestro análisis volveremos a utilizar una de las herramientas más potentes de que disponemos, que es fijarnos en los ejemplos de validación con mayor pérdida. Podemos reutilizar gran parte de la función que construimos para analizar el modelo de clasificación de secuencias en el Capítulo 2, pero ahora calcularemos una pérdida por token en la secuencia de muestra.

Definamos un método que podamos aplicar al conjunto de validación:

from torch.nn.functional import cross_entropy

def forward_pass_with_label(batch):
    # Convert dict of lists to list of dicts suitable for data collator
    features = [dict(zip(batch, t)) for t in zip(*batch.values())]
    # Pad inputs and labels and put all tensors on device
    batch = data_collator(features)
    input_ids = batch["input_ids"].to(device)
    attention_mask = batch["attention_mask"].to(device)
    labels = batch["labels"].to(device)
    with torch.no_grad():
        # Pass data through model
        output = trainer.model(input_ids, attention_mask)
        # logit.size: [batch_size, sequence_length, classes]
        # Predict class with largest logit value on classes axis
        predicted_label = torch.argmax(output.logits, axis=-1).cpu().numpy()
    # Calculate loss per token after flattening batch dimension with view
    loss = cross_entropy(output.logits.view(-1, 7),
                         labels.view(-1), reduction="none")
    # Unflatten batch dimension and convert to numpy array
    loss = loss.view(len(input_ids), -1).cpu().numpy()

    return {"loss":loss, "predicted_label": predicted_label}

Ahora podemos aplicar esta función a todo el conjunto de validación utilizando map()y cargar todos los datos en DataFrame para su posterior análisis:

valid_set = panx_de_encoded["validation"]
valid_set = valid_set.map(forward_pass_with_label, batched=True, batch_size=32)
df = valid_set.to_pandas()

Los tokens y las etiquetas siguen codificados con sus ID, así que vamos a volver a asignar los tokens y las etiquetas a cadenas para que sea más fácil leer los resultados. A los tokens de relleno con etiqueta -100 les asignamos una etiqueta especial, IGN, para poder filtrarlos más tarde. También nos deshacemos de todo el relleno de los campos loss y predicted_label truncándolos a la longitud de las entradas:

index2tag[-100] = "IGN"
df["input_tokens"] = df["input_ids"].apply(
    lambda x: xlmr_tokenizer.convert_ids_to_tokens(x))
df["predicted_label"] = df["predicted_label"].apply(
    lambda x: [index2tag[i] for i in x])
df["labels"] = df["labels"].apply(
    lambda x: [index2tag[i] for i in x])
df['loss'] = df.apply(
    lambda x: x['loss'][:len(x['input_ids'])], axis=1)
df['predicted_label'] = df.apply(
    lambda x: x['predicted_label'][:len(x['input_ids'])], axis=1)
df.head(1)
máscara_atención entradas_id etiquetas pérdida etiqueta_prevista fichas_de_entrada
0 [1, 1, 1, 1, 1, 1, 1] [0, 10699, 11, 15, 16104, 1388, 2] [IGN, B-ORG, IGN, I-ORG, I-ORG, I-ORG, IGN]. [0.0, 0.014679872, 0.0, 0.009469474, 0.010393422, 0.01293836, 0.0] [I-ORG, B-ORG, I-ORG, I-ORG, I-ORG, I-ORG, I-ORG] [<s>, ▁Ham, a, ▁(, ▁Unternehmen, ▁), </s>]

Cada columna contiene una lista de tokens, etiquetas, etiquetas previstas, etc. para cada muestra. Echemos un vistazo a los tokens individualmente descomprimiendo estas listas. La función pan⁠das.Series.explode()nos permite hacer exactamente eso en una línea, creando una fila para cada elemento de la lista original de filas. Como todas las listas de una fila tienen la misma longitud, podemos hacerlo en paralelo para todas las columnas. También eliminamos las fichas de relleno que denominamos IGN, ya que su pérdida es cero de todos modos. Por último, convertimos las pérdidas, que siguen siendo objetos numpy.Array, en flotantes estándar:

df_tokens = df.apply(pd.Series.explode)
df_tokens = df_tokens.query("labels != 'IGN'")
df_tokens["loss"] = df_tokens["loss"].astype(float).round(2)
df_tokens.head(7)
máscara_atención entradas_id etiquetas pérdida etiqueta_prevista fichas_de_entrada
1 10699 B-ORG 0.01 B-ORG ▁Ham
1 15 I-ORG 0.01 I-ORG ▁(
1 16104 I-ORG 0.01 I-ORG ▁Unternehmen
1 1388 I-ORG 0.01 I-ORG ▁)
1 56530 O 0.00 O ▁WE
1 83982 B-ORG 0.34 B-ORG ▁Luz
1 10 I-ORG 0.45 I-ORG ▁a

Con los datos en esta forma, ahora podemos agruparlos por los tokens de entrada y agregar las pérdidas de cada token con el recuento, la media y la suma. Por último, ordenamos los datosagregados por la suma de las pérdidas y vemos qué tokens han acumulado más pérdidas en el conjunto de validación:

(
    df_tokens.groupby("input_tokens")[["loss"]]
    .agg(["count", "mean", "sum"])
    .droplevel(level=0, axis=1)  # Get rid of multi-level columns
    .sort_values(by="sum", ascending=False)
    .reset_index()
    .round(2)
    .head(10)
    .T
)
0 1 2 3 4 5 6 7 8 9
fichas_de_entrada ▁der ▁in ▁von ▁/ ▁und ▁( ▁) ▁'' ▁A
cuenta 6066 1388 989 808 163 1171 246 246 2898 125
media 0.03 0.1 0.14 0.14 0.64 0.08 0.3 0.29 0.02 0.44
suma 200.71 138.05 137.33 114.92 104.28 99.15 74.49 72.35 59.31 54.48

Podemos observar varias pautas en esta lista:

  • El token espacio en blanco tiene la mayor pérdida total, lo que no es sorprendente, ya que también es el token más común de la lista. Sin embargo, su pérdida media es mucho menor que la de los demás tokens de la lista. Esto significa que el modelo no tiene dificultades para clasificarlo.

  • Palabras como "in", "von", "der" y "und" aparecen con relativa frecuencia. A menudo aparecen junto a entidades con nombre y a veces forman parte de ellas, lo que explica que el modelo pueda confundirlas.

  • Los paréntesis, las barras oblicuas y las mayúsculas al principio de las palabras son más raros, pero tienen una pérdida media relativamente alta. Los investigaremos más a fondo.

También podemos agrupar los ID de etiqueta y ver las pérdidas de cada clase:

(
    df_tokens.groupby("labels")[["loss"]]
    .agg(["count", "mean", "sum"])
    .droplevel(level=0, axis=1)
    .sort_values(by="mean", ascending=False)
    .reset_index()
    .round(2)
    .T
)
0 1 2 3 4 5 6
etiquetas B-ORG I-LOC I-ORG B-LOC B-PER I-PER O
cuenta 2683 1462 3820 3172 2893 4139 43648
media 0.66 0.64 0.48 0.35 0.26 0.18 0.03
suma 1769.47 930.94 1850.39 1111.03 760.56 750.91 1354.46

Vemos que B⁠-⁠ORG tiene la pérdida media más alta, lo que significa que determinar el inicio de una organización supone un reto para nuestro modelo.

Podemos desglosar esto aún más trazando la matriz de confusión de la clasificación de tokens, donde vemos que el comienzo de una organización se confunde a menudo con el token posterior I-ORG:

from sklearn.metrics import ConfusionMatrixDisplay, confusion_matrix

def plot_confusion_matrix(y_preds, y_true, labels):
    cm = confusion_matrix(y_true, y_preds, normalize="true")
    fig, ax = plt.subplots(figsize=(6, 6))
    disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=labels)
    disp.plot(cmap="Blues", values_format=".2f", ax=ax, colorbar=False)
    plt.title("Normalized confusion matrix")
    plt.show()
plot_confusion_matrix(df_tokens["labels"], df_tokens["predicted_label"],
                      tags.names)

En el gráfico podemos ver que nuestro modelo tiende a confundir más las entidades B-ORG y I-ORG. Por lo demás, clasifica bastante bien las entidades restantes, lo que queda claro por la naturaleza casi diagonal de la matriz de confusión.

Ahora que hemos examinado los errores a nivel de token, pasemos a examinar las secuencias con grandes pérdidas. Para este cálculo, volveremos a visitar nuestroDataFrame "no explotado" y calcularemos la pérdida total sumando la pérdida por token. Para ello, escribamos primero una función que nos ayude a mostrar las secuencias de tokens con las etiquetas y las pérdidas:

def get_samples(df):
    for _, row in df.iterrows():
        labels, preds, tokens, losses = [], [], [], []
        for i, mask in enumerate(row["attention_mask"]):
            if i not in {0, len(row["attention_mask"])}:
                labels.append(row["labels"][i])
                preds.append(row["predicted_label"][i])
                tokens.append(row["input_tokens"][i])
                losses.append(f"{row['loss'][i]:.2f}")
        df_tmp = pd.DataFrame({"tokens": tokens, "labels": labels,
                               "preds": preds, "losses": losses}).T
        yield df_tmp

df["total_loss"] = df["loss"].apply(sum)
df_tmp = df.sort_values(by="total_loss", ascending=False).head(3)

for sample in get_samples(df_tmp):
    display(sample)
0 1 2 3 4 ... 13 14 15 16 17
fichas ▁'' 8 . ▁Juli ▁'' ... n ischen ▁Gar de </s>
etiquetas B-ORG IGN IGN I-ORG I-ORG ... IGN IGN I-ORG IGN IGN
preds O O O O O ... I-ORG I-ORG I-ORG I-ORG O
pérdidas 7.89 0.00 0.00 6.88 8.05 ... 0.00 0.00 0.01 0.00 0.00
0 1 2 3 4 ... 14 15 16 17 18
fichas ▁' ▁'' ▁Τ Κ ▁'' ... k ▁'' ▁' ala </s>
etiquetas O O O IGN O ... IGN I-LOC I-LOC IGN IGN
preds O O B-ORG O O ... O O O O O
pérdidas 0.00 0.00 3.59 0.00 0.00 ... 0.00 7.66 7.78 0.00 0.00
0 1 2 3 4 ... 10 11 12 13 14
fichas ▁United ▁Naciones ▁Multi dimensional ▁Integra ... ▁el ▁Central ▁Africano ▁República </s>
etiquetas B-PER I-PER I-PER IGN I-PER ... I-PER I-PER I-PER I-PER IGN
preds B-ORG I-ORG I-ORG I-ORG I-ORG ... I-ORG I-ORG I-ORG I-ORG I-ORG
pérdidas 6.46 5.59 5.51 0.00 5.11 ... 4.77 5.32 5.10 4.87 0.00

Es evidente que algo falla en las etiquetas de estas muestras; por ejemplo, ¡las Naciones Unidas y la República Centroafricana están etiquetadas cada una como una persona! Al mismo tiempo, "8. Juli" en el primer ejemplo está etiquetado como una organización. Resulta que las anotaciones del conjunto de datos PAN-X se generaron mediante un proceso automatizado. Este tipo de anotaciones suelen denominarse "estándar de plata" (en contraste con el "estándar de oro" de las anotaciones generadas por humanos), y no es de extrañar que haya casos en los que el enfoque automatizado falló a la hora de producir etiquetas sensatas. De hecho, estos fallos no son exclusivos de los enfoques automáticos; incluso cuando los humanos anotan cuidadosamente los datos, pueden producirse errores cuando la concentración de los anotadores se desvanece o simplemente malinterpretan la frase.

Otra cosa que hemos observado antes es que los paréntesis y las barras obtienen una pérdida relativamente alta. Veamos algunos ejemplos de secuencias con un paréntesis inicial:

df_tmp = df.loc[df["input_tokens"].apply(lambda x: u"\u2581(" in x)].head(2)
for sample in get_samples(df_tmp):
    display(sample)
0 1 2 3 4 5
fichas ▁Ham a ▁( ▁Unternehmen ▁) </s>
etiquetas B-ORG IGN I-ORG I-ORG I-ORG IGN
preds B-ORG I-ORG I-ORG I-ORG I-ORG I-ORG
pérdidas 0.01 0.00 0.01 0.01 0.01 0.00
0 1 2 3 4 5 6 7
fichas ▁Kesk kül a ▁( ▁Mart na ▁) </s>
etiquetas B-LOC IGN IGN I-LOC I-LOC IGN I-LOC IGN
preds B-LOC I-LOC I-LOC I-LOC I-LOC I-LOC I-LOC I-LOC
pérdidas 0.02 0.00 0.00 0.01 0.01 0.00 0.01 0.00

En general, no incluiríamos los paréntesis y su contenido como parte de la entidad con nombre, pero parece que así es como la extracción automática anotó los documentos. En los otros ejemplos, los paréntesis contienen una especificación geográfica. Aunque esto también es una ubicación, es posible que queramos desconectarlo de la ubicación original en las anotaciones. Este conjunto de datos está formado por artículos de Wikipedia en diferentes idiomas, y los títulos de los artículos suelen contener algún tipo de explicación entre paréntesis. Por ejemplo, en el primer ejemplo el texto entre paréntesis indica que Hama es una "Unternehmen", o empresa en inglés. Se trata de detalles importantes que debemos conocer cuando pongamos en marcha el modelo, ya que pueden tener implicaciones en el rendimiento posterior de toda la cadena de la que forma parte el modelo.

Con un análisis relativamente sencillo, hemos identificado algunos puntos débiles tanto en nuestro modelo como en el conjunto de datos. En un caso de uso real, repetiríamos este paso, limpiando el conjunto de datos, volviendo a entrenar el modelo y analizando los nuevos errores hasta que estuviéramos satisfechos con el rendimiento.

Aquí analizamos los errores en una sola lengua, pero también nos interesa el rendimiento entre lenguas. En la próxima sección realizaremos algunos experimentos para ver lo bien que funciona la transferencia entre lenguas en XLM-R.

Transferencia multilingüe

Ahora que hemos afinado XLM-R en alemán, podemos evaluar su capacidad de transferencia a otras lenguas mediante el método predict() de laTrainer. Como tenemos previsto evaluar varias lenguas, vamos a crear una función sencilla que lo haga por nosotros:

def get_f1_score(trainer, dataset):
    return trainer.predict(dataset).metrics["test_f1"]

Podemos utilizar esta función para examinar el rendimiento en el conjunto de pruebas y llevar un registro de nuestras puntuaciones en dict:

f1_scores = defaultdict(dict)
f1_scores["de"]["de"] = get_f1_score(trainer, panx_de_encoded["test"])
print(f"F1-score of [de] model on [de] dataset: {f1_scores['de']['de']:.3f}")
F1-score of [de] model on [de] dataset: 0.868

Son resultados bastante buenos para una tarea NER. Nuestras métricas están en torno al 85%, y podemos ver que el modelo parece tener más dificultades con las entidades de ORG, probablemente porque son las menos comunes en los datos de entrenamiento y muchos nombres de organizaciones son raros en el vocabulario de XLM-R. ¿Qué ocurre con los demás idiomas? Para entrar en calor, veamos cómo le va al francés a nuestro modelo afinado en alemán:

text_fr = "Jeff Dean est informaticien chez Google en Californie"
tag_text(text_fr, tags, trainer.model, xlmr_tokenizer)
0 1 2 3 4 5 6 7 8 9 10 11 12 13
Fichas <s> ▁Jeff ▁De un ▁más ▁informática ien ▁chez ▁Google ▁en ▁Cali para nie </s>
Etiquetas O B-PER I-PER I-PER O O O O B-ORG O B-LOC I-LOC I-LOC O

¡No está mal! Aunque el nombre y la organización son los mismos en ambos idiomas, el modelo consiguió etiquetar correctamente la traducción francesa de "Kalifornien". A continuación, vamos a cuantificar lo bien que le va a nuestro modelo alemán en el conjunto de pruebas francés escribiendo una función sencilla que codifique un conjunto de datos y genere el informe de clasificación sobre él:

def evaluate_lang_performance(lang, trainer):
    panx_ds = encode_panx_dataset(panx_ch[lang])
    return get_f1_score(trainer, panx_ds["test"])
f1_scores["de"]["fr"] = evaluate_lang_performance("fr", trainer)
print(f"F1-score of [de] model on [fr] dataset: {f1_scores['de']['fr']:.3f}")
F1-score of [de] model on [fr] dataset: 0.714

Aunque vemos una caída de unos 15 puntos en las métricas micromediadas, ¡recuerda que nuestro modelo no ha visto ni un solo ejemplo francés etiquetado! En general, la magnitud de la caída del rendimiento está relacionada con lo "alejadas" que estén las lenguas entre sí. Aunque el alemán y el francés se agrupan como lenguas indoeuropeas, técnicamente pertenecen a familias lingüísticas diferentes: Germánica y Románica, respectivamente.

A continuación, evaluemos el rendimiento en italiano. Como el italiano también es una lengua románica, esperamos obtener un resultado similar al que obtuvimos con el francés:

f1_scores["de"]["it"] = evaluate_lang_performance("it", trainer)
print(f"F1-score of [de] model on [it] dataset: {f1_scores['de']['it']:.3f}")
F1-score of [de] model on [it] dataset: 0.692

De hecho, nuestras expectativas se ven confirmadas por laspuntuaciones F1. Por último, examinemos el rendimiento en inglés, que pertenece a la familia de las lenguas germánicas:

f1_scores["de"]["en"] = evaluate_lang_performance("en", trainer)
print(f"F1-score of [de] model on [en] dataset: {f1_scores['de']['en']:.3f}")
F1-score of [de] model on [en] dataset: 0.589

Sorprendentemente, a nuestro modelo le va peor en inglés, aunque intuitivamente podríamos esperar que el alemán fuera más parecido al inglés que el francés. Después de haber afinado en alemán y realizado la transferencia de tiro cero al francés y al inglés, examinemos a continuación cuándo tiene sentido afinar directamente en la lengua meta.

¿Cuándo tiene sentido la transferencia a tiro cero?

Hasta ahora hemos visto que el ajuste fino de XLM-R en el corpus alemán produce unapuntuación F1 de alrededor del 85%, y sin ningún entrenamiento adicional el modelo es capaz de lograr un rendimiento modesto en los demás idiomas de nuestro corpus. La pregunta es: ¿hasta qué punto son buenos estos resultados y cómo se comparan con los de un modelo XLM-R ajustado a un corpus monolingüe?

En esta sección exploraremos esta cuestión para el corpus francés ajustando XLM-R en conjuntos de entrenamiento de tamaño creciente. Siguiendo el rendimiento de esta forma, podemos determinar en qué punto la transferencia multilingüe sin disparos es superior, lo que en la práctica puede ser útil para orientar las decisiones sobre si recopilar más datos etiquetados.

Para simplificar, mantendremos los mismos hiperparámetros de la ejecución del ajuste fino en el corpus alemán, salvo que modificaremos el argumento logging_steps de Training​Ar⁠guments para tener en cuenta los cambios en el tamaño del conjunto de entrenamiento. Podemos envolver todo esto en una sencilla función que tome un objeto DatasetDict correspondiente a un corpus monolingüe, lo reduzca en num_samples y ajuste XLM-R en esa muestra para obtener las métricas de la mejor época:

def train_on_subset(dataset, num_samples):
    train_ds = dataset["train"].shuffle(seed=42).select(range(num_samples))
    valid_ds = dataset["validation"]
    test_ds = dataset["test"]
    training_args.logging_steps = len(train_ds) // batch_size

    trainer = Trainer(model_init=model_init, args=training_args,
        data_collator=data_collator, compute_metrics=compute_metrics,
        train_dataset=train_ds, eval_dataset=valid_ds, tokenizer=xlmr_tokenizer)
    trainer.train()
    if training_args.push_to_hub:
        trainer.push_to_hub(commit_message="Training completed!")

    f1_score = get_f1_score(trainer, test_ds)
    return pd.DataFrame.from_dict(
        {"num_samples": [len(train_ds)], "f1_score": [f1_score]})

Como hicimos con el ajuste fino en el corpus alemán, también tenemos que codificar el corpus francés en ID de entrada, máscaras de atención e ID de etiqueta:

panx_fr_encoded = encode_panx_dataset(panx_ch["fr"])

A continuación vamos a comprobar que nuestra función funciona ejecutándola en un pequeño conjunto de entrenamiento de 250 ejemplos:

training_args.push_to_hub = False
metrics_df = train_on_subset(panx_fr_encoded, 250)
metrics_df
número_muestras puntuación_f1
0 250 0.137329

Podemos ver que con sólo 250 ejemplos, el ajuste fino en francés tiene un rendimiento muy inferior a la transferencia de cero del alemán. Aumentemos ahora el tamaño de nuestro conjunto de entrenamiento a 500, 1.000, 2.000 y 4.000 ejemplos para hacernos una idea de cómo aumenta el rendimiento:

for num_samples in [500, 1000, 2000, 4000]:
    metrics_df = metrics_df.append(
        train_on_subset(panx_fr_encoded, num_samples), ignore_index=True)

Podemos comparar el ajuste fino de las muestras francesas con la transferencia multilingüe cero del alemán trazando laspuntuaciones F1 en el conjunto de pruebas en función del aumento del tamaño del conjunto de entrenamiento:

fig, ax = plt.subplots()
ax.axhline(f1_scores["de"]["fr"], ls="--", color="r")
metrics_df.set_index("num_samples").plot(ax=ax)
plt.legend(["Zero-shot from de", "Fine-tuned on fr"], loc="lower right")
plt.ylim((0, 1))
plt.xlabel("Number of Training Samples")
plt.ylabel("F1 Score")
plt.show()

En el gráfico podemos ver que la transferencia de cero disparos sigue siendo competitiva hasta unos 750 ejemplos de entrenamiento, después de lo cual el ajuste fino en francés alcanza un nivel de rendimiento similar al que obtuvimos al ajustar en alemán. Sin embargo, ¡este resultado no es nada despreciable! Según nuestra experiencia, conseguir que los expertos del dominio etiqueten incluso cientos de documentos puede ser costoso, sobre todo para la NER, en la que el proceso de etiquetado es minucioso y requiere mucho tiempo.

Hay una última técnica que podemos probar para evaluar el aprendizaje multilingüe: ¡el ajuste fino en varias lenguas a la vez! Veamos cómo podemos hacerlo.

Ajuste fino en varias lenguas a la vez

Hasta ahora hemos visto que la transferencia de cero idiomas del alemán al francés o al italiano produce una caída de unos 15 puntos en el rendimiento. Una forma de mitigarlo es afinar en varios idiomas al mismo tiempo. Para ver qué tipo de ganancias podemos obtener, utilicemos primero la función concatenate_datasets() denlpt_pin01 Datasets para concatenar los corpus alemán y francés:

from datasets import concatenate_datasets

def concatenate_splits(corpora):
    multi_corpus = DatasetDict()
    for split in corpora[0].keys():
        multi_corpus[split] = concatenate_datasets(
            [corpus[split] for corpus in corpora]).shuffle(seed=42)
    return multi_corpus
panx_de_fr_encoded = concatenate_splits([panx_de_encoded, panx_fr_encoded])

Para el entrenamiento, volveremos a utilizar los mismos hiperparámetros de las secciones anteriores, por lo que podemos actualizar simplemente los pasos de registro, el modelo y los conjuntos de datos en el entrenador:

training_args.logging_steps = len(panx_de_fr_encoded["train"]) // batch_size
training_args.push_to_hub = True
training_args.output_dir = "xlm-roberta-base-finetuned-panx-de-fr"

trainer = Trainer(model_init=model_init, args=training_args,
    data_collator=data_collator, compute_metrics=compute_metrics,
    tokenizer=xlmr_tokenizer, train_dataset=panx_de_fr_encoded["train"],
    eval_dataset=panx_de_fr_encoded["validation"])

trainer.train()
trainer.push_to_hub(commit_message="Training completed!")

Veamos cómo funciona el modelo en el conjunto de pruebas de cada lengua:

for lang in langs:
    f1 = evaluate_lang_performance(lang, trainer)
    print(f"F1-score of [de-fr] model on [{lang}] dataset: {f1:.3f}")
F1-score of [de-fr] model on [de] dataset: 0.866
F1-score of [de-fr] model on [fr] dataset: 0.868
F1-score of [de-fr] model on [it] dataset: 0.815
F1-score of [de-fr] model on [en] dataset: 0.677

Su rendimiento en la división francesa es mucho mejor que antes, igualando el rendimiento en el conjunto de pruebas alemán. Curiosamente, su rendimiento en las divisiones italiana e inglesa también mejora en unos 10 puntos. Así que, incluso añadiendo datos de entrenamiento en otra lengua, mejora el rendimiento del modelo en lenguas desconocidas.

Completemos nuestro análisis comparando el rendimiento del ajuste fino en cada lengua por separado con el aprendizaje multilingüe en todos los corpus. Como ya hemos afinado en el corpus alemán, podemos afinar en las demás lenguas con nuestra función train_on_subset(), con num_samples igual al número de ejemplos del conjunto de entrenamiento:

corpora = [panx_de_encoded]

# Exclude German from iteration
for lang in langs[1:]:
    training_args.output_dir = f"xlm-roberta-base-finetuned-panx-{lang}"
    # Fine-tune on monolingual corpus
    ds_encoded = encode_panx_dataset(panx_ch[lang])
    metrics = train_on_subset(ds_encoded, ds_encoded["train"].num_rows)
    # Collect F1-scores in common dict
    f1_scores[lang][lang] = metrics["f1_score"][0]
    # Add monolingual corpus to list of corpora to concatenate
    corpora.append(ds_encoded)

Ahora que hemos afinado los corpus de cada lengua, el siguiente paso es concatenar todas las divisiones para crear un corpus multilingüe de las cuatro lenguas. Como en el análisis anterior del alemán y el francés, podemos utilizar la función concatenate_splits()para que realice este paso por nosotros en la lista de corpus que generamos en el paso anterior:

corpora_encoded = concatenate_splits(corpora)

Ahora que tenemos nuestro corpus multilingüe, ejecutamos los pasos conocidos con el entrenador:

training_args.logging_steps = len(corpora_encoded["train"]) // batch_size
training_args.output_dir = "xlm-roberta-base-finetuned-panx-all"

trainer = Trainer(model_init=model_init, args=training_args,
    data_collator=data_collator, compute_metrics=compute_metrics,
    tokenizer=xlmr_tokenizer, train_dataset=corpora_encoded["train"],
    eval_dataset=corpora_encoded["validation"])

trainer.train()
trainer.push_to_hub(commit_message="Training completed!")

El último paso es generar las predicciones del entrenador sobre el conjunto de pruebas de cada lengua. Esto nos dará una idea de lo bien que funciona realmente el aprendizaje multilingüe. Recopilaremos laspuntuaciones F1 en nuestro diccionario f1_scores y luego crearemos un DataFrame que resuma los principales resultados de nuestros experimentos multilingües:

for idx, lang in enumerate(langs):
    f1_scores["all"][lang] = get_f1_score(trainer, corpora[idx]["test"])
scores_data = {"de": f1_scores["de"],
               "each": {lang: f1_scores[lang][lang] for lang in langs},
               "all": f1_scores["all"]}
f1_scores_df = pd.DataFrame(scores_data).T.round(4)
f1_scores_df.rename_axis(index="Fine-tune on", columns="Evaluated on",
                         inplace=True)
f1_scores_df
Evaluado en de fr it en
Afinar en
de 0.8677 0.7141 0.6923 0.5890
cada 0.8677 0.8505 0.8192 0.7068
todos 0.8682 0.8647 0.8575 0.7870

De estos resultados podemos extraer algunas conclusiones generales:

  • El aprendizaje multilingüe puede proporcionar ganancias significativas en el rendimiento, sobre todo si las lenguas de bajo recurso para la transferencia interlingüística pertenecen a familias lingüísticas similares. En nuestros experimentos podemos ver que el alemán, el francés y el italiano consiguen un rendimiento similar en la categoría all, lo que sugiere que estas lenguas son más parecidas entre sí que al inglés.

  • Como estrategia general, es una buena idea centrar la atención en la transferencia interlingüística dentro de las familias lingüísticas, sobre todo cuando se trata de escrituras diferentes, como el japonés.

Interactuar con los widgets del modelo

En este capítulo, hemos empujado bastantes modelos ajustados al Hub. Aunque podríamos utilizar la función pipeline() para interactuar con ellos en nuestra máquina local, Hub proporciona widgets que son estupendos para este tipo de flujo de trabajo. En laFigura 4-5 se muestra un ejemplo de nuestro punto de controltransformersbook/xlm-roberta-base-finetuned-panx-all, que, como puedes ver, ha hecho un buen trabajo identificando todas las entidades de un texto alemán.

A Hub widget
Figura 4-5. Ejemplo de widget en el Hub Cara Abrazada

Conclusión

En este capítulo hemos visto cómo abordar una tarea de PLN en un corpus multilingüe utilizando un único transformador preentrenado en 100 lenguas: XLM-R. Aunque pudimos demostrar que la transferencia multilingüe del alemán al francés es competitiva cuando sólo se dispone de un pequeño número de ejemplos etiquetados para el ajuste fino, este buen rendimiento generalmente no se produce si la lengua de destino es significativamente diferente de aquella en la que se ajustó el modelo base o no era una de las 100 lenguas utilizadas durante el preentrenamiento. Propuestas recientes como MAD-X están diseñadas precisamente para estos escenarios de bajos recursos, y como MAD-X está construido sobrenlpt_pin01 Transformers, ¡puedes adaptar fácilmente el código de este capítulo para trabajar con él!6

Hasta ahora hemos examinado dos tareas: la clasificación de secuencias y la clasificación de tokens. Ambas pertenecen al dominio de la comprensión del lenguaje natural, donde el texto se sintetiza en predicciones. En el próximo capítulo veremos por primera vez la generación de texto, en la que no sólo la entrada, sino también la salida del modelo es texto.

1 A. Conneau et al., "Unsupervised Cross-Lingual Representation Learning at Scale",(2019).

2 J. Hu y otros, "XTREME: A Massively Multilingual Multi-Task Benchmark for Evaluating Cross-Lingual Generalization",(2020); X. Pan y otros, "Cross-Lingual Name Tagging and Linking for 282 Languages", Actas de la 55ª Reunión Anual de la Asociación de Lingüística Computacional 1 (julio de 2017): 1946–1958, http://dx.doi.org/10.18653/v1/P17-1178.

3 Y. Liu y otros, "RoBERTa: A Robustly Optimized BERT Pretraining Approach",(2019).

4 T. Kudo y J. Richardson, "SentencePiece: A Simple and Language Independent Subword Tokenizer and Detokenizer for Neural Text Processing",(2018).

5 J. Devlin et al., "BERT: Preentrenamiento de Transformadores Bidireccionales Profundos para la Comprensión del Lenguaje",(2018).

6 J. Pfeiffer y otros, "MAD-X: An Adapter-Based Framework for Multi-Task Cross-Lingual Transfer",(2020).

Get Procesamiento del Lenguaje Natural con Transformadores, Edición Revisada now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.