---
title: "6.2.3 Extracción de guidance, KPIs y tono con LLM"
description: "Previsiones, KPIs y tono de filings, conference calls y noticias: reglas, un LLM local, FinBERT y diccionario medidos contra la verdad conocida."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/extraccion-de-guidance-kpis-y-tono-con-llm/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/extraccion-de-guidance-kpis-y-tono-con-llm.png"
---

# 6.2.3 · 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.

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.2 · LLMs para investigación y trading
- Duración: 75 min
- Requisitos: [6.2.2 · Prompting estructurado: JSON que se puede validar](https://pythonparatrading.com/curso-python-trading/nivel-6/prompting-estructurado-y-json-con-pydantic/), [6.2.1 · Cómo funciona un LLM: del siguiente token a la API](https://pythonparatrading.com/curso-python-trading/nivel-6/como-funcionan-los-llm-apis-y-modelos-locales/), [6.1.1 · Tokenización, embeddings, diccionarios financieros y FinBERT](https://pythonparatrading.com/curso-python-trading/nivel-6/tokenizacion-embeddings-loughran-mcdonald-y-finbert/)

## Al terminar esta lección

- Medir una extracción con precisión, cobertura (recall) y exactitud numérica frente a una verdad conocida.
- Construir una línea base de reglas (expresiones regulares) para previsiones y KPIs, y entender por qué gana en textos de plantilla.
- Usar un LLM local para extraer datos y para clasificar el tono por las probabilidades del siguiente token, sin generar texto.
- Comparar el sentimiento de un LLM con FinBERT y con un diccionario en noticias, y el tono de las preguntas de los analistas en las llamadas.
- Separar lo que se debe al tamaño del modelo de lo que se debe al método, citando la evidencia publicada.

## 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:

```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.

```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"]]
```
```output
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 $E$ los datos extraídos, $V$ los verdaderos y
$E \cap V$ los extraídos que coinciden con uno verdadero:

$$
\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).

```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"]))
```
```output
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.

```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())})
```
```output
                      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.* Ocho barras horizontales con el número de previsiones por plantilla: en inglés, «range of … million» 1.124, «outlook … $N-N million» 1.090, «between … billion» 855 (destacado: cifras con dos decimales en miles de millones que pierden precisión) y «between … million» 274; en español, «entre … millones» 212, «horquilla de N-N» 188, «entre … mil millones» 171 y «facturar … millones» 6.

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.

```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])
```
```output
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».

```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)})
```
```output
                      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.

```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")
```
```output
                  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.

```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}")
```
```output
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.* Cuatro barras horizontales. En los textos de plantilla, las reglas aciertan el 100 % y el LLM el 5,3 % (1 de 19). En frases nuevas, las reglas el 0 % (0 de 7) y el LLM el 43 % (3 de 7, destacado), las tres en español.

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 $p_{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 = \frac{p_{\text{positive}} - p_{\text{negative}}}
{p_{\text{positive}} + p_{\text{negative}} + p_{\text{neutral}}}
$$

```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)
```
```output
['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)$:

```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)
```
```output
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.

```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)
```
```output
                 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)[^1].
- **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»[^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.

<aside class="callout" data-kind="aviso">

**Comprobar la versión de lo que se cita.** «Financial Statement Analysis with Large Language Models» (Kim, Muhn y
Nikolaev, arXiv:2407.17866) tuvo mucha difusión por concluir que un LLM predecía la dirección de los beneficios mejor
que los analistas. Su versión 3, del 20 de febrero de 2025, está **retirada**: «A co-author identified inconsistencies
in the data and analyses while attempting to replicate past analyses from the working paper». Mientras siga así, no es
evidencia, y la versión 1 tampoco: es el mismo trabajo.

</aside>

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(\text{ventas}/\text{consenso})$. ¿Coincide con la que da el README (0,18)?

**Solución.**

```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))
```
```output
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.

2. 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.**

```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)
```
```output
               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 $p_{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 ($\pm 1/4$, $\pm 1/2$, $\pm 2/3$…) y con 0,4 las noticias de
$\pm 1/4$ pasan a neutras. Un umbral se elige con datos de validación, no con los de la evaluación.

3. 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.

[^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.

## 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?

   - A. Que las reglas son mejores que cualquier LLM para extraer previsiones
   - B. Que el LLM no aporta nada en finanzas
   - C. Que los datos son fáciles
   - 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

   *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?

   - 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
   - B. Porque el modelo no sabe escribir
   - C. Porque FinBERT lo exige
   - D. Porque así el modelo no puede equivocarse

   *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?

   - A. El LLM es mejor que FinBERT
   - 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
   - C. Las noticias son más largas que los filings
   - D. Es casualidad: con otra semilla sería al revés

   *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?

   - A. Que el diccionario de Loughran-McDonald es perfecto
   - B. Que las llamadas están en inglés
   - 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
   - D. Que el LLM está mal configurado

   *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?

   - A. Citarlo igualmente: tuvo mucha difusión
   - 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
   - C. Usar solo la primera versión, que no está retirada
   - D. Sustituirlo por un artículo de prensa que lo resuma

   *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

### Documentación

- [Ficha de ProsusAI/finbert en Hugging Face (licencia CC-BY-NC-SA-4.0)](https://huggingface.co/ProsusAI/finbert) · huggingface.co

### Libros

- [Lopez-Lira, A. y Tang, Y. (2023-2025): Can ChatGPT Forecast Stock Price Movements? Return Predictability and Large Language Models (arXiv:2304.07619)](https://arxiv.org/abs/2304.07619) · arxiv.org
- [Huang, A. H., Wang, H. y Yang, Y. (2023): FinBERT: A Large Language Model for Extracting Information from Financial Text. Contemporary Accounting Research 40(2), 806-841](https://doi.org/10.1111/1911-3846.12832) · doi.org
- [Loughran, T. y McDonald, B. (2011): When Is a Liability Not a Liability? Textual Analysis, Dictionaries, and 10-Ks. The Journal of Finance 66(1), 35-65](https://doi.org/10.1111/j.1540-6261.2010.01625.x) · doi.org
- [Qwen Team (2024): Qwen2.5 Technical Report (arXiv:2412.15115)](https://arxiv.org/abs/2412.15115) · arxiv.org

### Datos

- [documentos6_extraccion_verdad.csv: previsiones y KPI de cada documento y llamada (datos sintéticos)](https://pythonparatrading.com/curso/datos/nivel-6/documentos6_extraccion_verdad.csv) · Descarga · documentos6_extraccion_verdad.csv
- [llamadas6_transcripciones.parquet: 840 conference calls sintéticas](https://pythonparatrading.com/curso/datos/nivel-6/llamadas6_transcripciones.parquet) · Descarga · llamadas6_transcripciones.parquet

Lección anterior: [6.2.2 · Prompting estructurado: JSON que se puede validar](https://pythonparatrading.com/curso-python-trading/nivel-6/prompting-estructurado-y-json-con-pydantic/)

Lección siguiente: [6.2.4 · Fine-tuning ligero para tareas financieras: LoRA y QLoRA](https://pythonparatrading.com/curso-python-trading/nivel-6/fine-tuning-ligero-lora-y-qlora/)
