Saltar al contenido
← Todos los proyectos

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.

📦 Datos: CFPB Consumer Complaint Database TF-IDFEmbeddings MiniLMNMFLLM con salida JSON

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)

TF-IDF + logística 0.659
TF-IDF + SVM lineal 0.665
Embeddings MiniLM + logística 0.63
Combinación TF-IDF + embeddings 0.677
LLM openai/gpt-oss-120b vía Groq 0.746

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
0 %5 %10 %15 %2023-012023-032023-052023-072023-092023-11MesPrueba
% de quejas

Crecimiento de cada tema (últimos 3 meses vs. 9 previos)

letters · continuously · uploaded · filing 23.3 %
identity · theft · remove · victim 19.1 %
agency · reporting · person · furnish 15.3 %
late · payment · attached · payments 13.1 %
debt · bank · company · received 6.7 %
items · validate · erroneous · unverified -8.6 %
consent · reports · perjury · declare -28.9 %
balance · owed · original · creditor -33.4 %
reporting · violated · instructions · privacy -42.5 %
records · noteworthy · exchanges · questioning -42.7 %

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

ModeloF1 macroIC 95 %ExactitudCosto por 1,000 quejas
TF-IDF + logística0.6590.644–0.67286.4%≈ $0 (CPU local)
TF-IDF + SVM lineal0.6650.649–0.68487.5%≈ $0 (CPU local)
Embeddings MiniLM + logística0.630.610–0.64881.3%≈ $0 (CPU local)
Combinación TF-IDF + embeddings0.6770.655–0.69685.5%≈ $0 (CPU local)
LLM openai/gpt-oss-120b vía Groq0.7460.632–0.82986.7%$0.00

Desempeño por producto

ProductoPrecisiónExhaustividadF1Quejas
Cobranza0.5380.6660.595524
Crédito automotriz0.4620.8140.59113
Crédito educativo0.650.8560.739139
Cuenta de ahorro/cheques0.770.8270.798422
Hipoteca0.7760.9060.836203
Préstamo personal0.3440.1690.22765
Reporte de crédito0.9640.8920.9275,355
Tarjeta de crédito0.6750.8010.733553
Transferencias0.6230.6830.652126

Palabras que más pesan por producto (explicabilidad)

ProductoPalabras clave
Cobranzadebt, collection, collections, owed, court, owe, calls, garnishment
Crédito automotrizcar, vehicle, auto, loan, finance, dealership, auto loan, title
Crédito educativoloans, student, mohela, nelnet, navient, student loan, loan, deferment
Cuenta de ahorro/chequesaccount, bank, funds, deposited, chime, savings, checking account, atm
Hipotecamortgage, home, escrow, foreclosure, heloc, modification, loan, property
Préstamo personalloan, annual, received receipt, daily, subscription, 11 months, ve paying, installment
Reporte de créditoexperian, credit report, equifax, transunion, inquiries, reporting, accounts, remove
Tarjeta de créditocard, credit card, capital, discover, synchrony, statement, comenity, update
Transferenciaspaypal, 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.

demo.py · editable
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).

p13_quejas.py200 líneas
"""#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()