Hugging Face · LLM Course · Capítulo 10
Curar datasets de alta calidad con Argilla
Uno de los capítulos agregados cuando el curso pasó de "NLP Course" a "LLM Course" — y se nota: convertir juicio humano en un dataset de entrenamiento es, hoy, tan central como el fine-tuning mismo.
Antes de leercap. 5 §4, crear un dataset propio · cap. 3, para qué sirven los datos curados
🎯 Al terminar vas a poder
- Explicar con tus palabras la separación entre Fields y Questions en Argilla, y reconocerla como la misma separación input/target de cualquier dataset de entrenamiento.
- Definir un dataset con la API v2 de Argilla (
Settings,Record,Suggestion,TaskDistribution) y exportarlo filtrado a unDatasetde Hugging Face o al Hub. - Calcular e interpretar el kappa de Cohen entre dos anotadores, y usarlo para decidir cuándo una guía de anotación todavía no está lista.
- Diagnosticar un dataset de SFT antes de entrenarlo: duplicados, longitudes fuera de rango y contaminación con los benchmarks con los que después lo vas a evaluar.
1. Por qué curar tu propio dataset
El Hub tiene datasets enormes para casi cualquier tarea genérica — pero "genérico" es exactamente el problema cuando tu caso de uso no lo es. Argilla es la herramienta de Hugging Face para convertir datos sin estructurar en un dataset etiquetado a propósito: pasar de "un montón de texto" a "ejemplos con la etiqueta, el span, o el rating que tu modelo necesita para aprender lo que vos querés", con humanos (o un equipo) en el loop.
Hay una segunda razón, menos obvia: en SFT (Capítulo 11) la calidad de los ejemplos pesa más que la cantidad. LIMA, un trabajo de 2023, mostró que unos mil ejemplos elegidos con cuidado alcanzaban para que un modelo base de 65B siguiera instrucciones de forma competitiva — el modelo ya sabía casi todo por el preentrenamiento; lo que aprendía en SFT era el formato y el estilo de responder. Eso cambia el cálculo: mil ejemplos revisados a mano por vos valen más que cien mil scrapeados, y anotar mil ejemplos es un trabajo de días, no de meses. Este capítulo es la herramienta para hacerlo sin que se te desordene.
El ciclo completo que vas a ver es siempre el mismo — generar o recolectar → anotar → medir acuerdo → filtrar → exportar → entrenar → evaluar → volver a anotar lo que salió mal — y Argilla cubre el tramo del medio. Lo que hay a los costados (de dónde salen los datos, cómo se limpian antes de entrenar) lo tratamos en §6.
2. Levantar Argilla
Argilla se despliega como un Space de Hugging Face (con almacenamiento persistente si no querés perder el trabajo de anotación al reiniciar), y se controla desde Python con su SDK:
!pip install argilla
import argilla as rg
client = rg.Argilla(api_url="https://tu-espacio.hf.space", api_key="...") # la API key que configuraste al crear el Space
client.me # confirma que la conexión funciona
Dos detalles que ahorran un mal rato. Primero: el almacenamiento persistente del Space es un add-on pago; sin él, cada reinicio del Space (por inactividad, por un cambio de configuración) borra la base de datos con todas las anotaciones. Si el proyecto va a durar más de una tarde, activalo antes de que alguien anote. Segundo: Argilla también corre local con Docker (la imagen oficial trae Elasticsearch adentro) — es la opción cuando los datos no pueden salir de tu máquina, que en un proyecto con textos financieros o clínicos es lo normal.
📜 Lo que decía el original
El curso está escrito contra la versión 2 del SDK de Argilla, que es la que
usamos acá. Si encontrás tutoriales con rg.init(...), FeedbackDataset
o rg.log(...), son de la versión 1 — la API cambió entera. El concepto es el mismo;
los nombres no.
3. Cargar y configurar el dataset
Un dataset en Argilla se define en dos partes: Fields (lo que el anotador ve — el texto del artículo, la imagen, el audio) y Questions (lo que le pedís que responda — una etiqueta de una lista, un span de texto para NER, un rating). Separar "qué se muestra" de "qué se pide" es lo que permite reusar el mismo dataset base para tareas de anotación completamente distintas.
Los Settings agrupan fields, questions, las guías de anotación y la distribución del
trabajo (§4). Un ejemplo concreto — clasificar titulares financieros por tono, con un campo de
comentario libre para que el anotador deje una nota cuando el caso es dudoso:
settings = rg.Settings(
guidelines="Clasificá el TONO del titular hacia la empresa, no el hecho. Ver casos límite abajo.",
fields=[rg.TextField(name="texto", title="Titular")],
questions=[
rg.LabelQuestion(name="categoria", labels=["positivo", "negativo", "neutro"]),
rg.TextQuestion(name="comentario", required=False),
],
distribution=rg.TaskDistribution(min_submitted=2), # dos anotadores por registro
)
dataset = rg.Dataset(name="titulares-finanzas", settings=settings)
dataset.create()
Las preguntas disponibles cubren casi cualquier forma de "juicio humano" que quieras capturar:
| Question | Qué captura | Ejemplo de uso |
|---|---|---|
LabelQuestion | Una etiqueta de una lista cerrada | Tono: positivo / negativo / neutro |
MultiLabelQuestion | Varias etiquetas a la vez | Temas de un artículo |
RatingQuestion | Un entero en una escala (values=[1,2,3,4,5]) | Calidad de una respuesta generada |
TextQuestion | Texto libre | Corregir la respuesta del modelo; dejar una nota |
SpanQuestion | Fragmentos de un field, etiquetados | NER: marcar empresa, monto, fecha |
RankingQuestion | Un orden entre opciones | Preferencia entre dos o más respuestas (datos para DPO) |
Los registros se cargan como rg.Record. Lo importante está en suggestions: la
predicción de un modelo ya existente, precargada, para que anotar sea corregir en vez de
empezar de cero. Cuando el modelo base ya acierta la mayoría de los casos fáciles, el anotador solo
gasta atención en los difíciles:
registros = [
rg.Record(
fields={"texto": fila["texto"]},
suggestions=[rg.Suggestion(question_name="categoria", value=fila["prediccion"])],
metadata={"fuente": fila["fuente"]}, # útil para filtrar después; declaralo en Settings si vas a filtrar por él
)
for fila in datos
]
dataset.records.log(registros)
Si ya tenés un Dataset de la librería datasets (Capítulo 5), no hace falta
construir los Record uno por uno: dataset.records.log() acepta también
el dataset directamente, con un mapping que dice qué columna va a qué field o sugerencia.
Y si corrés el script dos veces, dataset.create() va a fallar porque el nombre ya existe —
recuperalo con client.datasets(name="titulares-finanzas") en vez de crearlo de nuevo.
4. Anotar en equipo: guías, distribución y acuerdo
Si más de una persona anota, dos cosas evitan que el dataset final quede inconsistente: unas guías de anotación escritas de antemano (qué cuenta como "positivo" en un caso límite, no dejarlo a criterio de cada uno), y la configuración de distribución — cuántas respuestas independientes necesita cada registro antes de darlo por resuelto, útil para medir acuerdo entre anotadores en los casos ambiguos.
La UI de Argilla permite precargar sugerencias (por ejemplo, la predicción de un modelo ya existente) para que anotar sea corregir en vez de empezar de cero — acelera mucho cuando el modelo base ya acierta la mayoría de los casos fáciles.
Las guías se escriben iterando, no de una vez
Nadie escribe una buena guía de anotación en abstracto. La versión 0 es siempre demasiado general ("positivo si el titular es favorable a la empresa") y se rompe con el primer caso real: "Acme recorta 2.000 puestos; la acción sube 8 %" — ¿favorable a la empresa o al empleado? ¿Tono o hecho? El procedimiento que funciona es concreto:
- Escribís la guía v0 en cinco líneas.
- Dos personas anotan las mismas 30–50 muestras, sin hablar entre sí.
- Medís el acuerdo (abajo) y listás todos los desacuerdos.
- Cada desacuerdo se discute una vez y se convierte en una regla escrita con el ejemplo textual: "Titulares que mezclan una noticia negativa para terceros con una positiva para la acción →
positivo; anotamos el tono hacia la empresa cotizante". - Se repite con otras 30–50 muestras nuevas hasta que el acuerdo se estabiliza.
Las muestras de calibración quedan como un gold set: sirven para incorporar a un anotador nuevo (anota el gold set, comparás contra la referencia) y para detectar deriva si el equipo anota durante semanas. La guía final es una lista de casos límite reales con su resolución; el párrafo general de arriba es lo de menos.
Medir el acuerdo: kappa de Cohen
"Los dos anotadores coincidieron en el 80 % de los registros" suena bien, pero no dice nada solo: si el 90 % de los titulares son neutros, dos personas que marquen todo como neutro coinciden en el 90 % sin haber leído nada. El kappa de Cohen corrige exactamente eso: compara el acuerdo observado con el acuerdo que habría por azar dadas las frecuencias con que cada anotador usa cada etiqueta.
En palabras: kappa = (acuerdo observado − acuerdo esperado por azar) / (1 − acuerdo esperado por azar). El denominador es "cuánto margen había para mejorar sobre el azar"; el numerador, cuánto de ese margen efectivamente se usó. Un ejemplo chico, con dos etiquetas y 100 registros:
- Coincidieron en 80 de 100 → acuerdo observado
p_o = 0,80. - El anotador A marcó "positivo" en 60 registros (60 %); el B, en 50 (50 %).
- Probabilidad de que ambos digan "positivo" por azar:
0,60 × 0,50 = 0,30. De que ambos digan "negativo":0,40 × 0,50 = 0,20. Acuerdo esperadop_e = 0,30 + 0,20 = 0,50. kappa = (0,80 − 0,50) / (1 − 0,50) = 0,60.
Es decir: de un 80 % de acuerdo bruto, la mitad ya la explicaba el azar. La escala de referencia habitual (la de Landis y Koch, orientativa y no una ley): por debajo de 0,20 el acuerdo es prácticamente nulo; 0,21–0,40 débil; 0,41–0,60 moderado; 0,61–0,80 sustancial; más de 0,80 casi perfecto. Para un dataset de entrenamiento, con menos de 0,6 lo que tenés que arreglar es la guía, no el modelo — el modelo va a aprender el ruido de los anotadores.
📐 Formulación matricial de Cohen y generalización a Fleiss para múltiples anotadores
Dada una matriz de confusión entre dos anotadores sobre muestras, la probabilidad de acuerdo observado y la probabilidad esperada por independencia marginal se definen como:
Kappa de Fleiss (): Cuando intervienen anotadores fijos asignando registros a categorías, sea la cantidad de evaluadores que asignaron el registro a la categoría . El grado de acuerdo sobre el registro es:
El acuerdo global observado y el acuerdo esperado por azar determinan el coeficiente:
⚠️ Cuidado
Un kappa alto puede esconder un problema: si los dos anotadores están aceptando las sugerencias del modelo sin leer, van a coincidir entre sí y con el modelo — y el dataset resultante no agrega información nueva. Medí también cuántas sugerencias fueron corregidas; si es casi ninguna, o el modelo ya resolvía la tarea o los anotadores no la están haciendo.
5. Usar el dataset anotado
Una vez anotado, filtrás por estado (completado vs. pendiente) y exportás — a un
Dataset de la librería datasets del Capítulo 5, o directo de vuelta al Hub
— cerrando el círculo: los datos que curaste a mano vuelven a ser un dataset más, listo para
tokenizar y fine-tunear como cualquier otro del curso.
# Solo los registros con al menos una respuesta enviada (no borradores ni pendientes)
consulta = rg.Query(filter=rg.Filter([("response.status", "==", "submitted")]))
hf_dataset = dataset.records(query=consulta).to_datasets() # datasets.Dataset
dataset.to_hub(repo_id="tu-usuario/titulares-finanzas") # el dataset entero, con settings, al Hub
# ...y de vuelta, en otra máquina: rg.Dataset.from_hub("tu-usuario/titulares-finanzas")
🟢 Actualización 2026
En la API v2 la exportación es dataset.records.to_datasets() (o
to_list() / to_dict()) y la subida al Hub es dataset.to_hub(),
que guarda también los settings para poder reconstruir el dataset de anotación tal cual. El
push_to_hub() que menciona el original es el método del Dataset de
datasets — lo podés usar sobre lo que devuelve to_datasets(), pero ahí
solo viaja la tabla, no la configuración de Argilla.
Lo que devuelve to_datasets() tiene, por cada pregunta, columnas separadas para las
respuestas humanas (una lista, porque con min_submitted=2 hay más de
una, más columnas con quién respondió y en qué estado) y para la sugerencia con que
se precargó el registro. Eso te deja dos decisiones que tomar en código, no en la UI:
- Resolver desacuerdos. Con dos respuestas por registro, si coinciden te quedás con esa etiqueta; si no, o lo mandás a un tercer anotador (subís
min_submittedsolo para esos) o lo descartás. Un voto de mayoría sobre dos respuestas no existe — hace falta un tercero o una regla. - Separar la sugerencia de la respuesta. La columna de sugerencia es lo que dijo el modelo; la de respuestas, lo que dijo la persona. Para entrenar se usa la segunda. Si en un registro sin respuesta usás la sugerencia "porque algo hay", estás entrenando al modelo con sus propias predicciones.
🔧 Ya armaste tu propia versión de esto, a medida
interpretante-lacaniano
resuelve exactamente este problema sin Argilla: el rating 👍/👎 de la UI en Streamlit es la
"Question" (juicio humano sobre un registro), curaduria_delegada.py y
curar_produccion.py son el paso de filtrar por calidad, y el resultado alimenta
directamente el SFT de la siguiente generación — el mismo pipeline "generar → anotar → filtrar →
reentrenar" de este capítulo, con una diferencia real: tu curaduría no es solo humana, también filtra
por novedad respecto al corpus (una señal automática que Argilla no tiene de fábrica, y que
tuviste que construir vos porque tu caso de uso — evitar que el modelo se repita a sí mismo entre
generaciones — no es un problema de anotación genérico).
6. Calidad de datos para SFT: antes de entrenar
Anotar bien no alcanza si lo que entra al SFTTrainer del Capítulo 11 tiene duplicados,
ejemplos que el modelo no va a poder ver enteros, o —lo peor— las respuestas del benchmark con el que
después vas a medir. Tres filtros que conviene correr siempre, en este orden, sobre el dataset ya
exportado:
Deduplicación
Los duplicados exactos aparecen solos (el mismo titular scrapeado de dos fuentes; el mismo prompt
generado dos veces). Los casi duplicados son más comunes y más dañinos: dos ejemplos que
difieren en una coma o en un número. El efecto en entrenamiento es doble — el modelo ve ese ejemplo
"dos épocas" mientras el resto ve una, y aprende a memorizarlo en vez de generalizar. Lee et al. (2021)
midieron esto en preentrenamiento: deduplicar reduce el texto memorizado que el modelo regurgita y
mejora la perplexity fuera de muestra, con menos pasos. Para el tamaño de un dataset de SFT alcanza
con un hash del texto normalizado (minúsculas, sin puntuación ni espacios repetidos) para los exactos,
y MinHash sobre n-gramas de palabras para los cercanos; a escala de preentrenamiento, la librería
datatrove de Hugging Face trae ambos pasos listos.
Filtrado por longitud
En el Capítulo 11 vas a fijar un max_length (por ejemplo 2048 tokens). Todo ejemplo más
largo que eso se trunca, y en un par instrucción/respuesta lo que se corta es el final —
justamente la respuesta. Medí longitudes en tokens del modelo que vas a entrenar, no
en caracteres, y descartá o resumí lo que no entra. Del otro lado, respuestas de una o dos palabras
("Sí.", "No sé.") enseñan a responder con una o dos palabras; salvo que ese sea el comportamiento
buscado, fuera. Un histograma de longitudes de prompt y de respuesta, por separado, es el gráfico
más barato y más útil que podés hacer antes de entrenar.
Contaminación con benchmarks
Si tu dataset de SFT contiene —aunque sea parafraseadas— preguntas del set de test de GSM8K, MMLU o del benchmark con el que vas a reportar, el número que obtengas después no mide nada: mide memoria. Pasa más fácil de lo que parece, porque muchos datasets públicos de instrucciones se construyeron a partir de otros datasets públicos, y las preguntas de los benchmarks circulan en foros y blogs que terminan scrapeados. La comprobación estándar es de solapamiento de n-gramas: para cada ejemplo del test set, ¿alguna ventana de 8–13 tokens consecutivos aparece textual en tu dataset de entrenamiento? Si sí, ese ejemplo del test está contaminado y hay que sacarlo del entrenamiento (o reportar el resultado con la advertencia). En el Capítulo 12 vas a entrenar con el split train de GSM8K y evaluar con el test: ese es exactamente el caso donde una fuga entre splits invalida todo.
Dos chequeos más, rápidos: si las respuestas las generó un modelo, buscá y eliminá negativas de
plantilla ("como modelo de lenguaje no puedo…") y prefijos repetidos ("¡Claro! Acá tenés…") — el
modelo los aprende con entusiasmo. Y anotá de dónde salió cada ejemplo (la metadata
de §3 sirve para eso): cuando en el Capítulo 11 la evaluación muestre que el modelo falla en un
tipo de pregunta, vas a querer saber qué fuente lo produjo.
7. Errores comunes
Casi todos se descubren tarde — cuando el modelo entrenado sobre el dataset sale peor de lo esperado — y casi todos se evitan con una hora de trabajo antes de anotar.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| Kappa por debajo de 0,4; los anotadores discuten cada caso ambiguo de nuevo. | Se anotó sin guía escrita, o con una guía general sin casos límite. | Parar. Listar los desacuerdos, convertir cada uno en una regla con ejemplo textual, reanotar una muestra nueva, medir de vuelta (§4). |
| El dataset "parece" bueno pero el modelo entrenado sobre él es inconsistente en los casos difíciles. | Un solo anotador por registro: no hay forma de saber qué etiquetas son ruido. | TaskDistribution(min_submitted=2) al menos para una muestra; tercer anotador o descarte en los desacuerdos (§5). |
Columnas de respuesta vacías (listas []) en una parte del dataset exportado. | Se exportó todo, incluidos los registros pendientes o en borrador. | Filtrar por response.status == "submitted" antes de to_datasets(), y contar filas antes y después (§5). |
| El modelo fine-tuneado reproduce exactamente los errores del modelo que generó las sugerencias. | Al exportar se usó la columna de sugerencia (o se rellenaron con ella los registros sin respuesta) en vez de la de respuestas humanas. | Entrenar solo con la columna de respuestas; tirar la de sugerencia o guardarla aparte para medir cuántas se corrigieron (§5). |
| Se perdieron todas las anotaciones después de un reinicio del Space. | Space sin almacenamiento persistente. | Activarlo antes de que alguien anote, o correr Argilla local con Docker (§2). Exportar al Hub con to_hub() como respaldo periódico. |
| Loss de SFT bajísima en train, resultados en benchmark sospechosamente buenos. | Duplicados (el modelo memoriza) o contaminación con el test set. | Dedup y chequeo de solapamiento de n-gramas contra el test antes de entrenar (§6). |
8. Resumen
- Curar tu propio dataset tiene sentido cuando lo que hay en el Hub es genérico y tu caso de uso no lo es — y para SFT, mil ejemplos revisados valen más que cien mil sin revisar.
- Argilla separa Fields (qué se muestra) de Questions (qué se pide) — la misma separación conceptual que "input" y "target" en cualquier dataset de entrenamiento. En la API v2 eso vive en
Settings, los datos entran comoRecordconSuggestionprecargadas, yTaskDistributionfija cuántas respuestas necesita cada registro. - Guías de anotación escritas de antemano y sugerencias precargadas son lo que hace viable anotar en equipo sin que el resultado quede inconsistente — y la guía se escribe iterando sobre desacuerdos reales, midiendo el acuerdo con kappa de Cohen (acuerdo observado menos el esperado por azar, sobre el margen disponible).
- Se exporta filtrando por estado (
submitted), se resuelven los desacuerdos en código y se entrena con las respuestas humanas, nunca con las sugerencias. - Antes del Capítulo 11: deduplicar, filtrar por longitud en tokens y verificar que nada del test set de tus benchmarks esté en el entrenamiento.
- El pipeline completo — generar, anotar, filtrar, reentrenar — es el mismo que ya corre en interpretante-lacaniano, con Streamlit y scripts propios en vez de Argilla.
9. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. Querés reusar los mismos 5.000 titulares para dos tareas: clasificar el tono y marcar los nombres de empresa. ¿Qué cambia y qué no en la definición del dataset?
Cambian las Questions (una LabelQuestion para el tono, una SpanQuestion para las empresas); los Fields —el titular que ve el anotador— son los mismos. Esa separación entre "qué se muestra" y "qué se pide" es lo que permite reusar la base. (§3)
2. Dos anotadores coinciden en el 92 % de los registros. ¿Alcanza para decir que la guía funciona?
No sin saber la distribución de etiquetas. Si el 90 % de los casos son "neutro", dos personas que marquen todo neutro coinciden en el 90 % sin leer. Hay que calcular kappa: si el acuerdo esperado por azar ya es alto, el 92 % bruto puede ser un kappa mediocre. (§4)
3. Calculá el kappa: 200 registros, coinciden en 150. A marcó "positivo" en 100, B en 80.
p_o = 150/200 = 0,75. Azar: ambos positivo 0,5 × 0,4 = 0,20; ambos negativo 0,5 × 0,6 = 0,30; p_e = 0,50. kappa = (0,75 − 0,50) / 0,50 = 0,50 — "moderado": la guía necesita otra vuelta sobre los desacuerdos. (§4)
4. ¿Qué pasa si exportás con to_datasets() sin filtrar y entrenás directo?
Los registros pendientes salen con la lista de respuestas vacía; según cómo armes el texto de entrenamiento, terminás con ejemplos sin etiqueta (o, peor, rellenados con la sugerencia del modelo). Filtrá por response.status == "submitted" y usá solo la columna de respuestas. (§5)
5. Con min_submitted=2, un registro tiene una respuesta "positivo" y otra "neutro". ¿Con cuál entrenás?
Con ninguna, todavía: con dos respuestas no hay mayoría. O lo mandás a un tercer anotador, o lo descartás, o lo usás como caso límite para la guía. Elegir una al azar mete ruido con etiqueta de "verdad". (§4, §5)
6. Tu dataset de SFT tiene 3.000 pares y vas a evaluar con GSM8K. ¿Qué chequeo hacés antes de entrenar y por qué en ese orden: dedup, longitud, contaminación?
Dedup primero (si no, la contaminación se cuenta varias veces y los duplicados sesgan el histograma), longitud en tokens del modelo objetivo después (lo que no entra en max_length se trunca por la respuesta), y por último solapamiento de n-gramas contra el test de GSM8K: si hay coincidencias, el score que reportes mide memoria, no razonamiento. (§6)
10. Para practicar
✏️ Ejercicio 1 — Kappa a mano, y con la librería
Calculá el kappa de Cohen sin librerías para ver la fórmula funcionando, y compará con sklearn. Después modificá b para que coincida en el mismo 80 % pero marcando casi todo "neutro", y mirá cómo cae el kappa aunque el acuerdo bruto no cambie. CPU, pip install scikit-learn opcional.
from collections import Counter
def kappa(a, b):
n = len(a)
p_o = sum(x == y for x, y in zip(a, b)) / n
fa, fb = Counter(a), Counter(b)
p_e = sum(fa[k] / n * fb[k] / n for k in set(a) | set(b))
return (p_o - p_e) / (1 - p_e)
# 100 registros: 60 positivos y 40 negativos según A; B discrepa en 20
a = ["pos"] * 60 + ["neg"] * 40
b = ["pos"] * 45 + ["neg"] * 15 + ["neg"] * 35 + ["pos"] * 5 # B: 50 positivos, coincide en 80
print(f"acuerdo bruto: {sum(x == y for x, y in zip(a, b)) / 100:.2f} kappa: {kappa(a, b):.2f}")
try:
from sklearn.metrics import cohen_kappa_score
print("sklearn:", round(cohen_kappa_score(a, b), 2))
except ImportError:
pass
acuerdo bruto: 0.80 kappa: 0.60Pista / qué esperar
Es exactamente el ejemplo de §4: A tiene 60 % de positivos, B 50 %, acuerdo 0,80, kappa 0,60. Probá a = ["neu"]*95 + ["pos"]*5 y b = ["neu"]*100: acuerdo bruto 0,95, kappa 0 — B no aportó información.
✏️ Ejercicio 2 — Los tres filtros de §6 sobre un dataset de juguete
Armá un dataset de SFT con duplicados, casi duplicados, una respuesta demasiado corta y una "pregunta de benchmark" filtrada, y aplicá dedup por texto normalizado, filtro por longitud y chequeo de solapamiento de 8-gramas contra un "test set". Solo Python estándar.
import re
def norm(s):
return re.sub(r"[^a-z0-9 ]", "", re.sub(r"\s+", " ", s.lower())).strip()
def ngramas(s, n=8):
w = norm(s).split()
return {" ".join(w[i:i+n]) for i in range(len(w) - n + 1)}
train = [
{"prompt": "¿Qué es un adapter LoRA?", "respuesta": "Dos matrices de rango bajo A y B que se suman a los pesos originales."},
{"prompt": "¿Qué es un adapter LoRA?", "respuesta": "Dos matrices de rango bajo A y B que se suman a los pesos originales."},
{"prompt": "¿Qué es un adapter LoRA?", "respuesta": "Dos matrices de rango bajo, A y B, que se suman a los pesos originales."},
{"prompt": "¿Conviene usar packing?", "respuesta": "Sí."},
{"prompt": "Natalia sold clips to 48 of her friends in April, and then she sold half as many clips in May. How many clips did Natalia sell altogether in April and May?", "respuesta": "72"},
]
test = ["Natalia sold clips to 48 of her friends in April, and then she sold half as many clips in May. How many clips did Natalia sell altogether in April and May?"]
vistos, limpio = set(), []
for ej in train:
clave = norm(ej["prompt"] + " " + ej["respuesta"])
if clave in vistos: continue # 1. dedup (exactos y casi exactos tras normalizar)
vistos.add(clave)
if len(ej["respuesta"].split()) < 3: continue # 2. longitud (acá en palabras; en serio, en tokens)
ng_test = set().union(*(ngramas(t) for t in test))
if ngramas(ej["prompt"]) & ng_test: continue # 3. contaminación
limpio.append(ej)
print(len(train), "→", len(limpio))
for ej in limpio: print("-", ej["prompt"])
5 → 1Pista / qué esperar
Queda un solo ejemplo: el duplicado exacto y el casi duplicado (solo comas) caen en el paso 1, "Sí." en el 2, y la pregunta de GSM8K en el 3. Fijate que la normalización que elimina puntuación es lo que atrapa el casi-duplicado; para variaciones más grandes hace falta MinHash. Cambiá n=8 por n=13 y comprobá que el problema de GSM8K sigue detectado.
✏️ Ejercicio 3 — Un dataset Argilla completo (necesita un servidor Argilla)
Si tenés un Space o un Argilla local: creá el dataset de §3 con min_submitted=2, cargá diez titulares con sugerencias generadas por el pipeline de clasificación del Capítulo 1, anotalos vos con dos usuarios distintos, exportá filtrando por submitted y contá cuántas sugerencias corregiste.
from transformers import pipeline
import argilla as rg
clf = pipeline("sentiment-analysis") # cualquier clasificador sirve como "sugerencia"
titulares = ["Acme recorta 2.000 puestos; la acción sube 8 %", "Beta S.A. presenta resultados en línea con lo esperado"] # completá hasta diez
mapa = {"POSITIVE": "positivo", "NEGATIVE": "negativo"}
client = rg.Argilla(api_url="...", api_key="...")
dataset = client.datasets(name="titulares-finanzas") # o crealo con los Settings de §3
dataset.records.log([
rg.Record(fields={"texto": t},
suggestions=[rg.Suggestion(question_name="categoria", value=mapa[clf(t)[0]["label"]])])
for t in titulares
])
Pista / qué esperar
El clasificador genérico va a marcar "Acme recorta 2.000 puestos…" como negativo; con la guía de §4 ("tono hacia la empresa cotizante") vos lo corregís a positivo. Ese desacuerdo modelo/humano es justamente el valor del dataset: si no corregiste ninguna sugerencia, no necesitabas anotar.
11. Fuentes y para seguir
- LLM Course, Chapter 10 — Curate high-quality datasets — el capítulo original, con el paso a paso de desplegar el Space y anotar en la UI.
- Argilla — Documentación — la referencia de la API v2:
Settings, tipos deQuestion,TaskDistribution, consultas y exportación. - Cohen's kappa — la fórmula completa, la escala de Landis y Koch y las críticas conocidas (por qué kappa cae con etiquetas muy desbalanceadas).
- LIMA: Less Is More for Alignment (arXiv 2305.11206) — el argumento de "mil ejemplos curados alcanzan" de §1; leelo antes de decidir cuánto anotar.
- Deduplicating Training Data Makes Language Models Better (arXiv 2107.06499) — la evidencia detrás del paso de deduplicación de §6, y cómo se hace a escala con MinHash y sufijos.
- datatrove — la librería de Hugging Face para filtrar y deduplicar corpus grandes; el mismo pipeline de §6 cuando el dataset no entra en memoria.