Proyecto #13 · IA generativa y texto
Quejas de clientes: clasificación y temas emergentes
Clasificar 30 mil quejas reales con TF-IDF y embeddings locales, detectar temas que crecen y comparar con un LLM.
Resumen ejecutivo
- 30,000 quejas reales con narrativa (2023-01 a 2023-12), 9 productos. Validación temporal: entrenar con 9 meses y probar en los 3 más recientes (7,500 quejas).
- Mejor clasificador: LLM openai/gpt-oss-120b vía Groq con F1 macro 0.746 (IC 0.632–0.829); TF-IDF + logística 0.659. Todo corre en CPU, sin costo por consulta.
- Comparación con LLM en las mismas 300 quejas de prueba: LLM openai/gpt-oss-120b vía Groq logra F1 0.746 (IC 0.632–0.829) vs. 0.699 del clásico, sin entrenar con ejemplos, sin costo en la capa gratuita (~579 tokens por queja). Los IC se traslapan: el LLM sirve como segunda opinión y para categorías nuevas, el clásico para el volumen diario.
- Temas que más crecen en los últimos 3 meses: «letters · continuously · uploaded · filing» (+23%), «identity · theft · remove · victim» (+19%), «agency · reporting · person · furnish» (+15%). Es la alerta temprana que un área de experiencia del cliente o de cumplimiento quiere ver cada mes.
F1 macro (mejor)0.746
Exactitud86.7%
Quejas analizadas30,000
Costo por 1,000≈ $0
Resultados
F1 macro por enfoque (3 meses más recientes)
Matriz de confusión (% por fila) — LLM openai/gpt-oss-120b vía Groq
Filas: producto real · columnas: predicho
CobranzaCrédito automotrizCrédito educativoCuenta de ahorro/chequesHipotecaPréstamo personalReporte de créditoTarjeta de créditoTransferencias Cobranza 66.6 %1.1 %1.3 %1.7 %1.1 %1 %24 %2.7 %0.4 %Crédito automotriz 4.4 %81.4 %0 %2.7 %1.8 %2.7 %6.2 %0.9 %0 %Crédito educativo 2.9 %2.2 %85.6 %2.2 %0 %0.7 %2.9 %2.9 %0.7 %Cuenta de ahorro/cheques 1.2 %0.2 %0.7 %82.7 %0.9 %0.2 %0.7 %6.4 %6.9 %Hipoteca 1 %2 %0 %2 %90.6 %1.5 %0 %2.5 %0.5 %Préstamo personal 9.2 %24.6 %15.4 %4.6 %12.3 %16.9 %6.2 %10.8 %0 %Reporte de crédito 4.8 %1.2 %0.8 %0.4 %0.5 %0.1 %89.2 %2.8 %0.2 %Tarjeta de crédito 3.6 %1.8 %0.2 %6.1 %0.4 %0.2 %6 %80.1 %1.6 %Transferencias 0.8 %0.8 %1.6 %22.2 %2.4 %0 %0 %4 %68.3 %
0 % 90.6 %
Participación mensual de los 3 temas que más crecen
- letters · continuously · uploaded · filing
- identity · theft · remove · victim
- agency · reporting · person · furnish
% de quejas
Crecimiento de cada tema (últimos 3 meses vs. 9 previos)
Metodología y validación
TF-IDF (1–2 gramas) con logística y SVM lineal, embeddings MiniLM locales (ONNX) y su combinación; NMF para temas.
✔ Cómo se validó
- Partición temporal: 9 meses de entrenamiento, 3 meses posteriores de prueba.
- F1 macro (todas las clases pesan igual) con IC bootstrap; matriz de confusión y desempeño por clase.
- La comparación con LLM usa exactamente la misma muestra de prueba.
⚙ Supuestos
- El producto que elige el consumidor en el formulario es la etiqueta correcta (tiene ruido).
- Las narrativas vienen anonimizadas por el CFPB (XXXX).
⚠ Limitaciones
- Quejas en inglés de EE. UU.; para CONDUSEF habría que reentrenar con texto en español.
- NMF da temas aproximados: se validan leyendo ejemplos antes de reportarlos.
Comparación de clasificadores
| Modelo | F1 macro | IC 95 % | Exactitud | Costo por 1,000 quejas |
|---|---|---|---|---|
| TF-IDF + logística | 0.659 | 0.644–0.672 | 86.4% | ≈ $0 (CPU local) |
| TF-IDF + SVM lineal | 0.665 | 0.649–0.684 | 87.5% | ≈ $0 (CPU local) |
| Embeddings MiniLM + logística | 0.63 | 0.610–0.648 | 81.3% | ≈ $0 (CPU local) |
| Combinación TF-IDF + embeddings | 0.677 | 0.655–0.696 | 85.5% | ≈ $0 (CPU local) |
| LLM openai/gpt-oss-120b vía Groq | 0.746 | 0.632–0.829 | 86.7% | $0.00 |
Desempeño por producto
| Producto | Precisión | Exhaustividad | F1 | Quejas |
|---|---|---|---|---|
| Cobranza | 0.538 | 0.666 | 0.595 | 524 |
| Crédito automotriz | 0.462 | 0.814 | 0.59 | 113 |
| Crédito educativo | 0.65 | 0.856 | 0.739 | 139 |
| Cuenta de ahorro/cheques | 0.77 | 0.827 | 0.798 | 422 |
| Hipoteca | 0.776 | 0.906 | 0.836 | 203 |
| Préstamo personal | 0.344 | 0.169 | 0.227 | 65 |
| Reporte de crédito | 0.964 | 0.892 | 0.927 | 5,355 |
| Tarjeta de crédito | 0.675 | 0.801 | 0.733 | 553 |
| Transferencias | 0.623 | 0.683 | 0.652 | 126 |
Palabras que más pesan por producto (explicabilidad)
| Producto | Palabras clave |
|---|---|
| Cobranza | debt, collection, collections, owed, court, owe, calls, garnishment |
| Crédito automotriz | car, vehicle, auto, loan, finance, dealership, auto loan, title |
| Crédito educativo | loans, student, mohela, nelnet, navient, student loan, loan, deferment |
| Cuenta de ahorro/cheques | account, bank, funds, deposited, chime, savings, checking account, atm |
| Hipoteca | mortgage, home, escrow, foreclosure, heloc, modification, loan, property |
| Préstamo personal | loan, annual, received receipt, daily, subscription, 11 months, ve paying, installment |
| Reporte de crédito | experian, credit report, equifax, transunion, inquiries, reporting, accounts, remove |
| Tarjeta de crédito | card, credit card, capital, discover, synchrony, statement, comenity, update |
| Transferencias | paypal, money, transfer, coinbase, transaction, funds, venmo, cashapp |
Pruébalo: Python en tu navegador
Una versión compacta del análisis, ejecutada con Pyodide (CPython en WebAssembly). Edita el código y vuelve a ejecutarlo; si defines la variable grafica, se dibuja abajo.
La primera ejecución descarga Python + NumPy/pandas (~15 MB, una sola vez).
Código completo
El script exacto que generó los resultados de esta página (python run_all.py).
"""#13 Clasificación de quejas de clientes financieros y detección de temas emergentes.
Datos: CFPB Consumer Complaint Database (quejas reales de servicios financieros en EE. UU., dominio público).
El archivo oficial actual ya no publica la narrativa; se usa el espejo en Hugging Face con texto
(BEE-spoke-data/consumer-finance-complaints, CC0) y se toman los 12 meses de 2023. Tareas:
1) Clasificar el producto a partir del texto (lo que haría un área de atención para enrutar quejas).
2) Detectar temas emergentes mes a mes (factorización NMF sobre TF-IDF).
3) Comparar costo y calidad: TF-IDF clásico vs. embeddings semánticos locales vs. LLM (si hay API key).
Paralelo en México: las quejas ante CONDUSEF (REDECO).
"""
import json
import numpy as np
import pandas as pd
from sklearn.decomposition import NMF
from sklearn.feature_extraction.text import ENGLISH_STOP_WORDS, TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, confusion_matrix, f1_score
from sklearn.svm import LinearSVC
from common import RAW, SEED, SITE_DATA, bars, download, export, heatmap, line, table
from validacion import model_card
URL = "https://huggingface.co/datasets/BEE-spoke-data/consumer-finance-complaints/resolve/main/has-text/train-0000{}-of-00003.parquet"
DESDE, HASTA = "2023-01-01", "2023-12-31"
POR_MES = 2500
# Agrupación de productos del CFPB en categorías de negocio (como las de una financiera)
PRODUCTOS = {
"Credit reporting or other personal consumer reports": "Reporte de crédito",
"Credit reporting, credit repair services, or other personal consumer reports": "Reporte de crédito",
"Debt collection": "Cobranza",
"Credit card": "Tarjeta de crédito",
"Credit card or prepaid card": "Tarjeta de crédito",
"Checking or savings account": "Cuenta de ahorro/cheques",
"Mortgage": "Hipoteca",
"Vehicle loan or lease": "Crédito automotriz",
"Student loan": "Crédito educativo",
"Payday loan, title loan, personal loan, or advance loan": "Préstamo personal",
"Money transfer, virtual currency, or money service": "Transferencias",
}
CLASES = sorted(set(PRODUCTOS.values()))
def load() -> pd.DataFrame:
cache = RAW / "cfpb_muestra.parquet"
if cache.exists():
return pd.read_parquet(cache)
usar = ["Date received", "Product", "Issue", "Consumer complaint narrative", "Company"]
partes = []
for i in range(3):
ch = pd.read_parquet(download(URL.format(i), f"cfpb_text_{i}.parquet"), columns=usar)
ch["Date received"] = ch["Date received"].astype(str)
ch = ch.dropna(subset=["Consumer complaint narrative"])
ch = ch[(ch["Date received"] >= DESDE) & (ch["Date received"] <= HASTA) & ch["Product"].isin(PRODUCTOS)]
partes.append(ch)
df = pd.concat(partes, ignore_index=True)
df.columns = ["fecha", "producto_cfpb", "problema", "texto", "empresa"]
df["fecha"] = pd.to_datetime(df["fecha"])
df["mes"] = df["fecha"].dt.to_period("M").astype(str)
df["producto"] = df["producto_cfpb"].map(PRODUCTOS)
df = df.groupby("mes", group_keys=False).apply(lambda g: g.sample(min(len(g), POR_MES), random_state=SEED))
df["texto"] = df["texto"].str.replace(r"X{2,}", " ", regex=True) # el CFPB anonimiza con XXXX
df = df.reset_index(drop=True)
df.to_parquet(cache)
return df
def embeddings(textos: list[str]) -> np.ndarray:
"""Embeddings semánticos locales (MiniLM vía ONNX, sin API ni GPU), con caché en disco."""
from fastembed import TextEmbedding
cache = RAW / f"cfpb_emb_{len(textos)}.npy"
if cache.exists():
return np.load(cache)
m = TextEmbedding("sentence-transformers/all-MiniLM-L6-v2")
E = np.array(list(m.embed([t[:2000] for t in textos], batch_size=64)))
np.save(cache, E)
return E
def run():
df = load()
# Validación temporal: 9 meses para entrenar, los 3 más recientes para probar
meses = sorted(df.mes.unique())
corte = meses[-3]
tr, te = df[df.mes < corte], df[df.mes >= corte]
y_tr, y_te = tr.producto.values, te.producto.values
tfidf = TfidfVectorizer(min_df=5, max_df=0.5, ngram_range=(1, 2), sublinear_tf=True, max_features=60_000,
stop_words="english")
A_tr, A_te = tfidf.fit_transform(tr.texto), tfidf.transform(te.texto)
resultados, pred = {}, {}
lr = LogisticRegression(max_iter=3000, C=4, class_weight="balanced").fit(A_tr, y_tr)
pred["TF-IDF + logística"] = lr.predict(A_te)
svm = LinearSVC(C=0.5, class_weight="balanced").fit(A_tr, y_tr)
pred["TF-IDF + SVM lineal"] = svm.predict(A_te)
E = embeddings(df.texto.tolist())
E_tr, E_te = E[df.mes.values < corte], E[df.mes.values >= corte]
lr_e = LogisticRegression(max_iter=3000, C=2, class_weight="balanced").fit(E_tr, y_tr)
pred["Embeddings MiniLM + logística"] = lr_e.predict(E_te)
# Combinación: probabilidades promediadas de TF-IDF y embeddings
p_comb = (lr.predict_proba(A_te) + lr_e.predict_proba(E_te)) / 2
pred["Combinación TF-IDF + embeddings"] = lr.classes_[p_comb.argmax(1)]
rng = np.random.default_rng(SEED)
for nombre, p in pred.items():
f1 = f1_score(y_te, p, average="macro")
bs = [f1_score(y_te[i], p[i], average="macro") for i in (rng.integers(0, len(y_te), len(y_te)) for _ in range(200))]
resultados[nombre] = (f1, *np.percentile(bs, [2.5, 97.5]), float((p == y_te).mean()))
# Resultado del LLM (si se corrió llm_quejas.py con una API key)
llm_path = SITE_DATA / "llm" / "p13-quejas.json"
llm = json.loads(llm_path.read_text()) if llm_path.exists() else None
res = pd.DataFrame([{"Modelo": k, "F1 macro": round(v[0], 4), "IC 95 %": f"{v[1]:.3f}–{v[2]:.3f}",
"Exactitud": f"{v[3]:.1%}", "Costo por 1,000 quejas": "≈ $0 (CPU local)"}
for k, v in resultados.items()])
if llm:
res.loc[len(res)] = {"Modelo": llm["modelo"], "F1 macro": round(llm["f1_macro"], 4), "IC 95 %": llm["ic"],
"Exactitud": f"{llm['exactitud']:.1%}", "Costo por 1,000 quejas": f"${llm['costo_por_mil']:.2f}"}
mejor = res.loc[res["F1 macro"].idxmax(), "Modelo"]
p_best = pred.get(mejor, pred["Combinación TF-IDF + embeddings"])
cm = confusion_matrix(y_te, p_best, labels=CLASES, normalize="true") * 100
rep = pd.DataFrame(classification_report(y_te, p_best, labels=CLASES, output_dict=True, zero_division=0)).T.loc[CLASES]
rep = rep[["precision", "recall", "f1-score", "support"]].round(3).reset_index().rename(
columns={"index": "Producto", "precision": "Precisión", "recall": "Exhaustividad", "f1-score": "F1", "support": "Quejas"})
# Explicabilidad: palabras con mayor peso para cada producto
vocab = np.array(tfidf.get_feature_names_out())
palabras = pd.DataFrame([{"Producto": c, "Palabras clave": ", ".join(vocab[np.argsort(-lr.coef_[i])[:8]])}
for i, c in enumerate(lr.classes_)])
# Temas emergentes: NMF de 10 temas; participación mensual y crecimiento últimos 3 meses vs. 9 previos
# Solo palabras (sin números ni citas legales tipo "section 1681", que el CFPB repite en plantillas)
ruido = ["section", "sections", "usc", "states", "15", "fcra", "act", "pursuant", "consumer", "violation", "rights"]
tf_t = TfidfVectorizer(min_df=10, max_df=0.4, max_features=20_000, token_pattern=r"(?u)\b[a-z][a-z]{2,}\b",
stop_words=list(ENGLISH_STOP_WORDS.union(ruido)))
T = tf_t.fit_transform(df.texto)
nmf = NMF(n_components=10, random_state=SEED, init="nndsvda", max_iter=400)
W = nmf.fit_transform(T)
voc_t = np.array(tf_t.get_feature_names_out())
nombres = [" · ".join(voc_t[np.argsort(-c)[:4]]) for c in nmf.components_]
tema = W.argmax(1)
part = pd.crosstab(df.mes, tema, normalize="index") * 100
part.columns = nombres
crec = (part[part.index >= corte].mean() / part[part.index < corte].mean() - 1).sort_values(ascending=False)
top3 = crec.index[:3]
r = res.set_index("Modelo")
result = {
"title": "Quejas de clientes: clasificación y temas emergentes",
"summary": [
f"{len(df):,} quejas reales con narrativa ({meses[0]} a {meses[-1]}), {len(CLASES)} productos. Validación temporal: "
f"entrenar con 9 meses y probar en los 3 más recientes ({len(te):,} quejas).",
f"Mejor clasificador: {mejor} con F1 macro {r.loc[mejor, 'F1 macro']:.3f} (IC {r.loc[mejor, 'IC 95 %']}); TF-IDF + "
f"logística {r.loc['TF-IDF + logística', 'F1 macro']:.3f}. Todo corre en CPU, sin costo por consulta.",
(f"Comparación con LLM en las mismas {llm['n']} quejas de prueba: {llm['modelo']} logra F1 {llm['f1_macro']:.3f} "
f"(IC {llm['ic']}) vs. {llm['f1_clasico_misma_muestra']:.3f} del clásico, sin entrenar con ejemplos, "
+ (f"a ${llm['costo_por_mil']:.2f} por 1,000 quejas." if llm['costo_por_mil'] > 0 else
f"sin costo en la capa gratuita (~{llm.get('tokens_por_queja', 0):.0f} tokens por queja). Los IC se traslapan: "
"el LLM sirve como segunda opinión y para categorías nuevas, el clásico para el volumen diario.")
if llm else "Comparación con LLM pendiente: el script `llm_quejas.py` la corre en cuanto haya una API key "
"(Groq, Gemini, OpenRouter, Hugging Face o Anthropic), con la misma muestra de prueba y salida JSON."),
f"Temas que más crecen en los últimos 3 meses: {', '.join(f'«{t}» ({crec[t]:+.0%})' for t in top3)}. "
"Es la alerta temprana que un área de experiencia del cliente o de cumplimiento quiere ver cada mes.",
],
"metrics": [
{"label": "F1 macro (mejor)", "value": f"{r.loc[mejor, 'F1 macro']:.3f}"},
{"label": "Exactitud", "value": r.loc[mejor, "Exactitud"]},
{"label": "Quejas analizadas", "value": f"{len(df):,}"},
{"label": "Costo por 1,000", "value": "≈ $0"},
],
"charts": [
bars("f1", "F1 macro por enfoque (3 meses más recientes)", res.Modelo, res["F1 macro"].values, fmt="dec"),
heatmap("cm", f"Matriz de confusión (% por fila) — {mejor}", cm.round(1), CLASES, CLASES, fmt="pct",
subtitle="Filas: producto real · columnas: predicho"),
line("temas", "Participación mensual de los 3 temas que más crecen",
{t: [[m, round(float(v), 2)] for m, v in part[t].items()] for t in top3},
x_label="Mes", y_label="% de quejas", fmt="pct", markers=[[corte, "Prueba"]]),
bars("crec", "Crecimiento de cada tema (últimos 3 meses vs. 9 previos)", crec.index, (crec.values * 100).round(1),
fmt="pct"),
],
"tables": [table("Comparación de clasificadores", res), table("Desempeño por producto", rep),
table("Palabras que más pesan por producto (explicabilidad)", palabras)],
"card": model_card(
"TF-IDF (1–2 gramas) con logística y SVM lineal, embeddings MiniLM locales (ONNX) y su combinación; NMF para temas.",
["Partición temporal: 9 meses de entrenamiento, 3 meses posteriores de prueba.",
"F1 macro (todas las clases pesan igual) con IC bootstrap; matriz de confusión y desempeño por clase.",
"La comparación con LLM usa exactamente la misma muestra de prueba."],
["El producto que elige el consumidor en el formulario es la etiqueta correcta (tiene ruido).",
"Las narrativas vienen anonimizadas por el CFPB (XXXX)."],
["Quejas en inglés de EE. UU.; para CONDUSEF habría que reentrenar con texto en español.",
"NMF da temas aproximados: se validan leyendo ejemplos antes de reportarlos."]),
}
muestra = te.sample(1500, random_state=SEED)[["texto", "producto"]]
muestra["texto"] = muestra.texto.str.slice(0, 400)
export("p13-quejas", result, muestra)
return result
if __name__ == "__main__":
run()