Clasifica correos sin nube
Un clasificador Naive Bayes sobre 60 correos de una pyme. Se entrena en milisegundos en tu pestaña y puedes explicar cada peso.
EMAILS son 60 correos cortos (sintéticos, en español) con etiqueta cliente o spam. Vas a escribir un Naive Bayes multinomial, el clasificador de texto más explicable que existe. Cinco funciones:
tokenize(text): minúsculas, sin acentos (normalize('NFD') y quita los diacríticos \u0300-\u036f), y devuelve las secuencias de a-z0-9 de al menos dos caracteres.
train(docs): devuelve { classes, vocab, docs }. En classes, por etiqueta: { docs, tokens, counts }, con counts el número de veces que aparece cada palabra. vocab es el número de palabras distintas en total y docs el total de correos.
logProb(model, label, tokens): log(docs de la clase / docs) más, por cada token, log((count + 1) / (tokens de la clase + vocab)). El + 1 es el suavizado de Laplace: una palabra nueva no hunde la probabilidad a cero.
predict(model, text): { label, pSpam }, con pSpam = 1 / (1 + exp(logCliente - logSpam)) y label igual a 'spam' desde 0,5.
precisionRecall(preds, labels, positive): { precision, recall } de la clase positive, con 0 cuando un denominador es 0.
La vista previa pinta las palabras con más peso, mide precisión y recall dejando un correo fuera cada vez, y te deja probar un correo tuyo.
Retos 0/5
- tokenize: minúsculas, sin acentos, sin puntuación y sin palabras de una letra
- train cuenta documentos, tokens y palabras por clase, y el vocabulario total
- logProb suma el prior y los logaritmos suavizados; una palabra nueva no da -Infinity
- predict acierta un correo de spam y, dejando uno fuera, al menos 9 de cada 10
- precisionRecall con la clase positiva, y ceros cuando no hay positivos
function tokenize(text) {
// minúsculas, sin acentos, y luego las secuencias de a-z y 0-9 de al menos dos caracteres
return text.split(' ');
}
function train(docs) {
// por etiqueta: docs, tokens y counts por palabra; más el tamaño del vocabulario y el total de docs
return { classes: {}, vocab: 0, docs: 0 };
}
function logProb(model, label, tokens) {
// log(prior) + suma de log((count + 1) / (tokens + vocab)) por cada token (suavizado de Laplace)
return 0;
}
function predict(model, text) {
// pSpam = 1 / (1 + exp(logCliente - logSpam)); la etiqueta es 'spam' desde 0,5
return { label: 'cliente', pSpam: 0 };
}
function precisionRecall(preds, labels, positive) {
// precision = tp / (tp + fp), recall = tp / (tp + fn); 0 cuando un denominador es 0
return { precision: 0, recall: 0 };
}
const model = train(EMAILS);
console.log(predict(model, 'Has ganado un premio, haz clic aquí para reclamarlo'));La idea
Naive Bayes cuenta palabras. Para cada clase guarda cuántas veces aparece cada palabra y, cuando llega un correo nuevo, suma los logaritmos de esas frecuencias. La clase con la suma más alta gana. Se llama «ingenuo» porque supone que las palabras son independientes entre sí, y aun así funciona bien con texto corto.
Con 60 correos el modelo entrena en menos de un milisegundo y cada peso tiene explicación: «gana» pesa hacia spam porque aparece en cinco correos de spam y en ninguno de cliente. Ese es el argumento para empezar por aquí antes que por un modelo de lenguaje: cuando acierta sabes por qué, y cuando falla también.
La trampa es medirlo con los mismos correos con los que entrenaste. Ahí acierta casi todo. La vista previa mide dejando un correo fuera cada vez, que es la versión honesta con tan pocos datos. Y el umbral de 0,5 no es sagrado: si prefieres no perder ningún correo de cliente, súbelo y acepta algo más de spam en la bandeja.
Hazlo en tu máquina
Con Python y scikit-learn, sobre el CSV de este ejercicio (CC BY 4.0). Sale la misma cuenta con validación cruzada:
curl -O https://vorluxai.com/data/labs/correos-pyme.csv
python3 -c "
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import cross_val_score
df = pd.read_csv('correos-pyme.csv')
nb = make_pipeline(CountVectorizer(strip_accents='unicode'), MultinomialNB())
print(cross_val_score(nb, df.texto, df.etiqueta, cv=10).mean())
" Para profundizar: la referencia de scikit-learn (en inglés) →
¿Esto en producción, con tus datos? Hablemos 15 minutos →