DEV Community

Cover image for Cómo etiquetar texto con un LLM local para que solo responda con tus etiquetas

Cómo etiquetar texto con un LLM local para que solo responda con tus etiquetas

Etiquetar filas con un modelo de lenguaje parece la tarea más fácil que se le puede dar. Envías cada fila con una instrucción del tipo "queja, elogio o pregunta, solo la etiqueta", guardas lo que vuelve y cuentas. La acción Run Model de la pantalla Datasets de la aplicación de escritorio osFoundry funciona así: BatchInferenceDialog.tsx envía cada fila por separado como la instrucción, una línea en blanco y el texto de la fila, y guarda la respuesta como una columna nueva. A continuación, ese bucle se reconstruye en Python con mlx-lm y se ejecuta sobre 312 reseñas de una tienda cuyas etiquetas correctas se conocen, para ver qué devuelve en realidad. Un modelo de 3B acertó casi todas las reseñas. Y aun así escribió las etiquetas de seis formas distintas.

El montaje

  • Datos: 312 reseñas cortas de una pequeña tienda de cerámica, generadas por un script a partir de listas de frases, así que la etiqueta de cada reseña se conoce de antemano: 181 praise, 87 complaint, 44 question. Un conjunto en inglés y otro en japonés con la misma proporción. Son sintéticas, lo que las hace más fáciles que las reseñas reales; lo que interesa aquí es la forma de las respuestas, no un benchmark.
  • Modelos: Qwen2.5 3B Instruct y Qwen2.5 0.5B Instruct, ambos en versiones MLX de 4 bits de mlx-community.
  • Máquina: Apple M1 con 8 GB de memoria, mlx-lm 0.32.2, mlx 0.32.4, decodificación greedy.

El bucle es el mismo que el de la aplicación: un mensaje de usuario por fila, con la instrucción primero.

import csv
from mlx_lm import load, generate

PROMPT = "Label this customer review as complaint, praise or question. Output only the label."
model, tok = load("mlx-community/Qwen2.5-3B-Instruct-4bit")

def prompt_for(text):
    msgs = [{"role": "user", "content": PROMPT + "\n\n" + text}]
    return tok.apply_chat_template(msgs, add_generation_prompt=True, tokenize=False)

rows = list(csv.DictReader(open("reviews.csv")))
answers = [generate(model, tok, prompt_for(r["review"]), max_tokens=16) for r in rows]
Enter fullscreen mode Exit fullscreen mode

Respuesta correcta, seis grafías

Las respuestas del modelo de 3B para el conjunto en inglés, contadas tal como las escribió:

praise      138
Complaint    48
Praise       42
complaint    39
question     26
Question     19
Enter fullscreen mode Exit fullscreen mode

Seis valores distintos. Comparadas de forma exacta con las etiquetas reales, 202 de 312 son correctas. En minúsculas, 311 son correctas; el único fallo es "Perfect. My coffee tastes better in it. Probably. Will be back.", que clasificó como pregunta. Es decir, el modelo entendió la tarea casi a la perfección y aun así produjo una columna en la que un conteo simple o un filtro por complaint se equivoca en más de un tercio. La instrucción no tenía nada ambiguo: listaba las etiquetas en minúscula y pedía solo la etiqueta. Las mayúsculas vienen de la costumbre del modelo de empezar la respuesta con una, y lo hace en unas filas sí y en otras no.

La comprobación barata es el número de valores distintos en la columna nueva. Debería ser igual al número de etiquetas. Si es mayor, mira los valores sobrantes antes de contar nada.

El arreglo barato es normalizar antes de comparar:

def norm(s):
    return s.strip().strip("\"'「」.。::*` ").lower()
Enter fullscreen mode Exit fullscreen mode

Con eso basta para mayúsculas y puntos finales. No basta para el conjunto en japonés.

Una etiqueta que no es una palabra

Con la instrucción en japonés (不満・満足・質問のどれか, ラベルだけ), el mismo modelo de 3B dio cuatro valores distintos: 満足 178, 不満 87, 質問 45 y 不溝 dos veces. 不溝 no es una etiqueta, ni una palabra que alguien escribiría aquí. Normalizar no puede llevarlo a ningún sitio.

El tokenizador explica de dónde sale. El tokenizador de Qwen2.5 trabaja con bytes, y 満 no es un solo token:

不満  -> [16530, 35017, 222]    '不' + two byte pieces of 満
満足  -> [35017, 222, 99336]    two byte pieces of 満 + '足'
不溝  -> [16530, 35017, 251]    '不' + the same first piece + a different last byte
Enter fullscreen mode Exit fullscreen mode

満 y 溝 comparten sus primeros bytes en UTF-8, así que el modelo escribió 不, luego el trozo de bytes compartido, y después eligió mal el último byte. Las dos filas eran elogios según las etiquetas reales, y las dos empiezan por プレゼント用のはずが、自分で使っています ("lo compré para regalar y acabé usándolo yo"), así que el modelo ya dudaba.

Que elija en lugar de escribir

Si la respuesta tiene que ser una de tres cadenas, no hace falta dejar que el modelo escriba. Pasa el prompt una vez, mira las log-probabilidades que asigna al siguiente token y compara solo el primer token de cada etiqueta. Aquí los tres primeros tokens son distintos (com, pr, question en inglés; 不, el trozo de bytes de 満 y 質 en japonés), así que un solo paso decide:

from mlx_lm import stream_generate

LABELS = ["complaint", "praise", "question"]
first = [tok.encode(l)[0] for l in LABELS]
assert len(set(first)) == len(LABELS)

def choose(text):
    step = next(stream_generate(model, tok, prompt_for(text), max_tokens=1))
    scores = [step.logprobs[t].item() for t in first]
    return LABELS[scores.index(max(scores))], scores
Enter fullscreen mode Exit fullscreen mode

Si dos etiquetas comparten el primer token, puntúa la etiqueta completa: añade los tokens de cada etiqueta al prompt y suma sus log-probabilidades.

Las dos versiones se ejecutaron una detrás de otra para cada idioma, en la misma máquina:

Modelo 3B valores distintos correctas tal cual correctas tras norm tiempo para 312 filas
Inglés, generar 6 202 311 113.2 s
Inglés, elegir 3 311 311 94.5 s
Japonés, generar 4 308 308 146.4 s
Japonés, elegir 3 308 308 117.5 s

Elegir da exactamente tres valores cada vez, no necesita limpieza y es algo más rápido, porque se detiene tras un paso en lugar de generar la etiqueta y el token de fin de turno. No hace al modelo más listo: 311 y 308 aciertos en ambos casos.

Lo que esconde elegir

Las dos filas con 不溝 muestran el precio. En la ejecución que genera, estaban visiblemente rotas. En la que elige, el primer token era 不, así que ambas se convirtieron en 不満: incorrectas, pero imposibles de distinguir de las 87 quejas reales. Una respuesta inválida era un aviso gratis, y forzar una válida lo elimina.

Las puntuaciones devuelven el aviso. La diferencia entre la log-probabilidad de la mejor etiqueta y la de la segunda dice lo ajustada que fue la decisión, y ordenar por ella pone primero las filas dudosas:

label, s = choose(text)
top, second = sorted(s, reverse=True)[:2]
margin = top - second   # small = the model nearly picked another label
Enter fullscreen mode Exit fullscreen mode

En la ejecución en inglés, la única fila incorrecta tenía el margen más pequeño de las 312 (0.97; el siguiente más pequeño era 3.06). En la ejecución en japonés, 9 filas tenían un margen menor que 2, y 3 de las 4 filas incorrectas estaban entre ellas. Leer 9 filas de 312 es una cantidad de revisión razonable para un trabajo por lotes; leer las 312 es justo lo que el modelo debía ahorrarte.

A un modelo pequeño no lo salva nada

El modelo de 0.5B muestra los límites de todo esto. En el conjunto en inglés respondió "Complaint" a las 312 reseñas; al elegir dio "complaint" a las 312. En el conjunto en japonés, generar dio 206 valores distintos, casi todos frases sobre la reseña en lugar de una etiqueta, y elegir dio solo 不満 y 満足, con 110 de 312 aciertos. Un solo valor distinto es un aviso tan fuerte como seis: una columna de etiquetas con un único valor no ha clasificado nada.

De vuelta en la aplicación

El script completo, con el conteo y una matriz de confusión, tiene menos de 70 líneas. En la aplicación osFoundry, Run Model en la pantalla Datasets hace la parte por lotes con un modelo en tu ordenador, y Show Quality Stats sobre el resultado muestra el número de valores distintos de cada columna, que es la primera comprobación de arriba.

Quality Stats en osFoundry para un dataset etiquetado, con 3 valores únicos en la columna label (datos de ejemplo)

Ejecutar los propios modelos se explica en la página sobre inferencia de LLM en local con osFoundry, y la aplicación de escritorio está en la página de descarga de osFoundry.

Top comments (0)

⌂ 换网址