Lección 6.2.3 · 75 min

Guidance, KPIs y tono: qué extrae bien un LLM

La información que mueve un precio el día de resultados está en frases: la previsión del trimestre siguiente, el número de tiendas, el tono de las preguntas de los analistas. Esta lección la extrae con reglas, con un LLM local de 0,5B parámetros, con FinBERT y con un diccionario, y mide cada método contra la verdad publicada de los datos: dónde gana cada uno y qué cambiaría con un modelo mayor.

Fig. — Correlación con el tono verdadero: 40 noticias originales en inglés y las preguntas de los analistas de 20 conference calls, por método
Lecciones del nivel 6
  1. 6.1 · NLP financiero clásico y moderno

    1. 6.1.1 Tokenización, embeddings, diccionarios financieros y FinBERT
    2. 6.1.2 Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
    3. 6.1.3 Event studies con señales de texto, alineación temporal y topic modeling
  2. 6.2 · LLMs para investigación y trading

    1. 6.2.1 Cómo funciona un LLM: del siguiente token a la API
    2. 6.2.2 Prompting estructurado: JSON que se puede validar
    3. 6.2.3 Guidance, KPIs y tono: qué extrae bien un LLM
    4. 6.2.4 Fine-tuning ligero para tareas financieras: LoRA y QLoRA
    5. 6.2.5 Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro
  3. 6.3 · RAG financiero

    1. 6.3.1 Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant
    2. 6.3.2 Chunking de documentos financieros: tablas, notas al pie y XBRL
    3. 6.3.3 RAG sobre informes, folletos y KID, y cómo evaluarlo
  4. 6.4 · Agentes de IA para trading

    1. 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
    2. 6.4.2 Marcos de agentes y MCP
    3. 6.4.3 Arquitecturas multiagente tipo TradingAgents
    4. 6.4.4 Evidencia y límites de los agentes
    5. 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
    6. 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
  5. 6.5 · IA como copiloto del quant

    1. 6.5.1 Asistentes de código con un flujo de tests primero
    2. 6.5.2 Revisión automática de backtests y migraciones de código
    3. 6.5.3 Riesgos del código generado por IA
  6. 6.6 · Big data para trading

    1. 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
    2. 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
    3. 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
    4. 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
    5. 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
  7. 6.7 · Datos alternativos

    1. 6.7.1 Panorama y tamaño del mercado de datos alternativos
    2. 6.7.2 Tipos de datos alternativos: qué mide cada uno
    3. 6.7.3 Web scraping responsable
    4. 6.7.4 Proveedores y due diligence de datos alternativos
    5. 6.7.5 Del dato alternativo a la señal
    6. 6.7.6 Satélite, visión por computador y cadena de suministro

Qué se extrae y para qué sirve

El día de resultados, una empresa cotizada publica mucha información en forma de texto. Esta lección se ocupa de tres piezas con valor conocido:

  • La previsión de ventas (guidance) del trimestre siguiente. La dirección conoce la tendencia de su negocio mejor que el consenso de analistas; en los datos del curso, la distancia entre el punto medio de la previsión y el consenso anticipa parte de la sorpresa de ventas del trimestre siguiente (README de los datos del nivel 6).
  • El KPI operativo del sector (tiendas, suscriptores, producción, cartera de pedidos), que ayuda a leer las ventas antes de que se publiquen.
  • El tono: el de las preguntas de los analistas en la conference call, que en los datos lleva una deriva plantada del 2 % por unidad de tono en las 20 sesiones siguientes, y el de las noticias, que mueve el precio el día de reacción (lecciones 6.1.1 y 6.1.3).

Hay tres familias de métodos: reglas (expresiones regulares y diccionarios), modelos discriminativos afinados para una tarea (FinBERT) y LLM generativos. La pregunta no es cuál es «mejor», sino cuál acierta más en cada tarea, a qué coste y con qué riesgo de fallar en silencio. Los datos del curso permiten medirlo, porque la respuesta de cada documento está publicada:

Filings y llamadasReglasLLMNoticiasFinBERTDiccionarioContra la verdad
Fuente Mermaid
flowchart TB
  A[Filings y llamadas] --> B[Reglas]
  A --> C[LLM]
  N[Noticias] --> D[FinBERT]
  N --> C
  N --> E[Diccionario]
  B --> F[Contra la verdad]
  C --> F
  D --> F
  E --> F

Todos los textos son sintéticos y salen de plantillas (README de los datos): los resultados dicen cómo se comporta cada método en un caso controlado, no cuánto acertaría con filings reales.

La verdad y cómo medir

documentos6_extraccion_verdad.csv dice qué contiene cada documento (id_doc) y cada llamada (id_llamada): una fila guidance con el periodo y los dos extremos en millones de USD, una fila kpi con el valor y su unidad, y la frase literal de la que salen.

IN [01]python
import json
import re
import numpy as np
import pandas as pd
from bs4 import BeautifulSoup

fil = pd.read_parquet("filings6_documentos.parquet")
ll = pd.read_parquet("llamadas6_transcripciones.parquet")
verdad = pd.read_csv("documentos6_extraccion_verdad.csv")
verdad["fuente"] = np.where(verdad["id_doc"].notna(), "doc",
                            "llamada")
verdad["id"] = verdad["id_doc"].fillna(verdad["id_llamada"])
print(verdad.groupby(["fuente", "tipo"]).size().unstack())
verdad.loc[[0, 1, 6990], ["id", "tipo", "periodo", "bajo",
                          "alto", "valor", "unidad"]]
OUT [01]Qué hay que extraer
tipo     guidance   kpi
fuente
doc          3210  3780
llamada       710   840
           id      tipo periodo    bajo    alto   valor           unidad
0     D000007  guidance  2019T1  2440.0  2569.0     NaN  millones de USD
1     D000007       kpi  2018T4     NaN     NaN  1142.0          tiendas
6990   C00001  guidance  2019T1  2440.0  2569.0     NaN  millones de USD

Una extracción se evalúa como un clasificador, con números. Con EE los datos extraídos, VV los verdaderos y E∩VE \cap V los extraídos que coinciden con uno verdadero:

precision=∣E∩V∣∣E∣recall=∣E∩V∣∣V∣\text{precision} = \frac{|E \cap V|}{|E|} \qquad \text{recall} = \frac{|E \cap V|}{|V|}

«Coincidir» exige el mismo periodo y los mismos números dentro de una tolerancia del 0,5 %: una previsión de «$7.23 billion» es 7.230 millones, y la verdad, 7.228; el texto no tiene más precisión que esa. La exactitud al dígito se mide aparte. Una extracción con mucha precisión y poca cobertura pierde información en silencio; una con mucha cobertura y poca precisión mete datos falsos en el modelo.

Línea base: reglas

La primera herramienta, y a menudo la última, es una expresión regular. Las de esta celda se escribieron leyendo las frases de la columna frase de la verdad: hay cinco maneras de escribir una previsión y unos pocos patrones de KPI. Los números se leen según el idioma (en español, el punto separa miles y la coma, decimales).

IN [02]python
ORD = {"first": 1, "second": 2, "third": 3, "fourth": 4,
       "primer": 1, "segundo": 2, "tercer": 3, "cuarto": 4}
N = r"\d[\d.,]*\d|\d"
GUIA = [
    rf"range of \$(?P<a>{N}) million to \$(?P<b>{N}) million",
    rf"between \$(?P<a>{N}) (?P<u>million|billion) and "
    rf"\$(?P<b>{N})",
    rf"expected to be \$(?P<a>{N})-(?P<b>{N}) million",
    rf"entre (?P<a>{N}) y (?P<b>{N}) (?P<u>mil millones|millones)",
    rf"horquilla de (?P<a>{N})-(?P<b>{N}) millones",
]
TRIM = (r"(first|second|third|fourth|primer|segundo|tercer|cuarto)"
        r" (?:quarter|trimestre) (?:of |de )?(\d{4})")
KPI = rf"(?:at quarter end|al cierre del trimestre): (?P<v>{N})"

def numero(s, es):
    s = s.replace(".", "").replace(",", ".") if es else (
        s.replace(",", ""))
    return float(s)

def es_espanol(t):
    return bool(re.search(r"\b(trimestre|millones)\b", t))

def reglas(texto):
    """Previsión y KPI de un texto, o None si no los encuentra."""
    es = es_espanol(texto)
    guia = None
    for patron in GUIA:
        m = re.search(patron, texto)
        if m:
            k = 1000 if m.groupdict().get("u") in (
                "billion", "mil millones") else 1
            q = re.search(TRIM, texto[max(0, m.start() - 120):
                                      m.end()])
            per = f"{q.group(2)}T{ORD[q.group(1)]}" if q else None
            guia = (per, round(numero(m["a"], es) * k, 4),
                    round(numero(m["b"], es) * k, 4))
            break
    m = re.search(KPI, texto)
    kpi = numero(m["v"], es) if m else None
    return guia, kpi

def plano(html):
    return BeautifulSoup(html, "html.parser").get_text(" ")

textos = pd.concat([
    pd.Series([plano(t) for t in fil["texto"]],
              index=fil["id_doc"]),
    ll.groupby("id_llamada")["texto"].apply(" ".join)])
extr = {i: reglas(t) for i, t in textos.items()}
print(len(extr), "textos;", reglas(textos["D000010"]))
OUT [02]Reglas para previsiones y KPIs
4860 textos; (('2019T1', 19710.0, 20550.0), 441.0)

La función de evaluación sirve para cualquier método: recibe, por texto, la previsión (periodo, bajo, alto) y el KPI extraídos (o None), y los compara con la verdad.

IN [03]python
G = verdad[verdad["tipo"] == "guidance"].set_index("id")
K = verdad[verdad["tipo"] == "kpi"].set_index("id")

def cerca(a, b, tol=0.005):
    return a is not None and abs(a / b - 1) < tol

def evaluar(extr, ids):
    filas = {}
    for i in ids:
        guia, kpi = extr[i]
        hay_g, hay_k = i in G.index, i in K.index
        imp_g = hay_g and guia is not None and (
            cerca(guia[1], G.loc[i, "bajo"])
            and cerca(guia[2], G.loc[i, "alto"]))
        ok_g = imp_g and guia[0] == G.loc[i, "periodo"]
        dig_g = ok_g and (round(guia[1]) == G.loc[i, "bajo"]
                          and round(guia[2]) == G.loc[i, "alto"])
        ok_k = hay_k and cerca(kpi, K.loc[i, "valor"])
        filas[i] = dict(g_ext=guia is not None, g_ver=hay_g,
                        g_imp=imp_g, g_ok=ok_g, g_dig=dig_g,
                        k_ext=kpi is not None, k_ver=hay_k,
                        k_ok=ok_k)
    d = pd.DataFrame(filas).T.astype(int).sum()
    return pd.Series({
        "previsión: precisión": d.g_ok / max(d.g_ext, 1),
        "previsión: recall": d.g_ok / d.g_ver,
        "previsión: importes": d.g_imp / d.g_ver,
        "previsión: al dígito": d.g_dig / max(d.g_ok, 1),
        "KPI: precisión": d.k_ok / max(d.k_ext, 1),
        "KPI: recall": d.k_ok / d.k_ver}).round(3)

pd.DataFrame({"documentos": evaluar(extr, fil["id_doc"]),
              "llamadas": evaluar(extr, ll["id_llamada"].unique())})
OUT [03]Precisión, recall y exactitud de las reglas
                      documentos  llamadas
previsión: precisión       1.000     1.000
previsión: recall          1.000     1.000
previsión: importes        1.000     1.000
previsión: al dígito       0.735     0.765
KPI: precisión             0.969     1.000
KPI: recall                1.000     1.000

Las reglas encuentran todas las previsiones de los 4.020 documentos y las 840 llamadas, con el periodo y los importes correctos (precisión y recall de 1,000). Al dígito aciertan el 73,5 % en documentos y el 76,5 % en llamadas: el resto son previsiones escritas en miles de millones con dos decimales («$20.55 billion», «2,57 mil millones»), donde el texto no tiene la precisión de la verdad: en D000010, 20.550 frente a 20.545. Ninguna regla puede recuperar lo que el texto no dice; por eso la tolerancia del 0,5 % es la medida justa y la exactitud al dígito, un recordatorio de lo que se pierde.

La única mancha es la precisión del KPI en documentos, 0,969. Los 120 «falsos positivos» son las enmiendas (96 10-Q/A y 24 10-K/A): repiten el KPI del documento original y la verdad no las cuenta como documentos con KPI. No es un error de la regla, sino de la definición de la verdad, y solo se ve al leer los errores uno a uno. Antes de creerse una métrica, se miran sus fallos.

Una advertencia que pesa más que la tabla: estas reglas se escribieron mirando las frases. Los datos sintéticos usan cinco plantillas de previsión y unas pocas de KPI, y una regla escrita para ellas las encuentra todas. En filings reales, cada empresa redacta a su manera, cambia de redacción con los años y mezcla cifras en tablas, notas y párrafos; una regla necesita mantenimiento continuo y una medida de cobertura que avise cuando deja de encontrar.

Fig. — Las 3.920 previsiones de ventas de los datos del nivel 6 (documentos y llamadas) salen de ocho plantillas de frase

La figura cuenta las previsiones por plantilla, con cifras y trimestres sustituidos por marcadores: ocho maneras de escribirlas, cuatro por idioma, y la de «between … billion», en miles de millones con dos decimales, es la que impide acertar al dígito.

El LLM sobre una muestra

El modelo es el Qwen2.5-0.5B-Instruct de las lecciones 6.2.1 y 6.2.2, con un prompt estructurado que pide la previsión y el KPI a la vez. Un LLM de este tamaño tarda en la CPU lo que un humano en leer, así que la medida usa una muestra: 14 documentos (8-K, 10-Q y comunicados tipo CNMV, recortados a su texto sin tablas) y 6 llamadas (los turnos de la dirección en la presentación). Las 20 peticiones se ordenan por longitud antes de agruparlas en lotes: así cada lote lleva menos relleno.

IN [04]python
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
NOMBRE = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(NOMBRE)
tok.padding_side = "left"
llm = AutoModelForCausalLM.from_pretrained(
    NOMBRE, dtype=torch.float32).eval()

def recorte(html):
    t = re.sub(r"<table>.*?</table>", "", html, flags=re.S)
    t = "\n".join(l for l in t.splitlines()
                  if not l.startswith("|") and "ynthetic" not in l
                  and "intético" not in l)
    t = BeautifulSoup(t, "html.parser").get_text("\n")
    return re.sub(r"\n\s*\n+", "\n", t.replace("*", "")).strip()

items = fil["items"].fillna("").astype(str)
cand = fil[fil["tipo"].isin(["8-K", "10-Q", "OIR", "HR"])
           & ~items.str.contains("4.02")]
m_docs = cand.sample(14, random_state=5)
pres = ll[(ll["rol"] == "directivo")
          & (ll["seccion"] == "presentacion")]
pres = pres.groupby("id_llamada")["texto"].apply(" ".join)
m_llam = pres.sample(6, random_state=5)
muestra = pd.concat([
    pd.Series([recorte(t) for t in m_docs["texto"]],
              index=m_docs["id_doc"]), m_llam])

SISTEMA = (
    "Extrae del texto (1) la previsión de ventas del trimestre "
    "siguiente en millones de USD (1 billion = 1000) y (2) el KPI "
    "operativo al cierre del trimestre, tal como está escrito. "
    "Responde solo con JSON: {\"periodo\": \"AAAAT1-4\" o null, "
    "\"bajo\": number o null, \"alto\": number o null, "
    "\"kpi\": number o null}")

def generar(textos_usuario, sistema, max_tokens=48, lote=10):
    prompts = [tok.apply_chat_template(
        [{"role": "system", "content": sistema},
         {"role": "user", "content": t}],
        tokenize=False, add_generation_prompt=True)
        for t in textos_usuario]
    orden = np.argsort([len(p) for p in prompts])
    salida = [None] * len(prompts)
    for k in range(0, len(orden), lote):
        sel = orden[k:k + lote]
        b = tok([prompts[j] for j in sel], return_tensors="pt",
                padding=True)
        with torch.inference_mode():
            o = llm.generate(**b, do_sample=False,
                             max_new_tokens=max_tokens,
                             pad_token_id=tok.eos_token_id)
        n = b["input_ids"].shape[1]
        for j, fila in zip(sel, o):
            salida[j] = tok.decode(fila[n:],
                                   skip_special_tokens=True)
    return salida

resp = generar(list(muestra), SISTEMA)
print(len(resp), "respuestas;", resp[0].replace("`", "'")[:70])
OUT [04]Extracción con el LLM en 20 textos
20 respuestas; {"periodo": "AAAAT1-4", "bajo": null, "alto": 11743, "kpi": 173}

Las respuestas se leen con la misma tolerancia que en la lección 6.2.2: primer objeto JSON, números escritos como texto convertidos y periodos tipo 2024Q4 normalizados a 2024T4. Todo lo que no se puede leer cuenta como «no extraído».

IN [05]python
def a_numero(v):
    if isinstance(v, (int, float)):
        return float(v)
    if isinstance(v, str):
        s = re.sub(r"[^\d.]", "", v.replace(",", "."))
        return float(s) if s.count(".") <= 1 and s else None
    return None

def leer(texto):
    m = re.search(r"\{.*?\}", texto, flags=re.S)
    try:
        d = json.loads(m.group(0)) if m else {}
    except json.JSONDecodeError:
        d = {}
    q = re.search(r"(\d{4})\s*[TQ]\s*0?([1-4])",
                  str(d.get("periodo")))
    per = f"{q.group(1)}T{q.group(2)}" if q else None
    bajo, alto = a_numero(d.get("bajo")), a_numero(d.get("alto"))
    guia = (per, bajo, alto) if bajo and alto else None
    return guia, a_numero(d.get("kpi"))

extr_llm = {i: leer(r) for i, r in zip(muestra.index, resp)}
pd.DataFrame({"reglas": evaluar(extr, muestra.index),
              "LLM 0,5B": evaluar(extr_llm, muestra.index)})
OUT [05]Leer las respuestas y comparar con las reglas
                      reglas  LLM 0,5B
previsión: precisión   1.000     0.000
previsión: recall      1.000     0.000
previsión: importes    1.000     0.053
previsión: al dígito   0.737     0.000
KPI: precisión         1.000     0.000
KPI: recall            1.000     0.000

La primera respuesta ya enseña el problema principal: el modelo ha copiado literalmente el marcador del formato, "periodo": "AAAAT1-4", en vez de escribir el trimestre. Los modelos pequeños copian los ejemplos y las plantillas del prompt con facilidad; un marcador así se sustituye por un ejemplo concreto o, mejor, por salidas estructuradas (lección 6.2.2).

En la muestra, las reglas aciertan todo y el LLM nada: ni un periodo, ni un KPI, y los importes de la previsión solo en 1 de los 19 textos que la traen (5,3 %). Con el periodo copiado del marcador, ninguna previsión completa puede ser correcta; por eso la tabla separa la fila de «importes», que no exige el periodo, para ver si al menos lee las cifras. Tampoco.

IN [06]python
filas = []
for i in muestra.index[[0, 1, 2, 3, 14, 15]]:
    guia, kpi = extr_llm[i]
    filas.append({
        "id": i,
        "previsión LLM": guia,
        "verdad": (tuple(G.loc[i, ["periodo", "bajo", "alto"]])
                   if i in G.index else None),
        "KPI LLM": kpi,
        "KPI verdad": K.loc[i, "valor"] if i in K.index else None})
pd.DataFrame(filas).set_index("id")
OUT [06]Qué ha extraído el LLM, frente a la verdad
                  previsión LLM                      verdad    KPI LLM  KPI verdad
id
D001138                    None  (2020T4, 11570.0, 12027.0)     173.00     1462.00
D001430                    None  (2021T2, 12093.0, 12472.0)   12093.00     1327.00
D002985                    None  (2024T1, 28516.0, 30074.0)       0.88      633.00
D002444                    None    (2023T2, 6587.0, 6986.0)  699000.00     3625.00
C00801   (None, 7297.7, 7437.0)    (2025T3, 7064.0, 7437.0)    1007.70       14.28
C00468                     None    (2022T4, 3753.0, 3948.0)        NaN     1253.00

La tabla muestra seis casos. En cuatro documentos el modelo no devuelve una previsión legible y pone como KPI cualquier cifra: 173, el extremo bajo de la previsión (12.093), 0,88 o 699.000, cuando el KPI está escrito tal cual en el texto. En la llamada C00801 lee el extremo alto (7.437), se inventa el bajo (7.297,7 en vez de 7.064) y escribe como KPI 1.007,7 donde el texto dice 14,28 millones. No son errores de formato: el modelo no localiza la cifra pedida.

Frases que la regla no ha visto

Las reglas aciertan porque conocen las plantillas. Para ver qué pasa con redacciones nuevas, la celda siguiente usa ocho frases escritas para esta lección, con su respuesta, que no siguen ninguna plantilla de los datos; la última no trae previsión.

IN [07]python
nuevas = [
    ("We now see third-quarter 2024 sales landing somewhere "
     "around $1.2bn to $1.3bn.", ("2024T3", 1200, 1300)),
    ("Management guided Q2 2025 net sales to USD 845 to 870 "
     "million.", ("2025T2", 845, 870)),
    ("For Q1 2025 we expect revenue of $3.40 to $3.55 billion.",
     ("2025T1", 3400, 3550)),
    ("Q4 2024 outlook: top line between 12.5 and 13.1 billion "
     "dollars.", ("2024T4", 12500, 13100)),
    ("La dirección estima que la cifra de negocio del tercer "
     "trimestre de 2024 rondará los 950 a 1.000 millones de "
     "dólares.", ("2024T3", 950, 1000)),
    ("Esperamos ingresos de 2.100 millones de dólares como mínimo "
     "y de 2.250 millones como máximo en el cuarto trimestre de "
     "2023.", ("2023T4", 2100, 2250)),
    ("Prevemos unas ventas de 1,15 a 1,22 mil millones de dólares "
     "para el segundo trimestre de 2025.", ("2025T2", 1150, 1220)),
    ("No damos previsiones para el próximo trimestre.", None),
]
r_nuevas = generar([f for f, _ in nuevas], SISTEMA)

def importes(guia, esperado):
    if esperado is None:
        return guia is None
    return guia is not None and cerca(guia[1], esperado[1]) and (
        cerca(guia[2], esperado[2]))

def completo(guia, esperado):
    return importes(guia, esperado) and (
        esperado is None or guia[0] == esperado[0])

def si(x):
    return "sí" if x else "no"

for (frase, esp), r in zip(nuevas, r_nuevas):
    g_reg, g_llm = reglas(frase)[0], leer(r)[0]
    print(f"reglas {si(completo(g_reg, esp))} · LLM "
          f"{si(completo(g_llm, esp))} (importes "
          f"{si(importes(g_llm, esp))}) · {g_llm}")
OUT [07]Ocho frases nuevas: reglas frente a LLM
reglas no · LLM no (importes no) · (None, 1.2, 1.3)
reglas no · LLM no (importes no) · (None, 839.0, 870.0)
reglas no · LLM no (importes no) · (None, 3.4, 3.55)
reglas no · LLM no (importes no) · (None, 12.5, 13.1)
reglas no · LLM no (importes sí) · (None, 950.0, 1000.0)
reglas no · LLM no (importes sí) · (None, 2100.0, 2250.0)
reglas no · LLM no (importes sí) · (None, 1150.0, 1220.0)
reglas sí · LLM sí (importes sí) · None

Las reglas no encuentran ninguna de las siete previsiones nuevas, porque ninguna sigue sus plantillas; solo aciertan la octava, que no trae previsión, por no encontrar nada. El LLM no acierta ninguna completa (vuelve a dejar el periodo en None), pero lee bien los importes de las tres frases en español, también la de «1,15 a 1,22 mil millones», que convierte a 1.150 y 1.220. En las inglesas falla las unidades (1,2 y 1,3 en vez de 1.200 y 1.300 millones; 3,4 y 12,5 miles de millones sin convertir) y en una lee 839 donde dice 845.

Ese es el argumento a favor de los LLM en extracción: generalizan a redacciones que nadie previó. Con un modelo de 0,5B lo hacen a medias; con uno grande, la conversión de unidades y el periodo son el tipo de instrucción que siguen bien, aunque hay que medirlo igual que aquí.

Fig. — Previsiones de ventas con los dos importes correctos (error < 0,5 %): reglas frente al LLM de 0,5B, en textos de plantilla de la muestra y en siete frases redactadas para la lección

La figura resume la extracción de previsiones: en los textos de plantilla las reglas lo aciertan todo y el LLM casi nada; en frases nuevas, las reglas no encuentran nada y el LLM lee bien los importes de tres de las siete.

El tono de las preguntas en las conference calls

Para clasificar no hace falta generar texto. Con un prompt que pide responder con una palabra (positive, negative o neutral), basta una pasada hacia delante y mirar la probabilidad que el modelo da a cada una de las tres palabras como siguiente token. La diferencia ppos−pnegp_{pos} - p_{neg}, normalizada por la masa de las tres, es una puntuación continua entre −1 y 1; y la salida no puede salirse del formato, porque se lee directamente de los logits (la idea de la decodificación restringida, lección 6.2.2).

s=ppositive−pnegativeppositive+pnegative+pneutrals = \frac{p_{\text{positive}} - p_{\text{negative}}} {p_{\text{positive}} + p_{\text{negative}} + p_{\text{neutral}}}
IN [08]python
ETIQUETAS = ["positive", "negative", "neutral"]
IDS = [tok(e)["input_ids"][0] for e in ETIQUETAS]
print([tok.decode([i]) for i in IDS])

def tono_llm(textos, sistema, lote=10):
    prompts = [tok.apply_chat_template(
        [{"role": "system", "content": sistema},
         {"role": "user", "content": t}],
        tokenize=False, add_generation_prompt=True)
        for t in textos]
    orden = np.argsort([len(p) for p in prompts])
    P = np.zeros((len(textos), 3))
    for k in range(0, len(orden), lote):
        sel = orden[k:k + lote]
        b = tok([prompts[j] for j in sel], return_tensors="pt",
                padding=True)
        with torch.inference_mode():
            z = llm(**b).logits[:, -1].double()
        P[sel] = torch.softmax(z, -1)[:, IDS].numpy()
    return (P[:, 0] - P[:, 1]) / P.sum(1), P.sum(1)
OUT [08]Puntuación de tono por las probabilidades del siguiente token
['positive', 'negative', 'neutral']

Las tres palabras se codifican como un solo token cada una, así que basta la probabilidad del primer token de la respuesta. Las preguntas de los analistas de cada llamada se juntan en un texto. El diccionario es el léxico financiero ficticio del curso (texto6_lexico.csv, con las categorías de Loughran y McDonald, lección 6.1.1) y su puntuación, (pos−neg)/(pos+neg+1)(pos - neg)/(pos + neg + 1):

IN [09]python
lex = pd.read_csv("texto6_lexico.csv")
lex = lex[lex["lista"] == "financiera"]
POS = set(lex.loc[lex["categoria"] == "positiva", "palabra"])
NEG = set(lex.loc[lex["categoria"] == "negativa", "palabra"])

def diccionario(t):
    w = re.findall(r"[a-záéíóúñü]+", t.lower())
    p, n = sum(x in POS for x in w), sum(x in NEG for x in w)
    return (p - n) / (p + n + 1)

lv = pd.read_csv("llamadas6_verdad.csv").set_index("id_llamada")
preg = (ll[ll["rol"] == "analista"].groupby("id_llamada")
        ["texto"].apply(" ".join))
llam = lv[["tono_analistas"]].join(preg)
llam["dicc"] = llam["texto"].map(diccionario)
m_tono = llam.sample(20, random_state=0)
SIS_TONO = ("These are analysts' questions in an earnings call. Is "
            "their tone about the company positive, negative or "
            "neutral? Answer with one word.")
m_tono["llm"], masa = tono_llm(list(m_tono["texto"]), SIS_TONO)
print(f"masa de las tres etiquetas: {masa.mean():.3f}")
print("840 llamadas, diccionario:",
      round(llam[["dicc", "tono_analistas"]].corr().iloc[0, 1], 3))
m_tono[["llm", "dicc", "tono_analistas"]].corr().round(3)
OUT [09]Tono de los analistas: diccionario y LLM frente a la verdad
masa de las tres etiquetas: 0.343
840 llamadas, diccionario: 0.887
                  llm   dicc  tono_analistas
llm             1.000  0.755           0.759
dicc            0.755  1.000           0.924
tono_analistas  0.759  0.924           1.000

El diccionario recupera el tono de los analistas con una correlación de 0,887 en las 840 llamadas y de 0,924 en la muestra de 20; el LLM, de 0,759 en la misma muestra. Las tres palabras reciben solo el 34,3 % de la probabilidad del siguiente token (el resto se reparte entre otros comienzos posibles de la respuesta), y la normalización de la fórmula corrige esa escala.

Que gane el diccionario no es una prueba de que los diccionarios superen a los LLM en conference calls: el README de los datos advierte que las preguntas reutilizan frases de plantilla escritas con las palabras del léxico, de modo que el diccionario lo tiene mucho más fácil que con transcripciones reales. En noticias, con frases fuera del léxico y negaciones, el contraste es más realista.

Sentimiento de noticias: LLM, FinBERT y diccionario

FinBERT (ProsusAI/finbert) es un BERT afinado con el Financial PhraseBank para clasificar frases en positivo, negativo y neutral (Araci, 2019); sus pesos se publican con licencia CC-BY-NC-SA-4.0, que no permite el uso comercial (lección 6.1.1). Solo entiende inglés, así que la comparación usa noticias originales en inglés: 40 para los tres métodos y 500 para FinBERT y el diccionario, que son baratos.

IN [10]python
from transformers import pipeline

noti = pd.read_parquet("noticias6_corpus.parquet").merge(
    pd.read_csv("noticias6_verdad.csv"), on="id_noticia")
en = noti[(noti["idioma"] == "en")
          & (noti["tipo_noticia"] == "original")]
grande = en.sample(500, random_state=0)
txt = list(grande["titular"] + ". " + grande["cuerpo"])
verd = grande["sentimiento_verdadero"].to_numpy()

finbert = pipeline("text-classification", model="ProsusAI/finbert",
                   device="cpu")

def puntua_finbert(textos):
    out = finbert(textos, top_k=None, batch_size=32)
    d = [{x["label"]: x["score"] for x in o} for o in out]
    return np.array([x["positive"] - x["negative"] for x in d])

s_fb = puntua_finbert(txt)
s_dic = np.array([diccionario(t) for t in txt])
SIS_NOT = ("Classify the news for the company's shareholders. "
           "Answer with one word: positive, negative or neutral.")
s_llm, _ = tono_llm(txt[:40], SIS_NOT)

def clase(s, umbral=0.0):
    return np.sign(np.where(np.abs(s) <= umbral, 0, s))

filas = {}
for nombre, s, n in [("LLM 0,5B", s_llm, 40),
                     ("FinBERT", s_fb[:40], 40),
                     ("diccionario", s_dic[:40], 40),
                     ("FinBERT", s_fb, 500),
                     ("diccionario", s_dic, 500)]:
    filas[(nombre, n)] = {
        "correlación": np.corrcoef(s, verd[:n])[0, 1],
        "acierto 3 clases": np.mean(
            clase(s, 0.2) == np.sign(verd[:n]))}
pd.DataFrame(filas).T.round(3)
OUT [10]Noticias en inglés: tres métodos frente al sentimiento verdadero
                 correlación  acierto 3 clases
LLM 0,5B    40         0.868             0.725
FinBERT     40         0.917             0.875
diccionario 40         0.592             0.675
FinBERT     500        0.881             0.860
diccionario 500        0.702             0.712

En las 40 noticias en inglés, FinBERT tiene la correlación más alta con el sentimiento verdadero (0,917) y acierta la clase en el 87,5 % de los casos; el LLM de 0,5B queda cerca en correlación (0,868) y peor en clases (72,5 %); el diccionario, muy por detrás (0,592 y 67,5 %). La figura de la cabecera reúne estas correlaciones y las de las llamadas. Con 500 noticias, FinBERT da 0,881 y el diccionario 0,702, en línea con la correlación de 0,71 que el README mide con todas las noticias. Las clases usan un umbral de 0,2 sobre la puntuación (por debajo, neutral).

El contraste con la extracción es la lección más útil. El mismo modelo de 0,5B que no localiza una cifra clasifica el tono de una noticia casi tan bien como un modelo afinado para eso. Clasificar el tono de un texto corto es una tarea que el preentrenamiento cubre bien y que no exige seguir instrucciones con precisión; localizar una cifra concreta, convertir sus unidades y no confundirla con otra, sí. El tamaño del modelo pesa mucho más en la segunda.

Lo que cambiaría un modelo mayor

Esta lección no puede ejecutar un modelo grande, así que lo que sigue son resultados publicados, con su fuente:

  • El tamaño importa en las pruebas de conocimiento. En el informe técnico de Qwen2.5, el modelo base de 0,5B parámetros obtiene 47,5 en MMLU y el de 72B, 86,1 (tablas 5 y 2); en la versión instruida, MMLU-Pro pasa de 15,0 a 71,1 (tablas 6 y 10)1Nota 1 · Qwen Team (2024): «Qwen2.5 Technical Report», arXiv:2412.15115 (https://arxiv.org/abs/2412.15115)..
  • Y en finanzas. Lopez-Lira y Tang (versión 6, octubre de 2025) escriben en su resumen que «forecasting ability generally increases with model size»; en el cuerpo del artículo, los modelos básicos (GPT-1, GPT-2, BERT) dan aciertos por debajo del 65 % al puntuar titulares, y sus puntuaciones no se correlacionan de forma significativa con la deriva posterior de los rendimientos.
  • Algunas técnicas de prompting solo funcionan a escala. Wei et al. (2022) observan que pedir un razonamiento paso a paso «only yields performance gains when used with models of ∼100B parameters»2Nota 2 · Wei, J. et al. (2022): «Chain-of-Thought Prompting Elicits Reasoning in Large Language Models», arXiv:2201.11903 (https://arxiv.org/abs/2201.11903), sección 3.2.: no tiene sentido probarlo con un modelo de 0,5B y concluir que no sirve.
  • Un modelo pequeño y especializado puede bastar. Huang, Wang y Yang (2023) muestran que su FinBERT supera al diccionario de Loughran-McDonald y a otros algoritmos de aprendizaje automático en clasificación de sentimiento: para una tarea acotada, un modelo de tipo BERT afinado es una línea base dura.

Con un modelo comercial, las palancas de prompting de la lección 6.2.2 cambian el resultado de la extracción: el esquema completo con salidas estructuradas, ejemplos resueltos en el prompt (sobre todo de las conversiones de unidades), un campo de texto para que el modelo copie la frase de la que saca la previsión (que además permite comprobarla) y separar la búsqueda de la frase de la lectura de los números. Lo que no cambia es el método de esta lección: medir contra una verdad, con precisión, cobertura y exactitud, antes de usar el dato en una señal.

Ejercicios

  1. Con las previsiones que extraen las reglas de los documentos, calcula para cada una el logaritmo del punto medio entre el consenso de ventas de ese trimestre (acciones6_ventas.csv) y su correlación con la sorpresa de ventas, log⁡(ventas/consenso)\log(\text{ventas}/\text{consenso}). ¿Coincide con la que da el README (0,18)?
Solución
IN [11]python
ventas = pd.read_csv("acciones6_ventas.csv")
tk = pd.read_csv("acciones6_tickers.csv")
emp = pd.read_csv("acciones6_empresas.csv")
actual = (tk.merge(emp[["id_empresa", "ticker"]], on="id_empresa",
                   suffixes=("", "_actual"))
          .drop_duplicates("ticker")
          .set_index("ticker")["ticker_actual"])
tick_doc = fil.set_index("id_doc")["ticker"].map(actual)

filas = []
for i, (guia, _) in extr.items():
    if i in tick_doc.index and guia is not None and guia[0]:
        fin = pd.Period(guia[0].replace("T", "Q")).end_time
        filas.append({"ticker": tick_doc[i],
                      "fin_trimestre": fin.strftime("%Y-%m-%d"),
                      "medio": (guia[1] + guia[2]) / 2})
pg = (pd.DataFrame(filas)
      .drop_duplicates(["ticker", "fin_trimestre"])
      .merge(ventas, on=["ticker", "fin_trimestre"]))
x = np.log(pg["medio"] / pg["consenso_ventas"])
y = np.log(pg["ventas"] / pg["consenso_ventas"])
print(len(pg), "trimestres · correlación",
      round(np.corrcoef(x, y)[0, 1], 3))
OUT [11]La previsión frente al consenso anticipa parte de la sorpresa
1713 trimestres · correlación 0.177

Coincide: 1.713 trimestres y una correlación de 0,177, la del README redondeada (0,18). La previsión de la dirección lleva información que el consenso no tiene, pero poca (la correlación con el rendimiento del día de resultados es prácticamente nula, 0,02, según el mismo README). Las reglas reproducen el resultado porque extraen todas las previsiones; con el LLM de 0,5B de la lección no habría ni una previsión completa con la que calcularlo.

  1. Repite la comparación de noticias con el umbral de clase en 0,1 y en 0,4 para FinBERT y el diccionario sobre las 500 noticias. ¿Qué método es más sensible al umbral y por qué?
Solución
IN [12]python
pd.DataFrame({u: {"FinBERT": np.mean(clase(s_fb, u)
                                     == np.sign(verd)),
                  "diccionario": np.mean(clase(s_dic, u)
                                         == np.sign(verd))}
              for u in [0.1, 0.2, 0.4]}).round(3)
OUT [12]Sensibilidad al umbral de clase
               0.1    0.2    0.4
FinBERT      0.866  0.860  0.868
diccionario  0.712  0.712  0.678

FinBERT apenas cambia (entre 0,860 y 0,868): sus puntuaciones ppos−pnegp_{pos} - p_{neg} suelen estar cerca de −1, 0 o 1, porque la softmax de un clasificador afinado es muy segura, y pocas caen entre 0,1 y 0,4. El diccionario baja con 0,4 (de 0,712 a 0,678): su puntuación toma valores discretos (±1/4\pm 1/4, ±1/2\pm 1/2, ±2/3\pm 2/3…) y con 0,4 las noticias de ±1/4\pm 1/4 pasan a neutras. Un umbral se elige con datos de validación, no con los de la evaluación.

  1. Escribe, sin ejecutarlo, el prompt que usarías con un modelo comercial para extraer el KPI operativo de las llamadas, y di cómo comprobarías cada respuesta sin leerla a mano.
Solución

Un prompt de sistema con el rol («Extraes datos de transcripciones de resultados»), la definición del KPI de cada sector (tiendas, suscriptores, producción…), la unidad tal como está escrita y la regla de no convertirla, el caso sin dato (null) y dos o tres ejemplos resueltos, uno en cada idioma y uno sin KPI. El formato, con salidas estructuradas y un modelo de Pydantic con metrica, valor, unidad y frase (la frase literal de la que sale el número).

La comprobación sin leer: que frase aparezca tal cual en la transcripción, que valor aparezca escrito en frase (con la convención de números del idioma), que la metrica sea la del sector de la empresa, y que el valor sea plausible frente al trimestre anterior de la misma empresa (un salto de 1.000 veces delata un error de unidades). Lo que falle se revisa a mano y se cuenta: esa tasa es la precisión real del sistema.

Footnotes

  1. Qwen Team (2024): «Qwen2.5 Technical Report», arXiv:2412.15115 (https://arxiv.org/abs/2412.15115). ↩

  2. Wei, J. et al. (2022): «Chain-of-Thought Prompting Elicits Reasoning in Large Language Models», arXiv:2201.11903 (https://arxiv.org/abs/2201.11903), sección 3.2. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. Las expresiones regulares de la lección aciertan prácticamente el 100 % de las previsiones de 4.020 documentos. ¿Qué conclusión es correcta?

    • AQue las reglas son mejores que cualquier LLM para extraer previsiones
    • BQue el LLM no aporta nada en finanzas
    • CQue los datos son fáciles
    • DQue las reglas se escribieron mirando las pocas plantillas con las que se generaron las frases; con redacciones nuevas su cobertura cae a cero, y en filings reales la variedad es enorme
    Ver respuesta

    Respuesta D. Que las reglas se escribieron mirando las pocas plantillas con las que se generaron las frases; con redacciones nuevas su cobertura cae a cero, y en filings reales la variedad es enorme

    Las frases sintéticas salen de ocho plantillas. Una regla escrita para ellas las encuentra todas y no encuentra ninguna de las frases redactadas a mano de la lección. Con texto real, una regla necesita mantenimiento continuo y una medida de cobertura; un LLM generaliza mejor, si es lo bastante grande.

  2. ¿Por qué la lección clasifica el sentimiento con las probabilidades de los tokens «positive», «negative» y «neutral» en vez de dejar que el modelo escriba la respuesta?

    • APorque una sola pasada hacia delante da una puntuación continua (p. positivo − p. negativo), sin generar texto, sin respuestas fuera de formato y mucho más rápido
    • BPorque el modelo no sabe escribir
    • CPorque FinBERT lo exige
    • DPorque así el modelo no puede equivocarse
    Ver respuesta

    Respuesta A. Porque una sola pasada hacia delante da una puntuación continua (p. positivo − p. negativo), sin generar texto, sin respuestas fuera de formato y mucho más rápido

    La salida queda restringida a tres opciones (la idea de la decodificación restringida, lección 6.2.2) y la diferencia de probabilidades sirve de puntuación para correlacionar con la verdad o para construir una señal. Equivocarse se puede equivocar igual.

  3. En las noticias en inglés de la muestra, el LLM de 0,5B tiene una correlación con el sentimiento verdadero cercana a la de FinBERT (0,87 frente a 0,92) y mucho mayor que la del diccionario (0,59); en la extracción de previsiones el mismo LLM casi no acierta. ¿Cómo se explica?

    • AEl LLM es mejor que FinBERT
    • BClasificar el tono de una frase corta es una tarea que un modelo pequeño resuelve con lo aprendido en el preentrenamiento; extraer una cifra, convertir unidades y distinguir la previsión de las ventas del trimestre exige seguir instrucciones con precisión, que es lo que más mejora con el tamaño
    • CLas noticias son más largas que los filings
    • DEs casualidad: con otra semilla sería al revés
    Ver respuesta

    Respuesta B. Clasificar el tono de una frase corta es una tarea que un modelo pequeño resuelve con lo aprendido en el preentrenamiento; extraer una cifra, convertir unidades y distinguir la previsión de las ventas del trimestre exige seguir instrucciones con precisión, que es lo que más mejora con el tamaño

    Las dos tareas exigen capacidades distintas. Los informes técnicos de modelos (por ejemplo, el de Qwen2.5) muestran que las pruebas de conocimiento y de seguimiento de instrucciones mejoran mucho con el tamaño; un modelo de 0,5B sirve para una clasificación sencilla y no para una extracción fiable.

  4. El diccionario recupera el tono de las preguntas de los analistas con una correlación de casi 0,9, mejor que el LLM. ¿Qué advierte el README de los datos?

    • AQue el diccionario de Loughran-McDonald es perfecto
    • BQue las llamadas están en inglés
    • CQue las preguntas reutilizan frases de plantilla escritas con las palabras del léxico, así que el diccionario lo tiene mucho más fácil que con textos reales; en noticias, con frases fuera del léxico y negaciones, el contraste es más realista
    • DQue el LLM está mal configurado
    Ver respuesta

    Respuesta C. Que las preguntas reutilizan frases de plantilla escritas con las palabras del léxico, así que el diccionario lo tiene mucho más fácil que con textos reales; en noticias, con frases fuera del léxico y negaciones, el contraste es más realista

    El tono de las preguntas se escribe con frases del mismo léxico ficticio que usa el diccionario. Es un resultado de los datos, no una prueba de que los diccionarios superen a los modelos en llamadas reales.

  5. Un estudio muy citado sobre análisis de estados financieros con LLM (Kim, Muhn y Nikolaev, arXiv:2407.17866) aparece retirado en arXiv. ¿Qué hacer con su resultado?

    • ACitarlo igualmente: tuvo mucha difusión
    • BNo apoyarse en él mientras esté retirado: los autores lo retiraron al detectar inconsistencias en los datos y análisis al replicarlo; comprobar siempre el estado de la versión que se cita
    • CUsar solo la primera versión, que no está retirada
    • DSustituirlo por un artículo de prensa que lo resuma
    Ver respuesta

    Respuesta B. No apoyarse en él mientras esté retirado: los autores lo retiraron al detectar inconsistencias en los datos y análisis al replicarlo; comprobar siempre el estado de la versión que se cita

    La versión 3 (20 de febrero de 2025) lleva el aviso de retirada temporal por inconsistencias detectadas al replicar los análisis. Una cifra de un trabajo retirado no es evidencia; la versión 1 tampoco, porque es el mismo trabajo.

Ideas clave

  • Una extracción se mide como un clasificador con números: precisión (lo extraído es correcto), cobertura (se extrae todo lo que hay) y exactitud numérica (dentro de una tolerancia y al dígito).
  • En textos de plantilla, unas pocas expresiones regulares lo extraen casi todo; con redacciones nuevas su cobertura cae a cero. Una regla mide su propia cobertura, o falla en silencio.
  • El LLM de 0,5B extrae mal previsiones y KPIs: confunde las ventas del trimestre con la previsión, no convierte miles de millones y cambia de trimestre. Es un límite del tamaño, no del método.
  • Para clasificar el tono basta una pasada: las probabilidades de «positive», «negative» y «neutral» dan una puntuación continua. En noticias, el LLM pequeño se acerca a FinBERT (0,87 frente a 0,92 de correlación) y supera al diccionario (0,59).
  • En las llamadas sintéticas, el diccionario gana porque las preguntas reutilizan frases del léxico; en textos reales el orden puede cambiar.
  • La evidencia publicada apunta a que la capacidad de extraer y de razonar sobre texto financiero crece con el tamaño del modelo; cada afirmación se comprueba en su fuente (y en su versión: hay trabajos retirados).

Recursos