Proyecto: un clasificador de sentimiento
28 min read
Un acierto sobre ejemplos ya vistos no es una medida, es un recuerdo. Ahí se quedó la lección anterior, sobre implementar un MLP (multilayer perceptron, perceptrón multicapa) desde cero: un bucle que entrena solo, dos coordenadas de entrada que escribí yo y cuatro ejemplos —los suficientes para que la red acabe acertándolos todos, los justos para que acertarlos no demuestre nada—. Entrenar mejor no arregla ninguna de las dos cosas. Hay que preguntarle a la red por algo que no haya leído, y hace falta un número que conteste.
Las dos piezas que eso pide vienen de sitios distintos. La representación estaba resuelta desde el bloque anterior: la bolsa de palabras convierte cualquier texto en un vector de longitud fija, y lo hace sin que nadie tenga que leerse los datos antes. La otra no ha aparecido todavía en el curso y es la que ocupa media lección: apartar unas cuantas reseñas antes de empezar, no dejar que intervengan en nada y preguntarle por ellas a la red cuando ya no quede nada por ajustar. Con las dos puestas, esto es el bloque 1 a la entrada y el bloque 2 encima, sin cambiar una línea del bucle —comprobarlo es parte del trabajo—; lo único nuevo es de dónde sale cada cosa.
Las reseñas las he generado con inteligencia artificial: 240 reseñas de cine de una línea, la mitad hablando bien de la película y la mitad hablando mal, en el mismo registro que las diez de la lección sobre la neurona artificial. Eso conviene tenerlo delante al leer la cifra del final, porque un corpus generado así sale más regular que uno recogido de internet —menos erratas, menos ironía, vocabulario más repetido—, y todo eso le pone las cosas fáciles al clasificador. Están en este fichero, y ábrelo antes de seguir: leerse los datos con los que va a entrenar un modelo es parte del trabajo, y aquí son dos minutos. Dentro va también el reparto —180 para entrenar, 60 para medir—, escrito en el fichero en lugar de sortearse al ejecutar, para que los números de abajo te salgan idénticos a ti.
De la reseña al vector, y de ahí a la red
La entrada la fija el bloque anterior. Cada reseña es un documento , y su bolsa de palabras es el vector que cuenta cuántas veces aparece en él cada entrada del vocabulario,
con el número de tokens del documento y el one-hot de la entrada . El vocabulario son los 442 tipos distintos que aparecen en las 180 reseñas de entrenamiento, sin recorte por frecuencia: la lección sobre el vocabulario y las palabras fuera de vocabulario (out-of-vocabulary, OOV) cortaba por los tipos más frecuentes porque el corpus era grande, y 1 299 ocurrencias no dan para tirar nada.
Apilando una reseña por fila, la entrada de la red es , con las etiquetas en valiendo cuando la reseña habla bien. Encima va el mismo MLP de la lección anterior con otros tamaños: una capa oculta de neuronas con y una salida con sigmoide, o sea . Cada paso mira el conjunto de entrenamiento entero, de modo que y el forward pass ocupa las dos líneas de siempre:
La pérdida es la entropía cruzada binaria de la lección sobre funciones de pérdida, promediada sobre las 180 reseñas, y la vuelta es la de la lección anterior sin tocar nada: , la recurrencia que la transporta a la capa oculta y los cuatro gradientes con su ,
Lo único que ha cambiado son los tamaños, y han cambiado mucho: donde la red de las cuatro frases tenía diecisiete números que ajustar, ésta tiene 5 329.
Ese salto obliga a mirar otra vez una sola decisión, la escala de la inicialización. Cada reseña enciende unas siete coordenadas de las 442, así que la preactivación de una neurona oculta es una suma de unos siete pesos: con pesos normales de escala eso da valores lo bastante grandes como para dejar la tangente hiperbólica saturada desde el primer paso, y con escala no. La diferencia se mide en el resultado — de acierto sobre las reseñas nuevas frente a —, y es la razón de que las celdas de abajo multipliquen por . Que la escala buena dependa de la anchura de las capas y de la profundidad de la red es la deuda que la lección anterior dejó apuntada al bloque 3, y sigue apuntada.
Medir donde el acierto significa algo
Llamemos a las 180 reseñas con las que se entrena y a las 60 que se guardan, con y . No comparten ninguna reseña, y esa es toda su relación.
A cada uno se le puede pedir la misma cifra, la tasa de acierto: qué fracción de sus reseñas recibe el veredicto correcto. La salida es una probabilidad, así que el veredicto es «positiva» cuando —la frontera de la lección sobre la neurona artificial— y «negativa» cuando no, y la cuenta es
La doble implicación dice que las dos afirmaciones valen lo mismo: o la red dice que sí y la etiqueta también, o ninguna de las dos. Y el conjunto sobre el que se cuenta va escrito dentro, que es lo que distingue de —dos números que esta lección va a mirar juntos y que no significan lo mismo ni de lejos—.
Que salga sólo de es parte de la medida, no un detalle de la implementación. Un vocabulario construido sobre las 240 le daría a cada reseña de prueba una coordenada propia para cada uno de sus tipos raros, coordenadas que existirían porque esa reseña existe, y la red llegaría al examen con el espacio hecho a su medida. Lo que desaparecería es justo lo que hay que medir: los tipos que un texto nuevo trae y el vocabulario no tiene. Es el problema OOV de aquella lección, y aquí llega sin la red de seguridad que ella le puso: no hay <UNK> que los recoja, de modo que un token fuera del vocabulario no llega al vector en absoluto.
La concesión hay que decirla entera, porque tiene un caso extremo. Una reseña cuyos tokens estuvieran todos fuera del vocabulario recibiría , y con él
un número que no depende de la reseña sino sólo de los sesgos: todas las reseñas ilegibles para este vocabulario reciben el mismo veredicto, sea el que sea. Es el hueco que ya señalaba la lección sobre la neurona artificial, al mirar qué contesta una neurona ante el vector nulo, ahora con un texto real detrás. Y una segunda concesión, más aburrida y más frecuente: sesenta reseñas medidas una vez dan un número con la firmeza de sesenta tiradas, suficiente para separar un de un y no para separarlo de un .
El clasificador, de punta a punta
La primera celda trae las reseñas, las tokeniza y construye las dos matrices. Ejecútala y mira tres cosas: la anchura del vector, cuántas de sus coordenadas son distintas de cero y cuántos tokens de prueba se quedan por el camino.
import numpy as np
from collections import Counter
from pyodide.http import open_url
D = json.load(open_url("/courses/dl-nlp/resenas-cine.json"))
ent, pru = D["entrenamiento"], D["prueba"]
def tokeniza(texto): # la tokenización del bloque anterior, en una
return re.findall(r"\w+", texto.lower()) # línea: a minúsculas y fuera la puntuación
frec = Counter(w for r in ent for w in tokeniza(r["texto"]))
V = sorted(frec) # el vocabulario sale SÓLO del entrenamiento
pos = {e: i for i, e in enumerate(V)}
def matriz(resenas):
X = np.zeros((len(resenas), len(V)))
for i, r in enumerate(resenas):
for token in tokeniza(r["texto"]):
if token in pos: # lo que no está en V no llega al vector
X[i, pos[token]] += 1.0
return X, np.array([[float(r["etiqueta"])] for r in resenas])
X_ent, Y_ent = matriz(ent)
X_pru, Y_pru = matriz(pru)
print("entrenamiento: %d reseñas, %d positivas" % (len(ent), int(Y_ent.sum())))
print("prueba: %d reseñas, %d positivas" % (len(pru), int(Y_pru.sum())))
print("vocabulario: %d entradas sobre %d ocurrencias" % (len(V), sum(frec.values())))
print("X_ent", X_ent.shape, " X_pru", X_pru.shape)
print("coordenadas no nulas: %.1f por reseña, el %.1f %% del vector"
% (X_ent.astype(bool).sum(1).mean(), 100 * X_ent.astype(bool).mean()))
tokens_pru = [w for r in pru for w in tokeniza(r["texto"])]
fuera = [w for w in tokens_pru if w not in pos]
print("\nfuera de vocabulario: %d de %d ocurrencias (%.1f %%), %d tipos"
% (len(fuera), len(tokens_pru), 100 * len(fuera) / len(tokens_pru), len(set(fuera))))
print("\nreseña de prueba:", pru[0]["texto"])
print("su vector, sin los ceros:",
{V[j]: int(X_pru[0, j]) for j in np.nonzero(X_pru[0])[0]})
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
Los tres números dicen en qué se ha metido la red. El vector tiene 442 coordenadas y una reseña enciende de media, el : la representación es dispersa, como anunciaba el bloque anterior, y cada ejemplo deja unas 435 coordenadas sin decir nada. Y de las 432 ocurrencias del conjunto de prueba, 36 —el , repartidas en 35 tipos— no están en el vocabulario. La última línea enseña una de ellas: la reseña una película preciosa que recomiendo sin reservas llega a la red con seis coordenadas, porque reservas no aparece en ninguna de las 180 y desaparece sin dejar rastro.
La segunda celda es el bucle de la lección anterior con los nombres cambiados. Necesita lo que dejó la primera, así que ejecútalas en orden. Mira las dos últimas columnas a la vez, que es la lección entera.
# Necesita la celda anterior: X_ent, Y_ent, X_pru, Y_pru y V.
B, d0, d1, d2 = len(X_ent), len(V), 12, 1
eta, pasos = 0.5, 600
rng = np.random.default_rng(0) # semilla fija: verás estos números
W1, b1 = rng.normal(size=(d1, d0)) * 0.1, np.zeros(d1)
W2, b2 = rng.normal(size=(d2, d1)) * 0.1, np.zeros(d2)
def salida(X, W1, b1, W2, b2):
H1 = np.tanh(X @ W1.T + b1)
return H1, 1.0 / (1.0 + np.exp(-(H1 @ W2.T + b2)))
def acierto(X, Y, W1, b1, W2, b2):
P = salida(X, W1, b1, W2, b2)[1]
return float(((P >= 0.5) == (Y == 1.0)).mean())
print(" paso pérdida acierto entrenamiento acierto prueba")
for t in range(pasos + 1):
H1, Y_hat = salida(X_ent, W1, b1, W2, b2)
if t % 50 == 0:
p = np.clip(Y_hat, 1e-12, 1.0 - 1e-12)
print("%5d %7.4f %19.3f %14.3f"
% (t, -np.mean(Y_ent * np.log(p) + (1.0 - Y_ent) * np.log(1.0 - p)),
acierto(X_ent, Y_ent, W1, b1, W2, b2),
acierto(X_pru, Y_pru, W1, b1, W2, b2)))
if t == pasos:
break
D2 = Y_hat - Y_ent # la vuelta, igual que en la lección anterior
D1 = (D2 @ W2) * (1.0 - H1 ** 2)
W1 -= eta * (D1.T @ X_ent) / B
b1 -= eta * D1.sum(0) / B
W2 -= eta * (D2.T @ H1) / B
b2 -= eta * D2.sum(0) / B
P_pru = salida(X_pru, W1, b1, W2, b2)[1]
falla = np.nonzero((P_pru >= 0.5) != (Y_pru == 1.0))[0]
print("\nse equivoca en %d de las %d reseñas de prueba:" % (len(falla), len(pru)))
for i in falla:
print(" ŷ = %.3f y = %d %s" % (P_pru[i, 0], int(Y_pru[i, 0]), pru[i]["texto"]))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
En el paso la red contesta a cara o cruz, y , con la pérdida en —el de una red que dice lo mismo a todo—. En el paso ya lleva sobre las reseñas que está viendo y sobre las que no, y en el paso alcanza el y no lo suelta. Lo interesante empieza ahí: entre el paso y el la pérdida se divide por trece, de a , y la columna de la derecha no se mueve ni una milésima. Con 20 000 pasos en lugar de 600 sigue en . Las siete reseñas que falla no las falla por poco entrenamiento.
Léelas, que es donde está el resultado de la lección. Cuatro de las siete llevan un no: no me aburrí ni un minuto recibe y no se hace larga en ningún momento recibe , y las dos hablan bien de la película. Las otras tres fallan por motivos distintos —dos traen tipos que el vocabulario no tiene, convencen y eterna, y la tercera junta sobreactuada con de principio a fin, que en el entrenamiento aparece en reseñas positivas—. La tercera celda pregunta por las cuatro primeras.
# Necesita las dos celdas anteriores: tokeniza, pos, V, salida y los pesos entrenados.
def vector(texto):
x = np.zeros((1, len(V)))
for token in tokeniza(texto):
if token in pos:
x[0, pos[token]] += 1.0
return x
def probabilidad(texto):
return float(salida(vector(texto), W1, b1, W2, b2)[1][0, 0])
a = "la película no es aburrida, es divertida"
b = "la película no es divertida, es aburrida"
print("¿el mismo vector?", bool(np.array_equal(vector(a), vector(b))))
print("ŷ = %.4f %s" % (probabilidad(a), a))
print("ŷ = %.4f %s" % (probabilidad(b), b))
# ¿Qué puede saber la red de «no»? Lo que diga su columna, y su columna dice esto.
n_pos = sum(tokeniza(r["texto"]).count("no") for r in ent if r["etiqueta"] == 1)
n_neg = sum(tokeniza(r["texto"]).count("no") for r in ent if r["etiqueta"] == 0)
print("\n«no»: %d ocurrencias en reseñas positivas, %d en negativas" % (n_pos, n_neg))
# Y una reseña que no comparte ninguna entrada con el vocabulario.
nueva = "peliculón sublime, cinta imprescindible"
print("\n%s\n vector nulo: %s ŷ = %.4f"
% (nueva, not vector(nueva).any(), probabilidad(nueva)))
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador.
Las dos primeras frases tienen las mismas palabras, con las mismas repeticiones, en otro orden, y dicen cosas opuestas. Su vector es idéntico —lo dice la primera línea— y su probabilidad también, las dos: la red las llama negativas, y con una de ellas acierta por casualidad. Ninguna tasa de acierto puede pasar de ahí, porque el error ya está cometido cuando la red recibe su entrada.
La segunda salida explica los cuatro fallos con no. Esa entrada aparece 5 veces en las reseñas positivas del entrenamiento y 24 en las negativas, así que su coordenada acaba pesando hacia lo negativo, que es lo mejor que puede hacer: la coordenada dice cuántas veces sale no, y no sobre qué cae. Y la última línea cierra la concesión de la sección anterior con un número: peliculón sublime, cinta imprescindible no comparte ninguna entrada con el vocabulario, recibe el vector nulo y con él un que no depende de lo que diga, sólo de los sesgos. Cualquier otra reseña ilegible recibiría ese mismo , y como está por debajo de , todas serían negativas.
Comprueba tu intuición
Tres preguntas: de dónde puede salir el vocabulario, sobre qué se cuenta una tasa de OOV y qué autoriza a concluir la distancia entre las dos columnas.
El vocabulario de esta lección sale sólo de las 180 reseñas de entrenamiento. Si se construyera sobre las 240, ¿qué sería cierto? Marca todo lo que valga.
Select every correct option. This is graded all-or-nothing: there is no partial credit.
Mides otro conjunto de prueba con este mismo vocabulario. Sus reseñas suman 500 ocurrencias de 320 tipos distintos, y 80 de esos tipos —100 ocurrencias entre todos— no están en . ¿Qué fracción de las ocurrencias de ese conjunto se queda fuera del vocabulario?
A margin of ±0.005 is accepted.
La red acierta las 180 reseñas de entrenamiento y 53 de las 60 de prueba. Entrenarla 20 000 pasos en vez de 600 deja esa segunda cifra exactamente donde estaba. ¿Qué se puede concluir de ahí?
El bloque termina con un clasificador que funciona y con el techo de su representación a la vista. Las dos frases de la última celda no son un caso rebuscado montado para que falle: son la consecuencia de que sume los one-hot de los tokens, y una suma no recuerda en qué orden le llegaron sus sumandos. Todo lo que este bloque ha construido —la neurona, las capas, el descenso, la vuelta entera— trabaja después de esa suma, y ninguna cantidad de entrenamiento alcanza a recuperar lo que la entrada tiró antes de existir. Por eso las siete reseñas resisten a 20 000 pasos: no es la red la que se queda corta.
Lo que falta, entonces, es una entrada que llegue en orden y una red capaz de leerla así, token a token, arrastrando lo que ya ha visto. Eso es el bloque 3, sobre redes neuronales recurrentes, y su lección sobre por qué el MLP falla con secuencias empieza midiendo el tamaño exacto del problema: qué le pasa a un perceptrón multicapa cuando la entrada deja de tener una longitud fija y la posición que ocupa cada token pasa a significar algo.
Further reading1 source · 1 paper
Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.
- Thumbs up? Sentiment Classification using Machine Learning Techniques
El artículo que fundó la clasificación de sentimiento, con reseñas de cine y bolsa de palabras como aquí. Choca con el mismo muro —83 % como techo, marcar el «no» apenas ayuda— y concluye que el orden pide algo que la bolsa no da.