---
title: "6.1.1 Tokenización, embeddings, diccionarios y FinBERT"
description: "Tokenizadores WordPiece y BPE, embeddings con MiniLM, diccionarios tipo Loughran-McDonald y FinBERT, medidos contra el sentimiento verdadero."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/tokenizacion-embeddings-loughran-mcdonald-y-finbert/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/tokenizacion-embeddings-loughran-mcdonald-y-finbert.png"
---

# 6.1.1 · Tokenización, embeddings, diccionarios financieros y FinBERT

> Un modelo no lee palabras: lee números. Esta lección recorre las tres formas clásicas de convertir una noticia financiera en una puntuación de sentimiento (contar palabras de un diccionario, proyectarla en un espacio de embeddings y clasificarla con un modelo afinado como FinBERT) y las mide sobre un corpus sintético en el que se conoce el sentimiento verdadero de cada noticia.

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.1 · NLP financiero clásico y moderno
- Duración: 70 min
- Requisitos: [5.1.2 · Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn](https://pythonparatrading.com/curso-python-trading/nivel-5/aprendizaje-supervisado-y-pipelines-de-scikit-learn/), [5.2.2 · Fundamentales point-in-time, macro y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-5/fundamentales-point-in-time-macro-y-datos-alternativos/)

## Al terminar esta lección

- Explicar qué hace un tokenizador de subpalabras (WordPiece, BPE a nivel de byte) y medir cuántos tokens cuesta el mismo texto en inglés y en español con tres vocabularios.
- Puntuar noticias con un diccionario financiero y con uno general, y localizar sus fallos típicos: palabras con otro sentido en finanzas, negaciones y frases fuera del léxico.
- Calcular embeddings de frases con MiniLM, compararlos por similitud coseno y entrenar sobre ellos un modelo lineal con separación temporal.
- Ejecutar FinBERT en local, convertir sus probabilidades en una puntuación y compararlo con el diccionario por idioma y por tipo de frase, sabiendo su licencia.

## Del texto a un número

Una estrategia que lee noticias necesita, al final, una columna de números: una puntuación por noticia que se pueda
agregar, retrasar y correlacionar con rentabilidades. Entre el texto y esa columna hay siempre los mismos pasos:
partir el texto en unidades, convertir esas unidades en números y resumirlos en una puntuación.

```mermaid
flowchart TB
  T[Texto] --> K[Tokens]
  K --> D[Recuento por léxico]
  K --> E[Embedding]
  K --> F[Modelo afinado]
  D --> S[Puntuación]
  E --> R[Modelo lineal]
  R --> S
  F --> S
```

Las tres ramas son las tres familias de esta lección: **diccionarios** (contar palabras positivas y negativas),
**embeddings** (un vector por frase, con un modelo entrenado encima) y **modelos afinados** como FinBERT, que leen la
frase y devuelven directamente la probabilidad de cada clase. Los LLM generativos, que también puntúan sentimiento,
llegan en la lección 6.2.3.

Los datos son las noticias sintéticas del nivel 6: 39.570 piezas de cinco agencias ficticias sobre 120 empresas del
panel del nivel 5, con su instante de publicación. Lo que no existe en un corpus real está en
`noticias6_verdad.csv`: el **sentimiento verdadero** de cada noticia, entre −1 y 1, con el que el generador escribió
el texto y movió el precio. Es la respuesta conocida contra la que se mide cada método.

```python
import re
import textwrap

import numpy as np
import pandas as pd

noticias = pd.read_parquet("noticias6_corpus.parquet")
verdad = pd.read_csv("noticias6_verdad.csv")
df = noticias.merge(verdad, on="id_noticia")
orig = df[df["tipo_noticia"] == "original"].copy()
orig["texto"] = orig["titular"] + ". " + orig["cuerpo"]
print(len(orig), "noticias originales")
print(orig["idioma"].value_counts().to_dict())
fila = orig.iloc[2]
print(fila["texto"])
print("sentimiento verdadero:", fila["sentimiento_verdadero"])
```
```output
22593 noticias originales
{'es': 16969, 'en': 5624}
Placeholder & Co. cuts Numvia Communications price target to $19.23. Analysts at Placeholder & Co. cut their price target on Numvia Communications to $19.23. Numvia Communications issued a warning about business weakness.
sentimiento verdadero: -0.546
```

Las copias sindicadas (la misma historia en otra agencia, a veces traducida) y las crónicas de mercado se quedan
fuera: una historia se puntúa una vez. Cómo detectar las copias es parte de la lección 6.1.2.

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

Las noticias del curso salen de plantillas con frases de tono, y un 70 % de esas frases usa palabras del léxico. Por
eso todas las correlaciones de esta lección son más altas que las que se obtienen con noticias reales. Lo que sí se
traslada es el **orden** de los métodos y el **tipo** de error de cada uno.

</aside>

## Tokenizar: palabras y subpalabras

La forma más simple de partir un texto es una expresión regular que extrae palabras. Es lo que usan los diccionarios:
pasar a minúsculas y quedarse con secuencias de letras. NLTK añade reglas para la puntuación y las contracciones:

```python
from nltk.tokenize import word_tokenize

frase = "Numvia didn't beat estimates; revenue fell 3.5%."

def palabras(texto):
    return re.findall(r"[a-záéíóúüñ]+", texto.lower())

print(palabras(frase))
print(word_tokenize(frase))
```
```output
['numvia', 'didn', 't', 'beat', 'estimates', 'revenue', 'fell']
['Numvia', 'did', "n't", 'beat', 'estimates', ';', 'revenue', 'fell', '3.5', '%', '.']
```

La expresión regular pierde la negación («didn't» se queda en `didn` y `t`) y las cifras; `word_tokenize` separa
`did` y `n't` y conserva `3.5` y `%`. Para un diccionario basta la primera; para un modelo no sirve ninguna, porque un
vocabulario de palabras completas nunca está completo: nombres de empresas, cifras y palabras raras quedarían fuera.

Los modelos actuales usan **subpalabras**: un vocabulario fijo (de 30.000 a 150.000 piezas) aprendido sobre un corpus,
con el que cualquier texto se descompone en fragmentos conocidos. Las dos familias que importan aquí:

| Algoritmo | Cómo construye el vocabulario | Modelos de esta lección |
|---|---|---|
| WordPiece | Une los pares de piezas que más aumentan la verosimilitud del corpus; marca las continuaciones con `##` | FinBERT y MiniLM (vocabulario de BERT, 30.522 piezas, minúsculas y sin acentos) |
| BPE a nivel de byte | Parte de los 256 bytes y une los pares más frecuentes; ningún texto queda fuera del vocabulario | Qwen2.5 (unas 151.000 piezas), `cl100k_base` de tiktoken |

El BPE lo propusieron Sennrich, Haddow y Birch (2016) para traducción automática; la página de Hugging Face sobre
algoritmos de tokenización explica las variantes[^1]. Lo importante para un quant es qué hace cada vocabulario con
textos financieros en dos idiomas:

```python
import tiktoken
from transformers import AutoTokenizer

tok_bert = AutoTokenizer.from_pretrained("ProsusAI/finbert")
tok_qwen = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
tok_tik = tiktoken.get_encoding("cl100k_base")

en = "Revenue was $3,075.9 million, a weak quarter."
es = "Las ventas sumaron 3.075,9 millones: caída de la demanda."

def piezas(texto):
    q = [tok_qwen.convert_tokens_to_string([t])
         for t in tok_qwen.tokenize(texto)]
    t = [tok_tik.decode([i]) for i in tok_tik.encode(texto)]
    return {"wordpiece": tok_bert.tokenize(texto),
            "qwen": q, "cl100k": t}

for texto in (en, es):
    for nombre, p in piezas(texto).items():
        print(f"{nombre:9} {len(p):2}", "|".join(p))
    print()
```
```output
wordpiece 15 revenue|was|$|3|,|07|##5|.|9|million|,|a|weak|quarter|.
qwen      16 Revenue| was| $|3|,|0|7|5|.|9| million|,| a| weak| quarter|.
cl100k    14 Revenue| was| $|3|,|075|.|9| million|,| a| weak| quarter|.

wordpiece 22 las|vent|##as|sum|##aro|##n|3|.|07|##5|,|9|mill|##ones|:|cai|##da|de|la|demand|##a|.
qwen      21 Las| ventas| sum|aron| |3|.|0|7|5|,|9| millones|:| ca|ída| de| la| demand|a|.
cl100k    19 Las| ventas| sum|aron| |3|.|075|,|9| millones|:| ca|ída| de| la| demand|a|.
```

Tres detalles con consecuencias:

- **Cifras.** WordPiece parte `3,075.9` en cinco piezas y Qwen escribe cada dígito por separado. Ningún modelo de
  lenguaje «ve» el número 3.075,9 como una magnitud; ve una secuencia de símbolos. Por eso la extracción de cifras
  con LLM necesita validación (lección 6.2.2).
- **Acentos y minúsculas.** El vocabulario de BERT sin mayúsculas quita las tildes: «caída» pasa a `cai` + `##da`.
- **Falsos amigos.** «demanda» se convierte en `demand` + `##a`: una pieza inglesa con otro significado. Un modelo
  entrenado con texto inglés asociará esa pieza a lo que significa *demand* en inglés.

El coste también cambia con el idioma. Medido sobre 400 noticias de cada idioma, en tokens por palabra:

```python
muestra = (orig.groupby("idioma", group_keys=False)
           .sample(400, random_state=0))
n_palabras = muestra["texto"].str.split().str.len()

def n_tokens(textos, nombre):
    if nombre == "cl100k":
        return np.array([len(tok_tik.encode(t)) for t in textos])
    tok = tok_bert if nombre == "wordpiece" else tok_qwen
    return np.array([len(tok.tokenize(t)) for t in textos])

coste = {}
for nombre in ("wordpiece", "qwen", "cl100k"):
    ratio = n_tokens(muestra["texto"], nombre) / n_palabras
    coste[nombre] = ratio.groupby(muestra["idioma"]).mean()
pd.DataFrame(coste).round(2)
```
```output
        wordpiece  qwen  cl100k
idioma
en           1.47  1.52    1.44
es           2.03  1.74    1.75
```

*Fig. — Tokens por palabra de las noticias del corpus según el tokenizador y el idioma: el vocabulario de BERT, construido con texto inglés, trocea mucho más el español.* Seis barras horizontales. WordPiece de BERT: 1,47 tokens por palabra en inglés y 2,03 en español (destacada). BPE de Qwen2.5: 1,52 y 1,74. cl100k_base: 1,44 y 1,75.

El español cuesta más tokens que el inglés con los tres vocabularios, y la diferencia es mayor con el de BERT, que
se construyó solo con texto inglés. Importa por tres motivos: las APIs comerciales cobran por token (lección 6.2.1),
los modelos tipo BERT truncan la entrada a 512 tokens (FinBERT) o a 256 piezas (MiniLM, según su ficha[^2]) y un
documento largo, como la sección de riesgos de un 10-K, no cabe entero; y un vocabulario que trocea mucho un idioma
suele indicar que el modelo vio poco texto en él.

## Diccionarios: de Harvard a Loughran-McDonald

Un diccionario de sentimiento es una lista de palabras con su categoría. La puntuación más usada cuenta las positivas
($p$) y las negativas ($n$) de un texto:

$$
\text{tono} = \frac{p - n}{p + n + 1}
$$

El 1 del denominador evita dividir por cero y acerca a cero los textos con pocas palabras de tono. El resultado está
entre −1 y 1.

Durante años se usaron listas de propósito general, como el diccionario de Harvard (General Inquirer). Loughran y
McDonald (2011) mostraron que en los informes anuales (10-K) casi tres cuartas partes de las apariciones de palabras
«negativas» de esa lista corresponden a palabras que en un contexto financiero no lo son: *tax*, *cost*, *liability*,
*capital*, *vice* (de *vice president*)[^3]. Construyeron listas propias del dominio, que mantienen y actualizan en su
web con siete categorías: negativa, positiva, incertidumbre, litigio, modal fuerte, modal débil y restrictiva. Las
listas son gratuitas para investigación académica y su uso comercial requiere licencia de los autores[^4]. No se
copian aquí.

El curso trae en su lugar `texto6_lexico.csv`, un léxico **inventado** con cinco de esas categorías en español y en
inglés (lista `financiera`) y una lista `general` que imita el defecto de las de propósito general:

```python
lex = pd.read_csv("texto6_lexico.csv")
print(lex.pivot_table(index=["lista", "categoria"], columns="idioma",
                      values="palabra", aggfunc="size",
                      fill_value=0))
gen_neg = lex.query("lista == 'general' and categoria == 'negativa'"
                    " and idioma == 'es'")
print(textwrap.fill(" ".join(gen_neg["palabra"]), 72))
```
```output
idioma                    en  es
lista      categoria
financiera incertidumbre  15  17
           litigio        12  14
           negativa       26  31
           positiva       23  31
           restrictiva     9   9
general    negativa       17  17
           positiva        9  10
cargo cargos caída coste costes crudo cáncer demanda deuda débil
impuesto impuestos pasivo pasivos pérdida quiebra vicepresidente
```

La lista general marca como negativas *coste*, *deuda*, *impuesto*, *pasivo*, *crudo*, *vicepresidente* y
*demanda*: el mismo error que encontraron Loughran y McDonald. Puntuar todas las noticias con las dos listas y
compararlas con el sentimiento verdadero:

```python
listas = {}
for (lista, idioma), g in lex.groupby(["lista", "idioma"]):
    pos = set(g.loc[g["categoria"] == "positiva", "palabra"])
    neg = set(g.loc[g["categoria"] == "negativa", "palabra"])
    listas[(lista, idioma)] = (pos, neg)

def tono(texto, idioma, lista="financiera"):
    pos, neg = listas[(lista, idioma)]
    w = palabras(texto)
    p = sum(x in pos for x in w)
    n = sum(x in neg for x in w)
    return (p - n) / (p + n + 1)

orig["dic_fin"] = [tono(t, i) for t, i in
                   zip(orig["texto"], orig["idioma"])]
orig["dic_gen"] = [tono(t, i, "general") for t, i in
                   zip(orig["texto"], orig["idioma"])]
s = orig["sentimiento_verdadero"]
print("financiera:", round(orig["dic_fin"].corr(s), 3))
print("general:   ", round(orig["dic_gen"].corr(s), 3))
print("noticias neutras con tono general < 0: "
      f"{(orig.loc[s == 0, 'dic_gen'] < 0).mean():.1%}")
```
```output
financiera: 0.709
general:    0.351
noticias neutras con tono general < 0: 7.0%
```

La lista financiera se correlaciona 0,71 con el sentimiento verdadero; la general, la mitad (0,35). La última línea
apunta a una causa: un 7 % de las noticias **neutras** (sentimiento exactamente cero, como una junta o una
conferencia) recibe un tono negativo de la lista general. ¿Qué palabras disparan esos falsos negativos?

```python
_, neg_gen = listas[("general", "es")]
neutras = orig.loc[(s == 0) & (orig["idioma"] == "es"), "texto"]
cuenta = pd.Series([w for t in neutras for w in palabras(t)
                    if w in neg_gen]).value_counts()
cuenta.head(6)
```
```output
demanda    426
caída       52
Name: count, dtype: int64
```

Casi todos salen de «demanda», en frases como «el impacto final dependerá de la demanda»: demanda de clientes, no
un pleito. La lista financiera la clasifica como litigio (una categoría que no entra en el tono) y la general, como
negativa. Es el mismo tipo de error que encontraron Loughran y McDonald con *liability* o *tax*: una palabra que en
otro contexto es mala noticia y en el financiero es descriptiva.

## Lo que un diccionario no puede ver

Contar palabras ignora el orden. Dos tipos de frase lo rompen: las **negaciones** («la compañía descarta pérdidas»
contiene *pérdidas*) y las frases de tono **sin palabras del léxico** («es un jarro de agua fría para los
accionistas»). El generador apunta en `noticias6_verdad.csv` cuántas frases de cada tipo lleva cada noticia, así que
se puede medir dónde falla el diccionario:

```python
grupo = np.select(
    [orig["frases_negacion"] > 0, orig["frases_fuera_lexico"] > 0],
    ["con negación", "fuera del léxico"], "solo léxico")
orig["grupo"] = grupo
por_grupo = orig.groupby("grupo").apply(
    lambda g: pd.Series({
        "noticias": len(g),
        "corr_dic": g["dic_fin"].corr(g["sentimiento_verdadero"]),
    }), include_groups=False)
por_grupo.round(3)
```
```output
                  noticias  corr_dic
grupo
con negación        2954.0     0.210
fuera del léxico    3718.0     0.833
solo léxico        15921.0     0.784
```

En las noticias con una negación, la correlación cae a 0,21, una cuarta parte de la del resto: el diccionario lee la
palabra negativa y se equivoca de signo. Las frases fuera del léxico, en cambio, no bajan la correlación: en el
generador, las noticias con más intensidad llevan más frases de tono (dos de media, frente a menos de una), y las
demás frases sí usan el léxico. Un ejemplo de negación:

```python
ej = orig[(orig["frases_negacion"] > 0)
          & (orig["idioma"] == "es")
          & (orig["sentimiento_verdadero"] > 0.4)
          & (orig["dic_fin"] < 0)].iloc[0]
print(textwrap.fill(ej["cuerpo"], 72))
print("verdadero:", ej["sentimiento_verdadero"],
      "| diccionario:", round(ej["dic_fin"], 3))
```
```output
Kogsa Bancorp ha firmado un contrato de 829,7 millones de dólares con la
administración de un estado de EE. UU. para los próximos 3 años. El
grupo deja atrás las dificultades del año pasado. Las acciones cerraron
la última sesión en 201,85 dólares.
verdadero: 0.7784 | diccionario: -0.5
```

«Deja atrás las dificultades» es una buena noticia escrita con una palabra negativa del léxico (*dificultades*). El
diccionario la cuenta como negativa y la noticia, claramente positiva, puntúa −0,5.

Los diccionarios tienen, aun así, tres virtudes que mantienen su uso en research y en producción: son
**transparentes** (se sabe por qué una noticia puntúa lo que puntúa), **baratos** (millones de documentos en segundos,
sin GPU) y **estables** (la misma lista da la misma puntuación hoy y dentro de diez años, sin sesgo de anticipación;
lección 6.2.5). Las mejoras habituales (reglas de negación en una ventana de pocas palabras, ponderación tf-idf,
listas por sector) reducen sus errores sin cambiar su naturaleza.

## Embeddings: una frase como vector

Un **embedding** de frase es un vector de dimensión fija que resume su significado: frases parecidas quedan cerca y
la cercanía se mide con la similitud coseno (el producto escalar de los vectores normalizados). `all-MiniLM-L6-v2`
es un modelo pequeño de sentence-transformers (22 millones de parámetros, 384 dimensiones, entrenado con texto
inglés[^2]) que sigue la idea de Sentence-BERT[^5]. Sus pesos están en la caché del entorno `ia`, fijados por commit:

```python
import torch
from sentence_transformers import SentenceTransformer

torch.manual_seed(0)
st = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2",
                         device="cpu", local_files_only=True)
frases = [
    "The company reported record profits.",
    "Earnings hit an all-time high.",
    "The company warned of heavy losses.",
    "La empresa obtuvo beneficios récord.",
    "La empresa advirtió de fuertes pérdidas.",
]
E = st.encode(frases, normalize_embeddings=True,
              show_progress_bar=False)
print(E.shape)
pd.DataFrame(E @ E.T, index=range(5), columns=range(5)).round(2)
```
```output
(5, 384)
      0     1     2     3     4
0  1.00  0.53  0.42  0.41  0.18
1  0.53  1.00  0.27  0.21  0.07
2  0.42  0.27  1.00  0.14  0.15
3  0.41  0.21  0.14  1.00  0.68
4  0.18  0.07  0.15  0.68  1.00
```

Las frases 0 y 1 dicen lo mismo con palabras distintas y su similitud es la más alta de las inglesas (0,53); la 2
comparte casi todas las palabras con la 0 y dice lo contrario, pero su similitud no es mucho menor (0,42). Un
embedding de propósito general mide sobre todo **de qué** habla una frase, no **en qué sentido**. En español es peor:
las frases 3 y 4, que dicen lo contrario, son las más parecidas de la tabla (0,68), y cada una queda lejos de su
traducción inglesa (0,41 y 0,15). MiniLM no es multilingüe: con texto español compara sobre todo la forma.

Para convertir embeddings en sentimiento hace falta un modelo encima entrenado con ejemplos etiquetados. En real, las
etiquetas saldrían de un conjunto anotado (como Financial PhraseBank, el que usó FinBERT) o de la reacción del
precio; aquí, para medir el techo del método, se usa el sentimiento verdadero de 2019 a 2022 y se evalúa de 2023 en
adelante, con la separación temporal del nivel 5 (lección 5.4.3). Una muestra de 1.200 noticias por idioma basta para
la comparación y mantiene la celda en segundos en CPU:

```python
from sklearn.linear_model import Ridge

m = (orig.groupby("idioma", group_keys=False)
     .sample(1200, random_state=1).sort_values("ts"))
X = st.encode(m["texto"].tolist(), batch_size=64,
              normalize_embeddings=True, show_progress_bar=False)
y = m["sentimiento_verdadero"].to_numpy()
entreno = (m["ts"] < "2023-01-01").to_numpy()
m["ridge"] = np.nan
for idioma in ("en", "es"):
    i = (m["idioma"] == idioma).to_numpy()
    modelo = Ridge(alpha=1.0).fit(X[i & entreno], y[i & entreno])
    prueba = i & ~entreno
    m.loc[m.index[prueba], "ridge"] = modelo.predict(X[prueba])

test = m[m["ts"] >= "2023-01-01"]
test.groupby("idioma").apply(lambda g: pd.Series({
    "prueba": len(g),
    "corr_ridge": g["ridge"].corr(g["sentimiento_verdadero"]),
    "corr_dic": g["dic_fin"].corr(g["sentimiento_verdadero"]),
}), include_groups=False).round(3)
```
```output
        prueba  corr_ridge  corr_dic
idioma
en       524.0       0.919     0.702
es       491.0       0.826     0.729
```

Con unos cientos de ejemplos por idioma, la regresión sobre embeddings supera al diccionario en los dos idiomas, y
más en inglés, el idioma de MiniLM, que en español. Hay que leer la cifra con cuidado: el modelo lineal ha aprendido
las **plantillas** del generador con etiquetas perfectas, y las de 2023 son las mismas que las de 2019. Con noticias
reales, unas pocas centenas de etiquetas ruidosas no dan para tanto, y el lenguaje cambia con los años. La ventaja
duradera de los embeddings está en otra parte: el mismo vector sirve para buscar documentos parecidos, agrupar
noticias por tema (lección 6.1.3) o alimentar un RAG (lección 6.3.1).

## FinBERT: un clasificador afinado para finanzas

FinBERT (Araci, 2019)[^6] es un BERT de tamaño base (110 millones de parámetros) preentrenado con texto financiero y
afinado para clasificar frases en tres clases (positiva, negativa, neutra) con Financial PhraseBank, un conjunto de
frases de noticias financieras en inglés anotadas a mano. La versión de `ProsusAI/finbert` es la más usada.

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

**Licencia.** Los pesos de `ProsusAI/finbert` se publican con licencia **CC-BY-NC-SA-4.0** (atribución, uso no
comercial y obras derivadas con la misma licencia), según su ficha de Hugging Face; el conjunto Financial PhraseBank
tiene también una licencia no comercial. El curso los usa con fines educativos y no los redistribuye. Para un uso
comercial, antes de descargar un modelo hay que leer su licencia y la de sus datos de entrenamiento (lección 1.3.7);
el README del entorno `ia` nombra una alternativa con licencia Apache-2.0.

</aside>

```mermaid
flowchart LR
  F[Frase] --> W[WordPiece]
  W --> B[BERT base]
  B --> C[Capa de clases]
  C --> P[Probabilidades]
```

La salida son tres probabilidades que suman uno. Una puntuación continua habitual es
$P(\text{positiva}) - P(\text{negativa})$, entre −1 y 1, comparable con el tono del diccionario:

```python
from transformers import pipeline

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

def puntua(textos, lote=32):
    salidas = finbert(list(textos), batch_size=lote, top_k=None,
                      truncation=True)
    filas = [{d["label"]: d["score"] for d in s} for s in salidas]
    probs = pd.DataFrame(filas)[["positive", "negative", "neutral"]]
    return probs.assign(fb=probs["positive"] - probs["negative"])

pruebas = ["The company does not expect any losses this year.",
           "Revenue declined sharply and the outlook was cut.",
           "The annual meeting will be held in Madrid."]
puntua(pruebas).round(3)
```
```output
   positive  negative  neutral     fb
0     0.058     0.345    0.596 -0.287
1     0.008     0.974    0.019 -0.966
2     0.027     0.026    0.947  0.001
```

La segunda frase es negativa sin dudas (0,97) y la tercera, neutra (0,95). La primera es una buena noticia escrita
con una negación, y FinBERT tampoco la acierta: la deja entre neutra (0,60) y negativa (0,35). Una frase suelta
no prueba nada; hace falta medirlo sobre muchas. Se usan las noticias del periodo de prueba de la celda anterior: las
524 inglesas y 200 españolas al azar (FinBERT es el paso más lento de la lección: unos segundos por cada cien noticias
en CPU), y así los cuatro métodos se comparan sobre las mismas noticias:

```python
sub_en = test[test["idioma"] == "en"].copy()
sub_es = test[test["idioma"] == "es"].sample(200, random_state=2)
sub_en["fb"] = puntua(sub_en["texto"])["fb"].to_numpy()
sub_es["fb"] = puntua(sub_es["texto"])["fb"].to_numpy()

def correlaciones(g):
    s = g["sentimiento_verdadero"]
    return pd.Series({"noticias": len(g),
                      "finbert": g["fb"].corr(s),
                      "ridge": g["ridge"].corr(s),
                      "dic_fin": g["dic_fin"].corr(s),
                      "dic_gen": g["dic_gen"].corr(s)})

pd.DataFrame({"en": correlaciones(sub_en),
              "es": correlaciones(sub_es)}).T.round(3)
```
```output
    noticias  finbert  ridge  dic_fin  dic_gen
en     524.0    0.876  0.919    0.702    0.459
es     200.0   -0.114  0.857    0.760    0.473
```

En inglés, FinBERT (0,88) supera con claridad al diccionario financiero (0,70) y a la lista general (0,46) sin
haber visto ninguna noticia del corpus. Solo lo supera la ridge sobre embeddings (0,92), entrenada con las etiquetas
perfectas de las mismas plantillas: una ventaja que con datos reales no existe. En español, FinBERT da −0,11: el
modelo no conoce el idioma y su tokenizador lo trocea en fragmentos sin sentido, mientras que el diccionario español
y la ridge mantienen su nivel. La figura de la cabecera resume los cuatro métodos en las 524 noticias inglesas.
¿Dónde gana FinBERT? Donde el diccionario falla:

```python
sub_en.groupby("grupo").apply(
    lambda g: pd.Series({
        "noticias": len(g),
        "finbert": g["fb"].corr(g["sentimiento_verdadero"]),
        "dic_fin": g["dic_fin"].corr(g["sentimiento_verdadero"]),
    }), include_groups=False).round(3)
```
```output
                  noticias  finbert  dic_fin
grupo
con negación          72.0    0.870    0.100
fuera del léxico      91.0    0.886    0.841
solo léxico          361.0    0.879    0.790
```

*Fig. — Correlación con el sentimiento verdadero por tipo de noticia (noticias inglesas de prueba): el diccionario se hunde con las negaciones y FinBERT no.* Seis barras horizontales. Noticias que solo usan el léxico (361): diccionario 0,79, FinBERT 0,88. Con frases fuera del léxico (91): 0,84 y 0,89. Con negación (72): diccionario 0,10 (destacada) y FinBERT 0,87.

En las noticias que solo usan el léxico, el diccionario (0,79) se acerca a FinBERT (0,88): contar palabras basta
cuando el texto las contiene. En las que llevan una negación, el diccionario se hunde (0,10) y FinBERT mantiene su
nivel (0,87). Que fallara con la frase suelta de la celda anterior y acierte en el grupo no es una contradicción: en
las noticias la negación va acompañada del resto del texto, y lo que se mide es la correlación de muchas.
Con 524 noticias, los grupos pequeños tienen un error de muestreo apreciable (con unas 70 noticias, una correlación
de 0,1 tiene un error típico de algo más de 0,1), así que conviene leer el orden y no los decimales.

## Qué elegir y qué cuesta

| Método | Coste | Idiomas | Transparencia | Riesgo principal |
|---|---|---|---|---|
| Diccionario financiero | Casi nulo | El de la lista | Total | Negaciones, contexto, palabras nuevas |
| Diccionario general | Casi nulo | El de la lista | Total | Vocabulario contable leído como negativo |
| Embeddings + modelo lineal | Bajo; necesita etiquetas | Los del modelo de embeddings | Baja | Etiquetas caras o ruidosas; deriva del lenguaje |
| FinBERT | Medio (CPU o GPU) | Inglés | Baja | Licencia no comercial; 512 tokens; dominio de entrenamiento |
| LLM generativo (6.2.3) | Alto | Muchos | Baja | Coste, formato, sesgo de anticipación (6.2.5) |

Tres advertencias para llevar a un backtest:

- **La puntuación no es la señal.** Un sentimiento bien medido solo vale si mueve el precio después de que se pueda
  operar. Medir esa reacción exige alinear cada noticia con la sesión correcta: es la lección 6.1.3, donde el mismo
  diccionario recupera unos 75 pb por unidad de tono el día de reacción frente a 119 pb con el sentimiento verdadero
  (README del nivel 6).
- **Un modelo afinado también tiene fecha.** FinBERT se entrenó con frases anteriores a 2019; un clasificador entrenado
  con datos posteriores al periodo del backtest introduce información del futuro (lección 6.2.5).
- **El idioma es una decisión de diseño.** Con un universo europeo, la mitad de las noticias puede estar en un idioma
  que el modelo no entiende. En este corpus, aplicar FinBERT a todo habría estropeado la señal del 75 % de las
  noticias.

## Ejercicios

1. Añade al diccionario una regla de negación: si una de las tres palabras anteriores a una palabra del léxico es
   «no», «descarta», «sin» o «niega», cambia su signo. Mide la correlación con el sentimiento verdadero en las noticias
   españolas con negación antes y después.

**Solución.**

```python
NEG = {"no", "descarta", "sin", "niega"}

def tono_neg(texto, idioma="es"):
    pos, neg = listas[("financiera", idioma)]
    w = palabras(texto)
    p = n = 0
    for k, x in enumerate(w):
        signo = -1 if NEG & set(w[max(0, k - 3):k]) else 1
        if x in pos:
            p, n = (p + 1, n) if signo > 0 else (p, n + 1)
        elif x in neg:
            p, n = (p, n + 1) if signo > 0 else (p + 1, n)
    return (p - n) / (p + n + 1)

g = orig[(orig["idioma"] == "es") & (orig["grupo"] == "con negación")]
nuevo = g["texto"].map(tono_neg)
s_g = g["sentimiento_verdadero"]
print("antes:  ", round(g["dic_fin"].corr(s_g), 3))
print("después:", round(nuevo.corr(s_g), 3))
```
```output
antes:   0.21
después: 0.768
```

Una regla de tres palabras recupera buena parte de la señal perdida en las noticias con negación. Es la mejora
clásica de los diccionarios; su límite es que hay que conocer de antemano las formas de negar, y en un texto real son
muchas más que cuatro.

2. Calcula cuántas noticias originales en inglés superan los 512 tokens de FinBERT y cuántas los 256 de MiniLM. Haz lo
   mismo con los 10-K de `filings6_documentos.parquet`. ¿Qué harías con los documentos que no caben?

**Solución.**

```python
from transformers.utils import logging as hf_logging

hf_logging.set_verbosity_error()  # sin el aviso de «> 512 tokens»
en_txt = orig.loc[orig["idioma"] == "en", "texto"].tolist()
n = np.array([len(x) for x in tok_bert(en_txt)["input_ids"]])
print(f"noticias > 256: {(n > 256).mean():.1%}",
      f"> 512: {(n > 512).mean():.1%}")
docs = pd.read_parquet("filings6_documentos.parquet")
diez_k = docs.loc[docs["tipo"] == "10-K", "texto"].tolist()
n_k = np.array([len(x) for x in tok_bert(diez_k)["input_ids"]])
print(f"10-K > 512: {(n_k > 512).mean():.1%}",
      f"mediana {np.median(n_k):.0f}")
```
```output
noticias > 256: 0.0% > 512: 0.0%
10-K > 512: 100.0% mediana 898
```

Las noticias caben todas; los 10-K, no (el recuento incluye las etiquetas HTML, y estos son documentos sintéticos
cortos: uno real tiene decenas de miles de palabras). Al tokenizar sin truncar, transformers avisa en su registro de
que la secuencia supera la longitud máxima del modelo; la celda baja ese registro a errores para que la salida sea
limpia. Con `truncation=True` el modelo solo leería el principio. Lo correcto es partir el documento en trozos que
quepan (por frases o por secciones, lección 6.3.2), puntuar cada trozo y agregar (media, o media ponderada por la
confianza), o puntuar solo la sección que interesa, como los factores de riesgo.

3. Un proveedor ofrece un «sentimiento de noticias» calculado con un modelo afinado sobre noticias de 2015 a 2024 y
   un histórico desde 2010. ¿Qué preguntas le harías antes de usarlo en un backtest?

**Solución.**

Con qué datos y en qué fecha se entrenó el modelo que puntuó cada noticia (si la puntuación de 2016 se calculó con un
modelo entrenado hasta 2024, el histórico lleva información del futuro: lección 6.2.5); si el histórico se recalculó
al cambiar de modelo o se conserva lo que se entregó en su día (point-in-time, lección 6.7.4); qué idiomas y fuentes
cubre y cómo trata las copias sindicadas; con qué instante se fecha cada puntuación (publicación de la noticia o
cálculo del proveedor); y qué licencia tienen el modelo y los textos.

[^1]: Sennrich, Haddow y Birch (2016), «Neural Machine Translation of Rare Words with Subword Units»
    (<https://arxiv.org/abs/1508.07909>); Hugging Face, «Tokenization algorithms»: BPE, BPE a nivel de byte (GPT-2),
    WordPiece (familia BERT) y Unigram/SentencePiece (<https://huggingface.co/docs/transformers/tokenizer_summary>),
    consultado el 2026-10-23.
[^2]: Ficha de `sentence-transformers/all-MiniLM-L6-v2` (<https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2>,
    consultada el 2026-10-23): licencia Apache-2.0, vectores de 384 dimensiones, inglés, y «by default, input text
    longer than 256 word pieces is truncated».
[^3]: Loughran y McDonald (2011), Journal of Finance 66(1), 35-65 (<https://doi.org/10.1111/j.1540-6261.2010.01625.x>):
    «almost three-fourths of the words identified as negative by the widely used Harvard Dictionary are words
    typically not considered negative in financial contexts». En el cuerpo del artículo la cifra es el 73,8 % de las
    apariciones de palabras negativas.
[^4]: Universidad de Notre Dame, Software Repository for Accounting and Finance, «Loughran-McDonald Master Dictionary
    w/ Sentiment Word Lists» (<https://sraf.nd.edu/loughranmcdonald-master-dictionary/>, consultado el 2026-10-23):
    gratuito para investigación académica, licencia comercial previa consulta a los autores.
[^5]: Reimers y Gurevych (2019), «Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks»
    (<https://arxiv.org/abs/1908.10084>).
[^6]: Araci (2019), «FinBERT: Financial Sentiment Analysis with Pre-trained Language Models»
    (<https://arxiv.org/abs/1908.10063>); ficha de `ProsusAI/finbert` (<https://huggingface.co/ProsusAI/finbert>,
    consultada el 2026-10-23): licencia CC-BY-NC-SA-4.0, tres etiquetas y afinado con Financial PhraseBank.

## Autoevaluación

1. El tokenizador de FinBERT (WordPiece, minúsculas y sin acentos) convierte «caída» en `cai`, `##da` y «demanda» en `demand`, `##a`. ¿Qué consecuencia práctica tiene?

   - A. Ninguna: el modelo entiende igual el español
   - B. El texto en español se trocea en muchas más piezas, algunas coinciden con palabras inglesas de otro significado y el modelo, entrenado con frases inglesas, apenas lo interpreta
   - C. El texto ocupa menos tokens que en inglés
   - D. Los acentos se conservan en un token aparte

   *Respuesta B: El texto en español se trocea en muchas más piezas, algunas coinciden con palabras inglesas de otro significado y el modelo, entrenado con frases inglesas, apenas lo interpreta.* El vocabulario de BERT se construyó con texto inglés: las palabras españolas se parten en fragmentos y la tilde desaparece. En la lección, FinBERT sobre noticias en español no se correlaciona con el sentimiento verdadero (−0,11), mientras que en inglés llega a 0,88.

2. Un diccionario general marca como negativas «coste», «deuda», «impuesto» o «vicepresidente». ¿Qué problema documentaron Loughran y McDonald con ese tipo de listas en los 10-K?

   - A. Que tienen pocas palabras positivas
   - B. Que no distinguen mayúsculas
   - C. Que casi tres cuartas partes de las apariciones de palabras «negativas» del diccionario de Harvard en los 10-K corresponden a palabras que en finanzas no son negativas
   - D. Que no funcionan con textos largos

   *Respuesta C: Que casi tres cuartas partes de las apariciones de palabras «negativas» del diccionario de Harvard en los 10-K corresponden a palabras que en finanzas no son negativas.* Un informe financiero habla de costes, deudas e impuestos sin que sea una mala noticia. Por eso construyeron listas propias del dominio. En el corpus del curso, la lista general se correlaciona 0,35 con el sentimiento verdadero y la financiera, 0,71.

3. La frase «la compañía descarta pérdidas» contiene una palabra negativa del léxico. ¿Qué le pasa a un diccionario y qué a FinBERT?

   - A. El diccionario cuenta «pérdidas» como negativa y se equivoca de signo; un modelo que lee la frase entera puede captar la negación
   - B. Los dos aciertan
   - C. Los dos fallan siempre
   - D. El diccionario acierta porque «descarta» es positiva

   *Respuesta A: El diccionario cuenta «pérdidas» como negativa y se equivoca de signo; un modelo que lee la frase entera puede captar la negación.* Contar palabras ignora el orden y el contexto. En las noticias con negación, la correlación del diccionario con el sentimiento verdadero cae a 0,21; FinBERT, que lee la frase como secuencia, conserva casi toda la señal en las noticias inglesas con negación (0,87).

4. Se entrena una regresión ridge sobre embeddings de MiniLM con el sentimiento de 2019-2022 y se evalúa en 2023-2025. ¿Por qué esa separación y no una aleatoria?

   - A. Porque es más rápida
   - B. Porque así hay más datos de entrenamiento
   - C. Porque las plantillas cambian cada año
   - D. Porque en un backtest el modelo solo puede haberse entrenado con textos anteriores; una partición aleatoria mezcla el futuro con el pasado y da una estimación optimista

   *Respuesta D: Porque en un backtest el modelo solo puede haberse entrenado con textos anteriores; una partición aleatoria mezcla el futuro con el pasado y da una estimación optimista.* Es la regla del nivel 5 (lecciones 5.4.1 y 5.4.3): validación con el tiempo. Además, en real las etiquetas de entrenamiento serían un conjunto anotado (como Financial PhraseBank), no el sentimiento verdadero, que no existe.

5. ¿Qué condiciona la licencia de los pesos de `ProsusAI/finbert` (CC-BY-NC-SA-4.0)?

   - A. Nada: todo lo que está en Hugging Face es de uso libre
   - B. Solo impide modificarlos
   - C. Permite usarlos citando la autoría, sin fines comerciales y compartiendo las obras derivadas con la misma licencia: para un uso comercial hay que buscar otro modelo o un permiso
   - D. Obliga a publicar el código de la estrategia

   *Respuesta C: Permite usarlos citando la autoría, sin fines comerciales y compartiendo las obras derivadas con la misma licencia: para un uso comercial hay que buscar otro modelo o un permiso.* NC (no comercial) y SA (compartir igual) son las dos cláusulas que importan en un fondo o una empresa. El curso lo usa con fines educativos; el README del entorno `ia` nombra una alternativa con licencia Apache-2.0.

## Ideas clave

- Los modelos trabajan con tokens de subpalabra. El mismo texto cuesta más tokens en español que en inglés con vocabularios entrenados en inglés, y WordPiece sin acentos convierte «demanda» en `demand` + `##a`.
- Un diccionario es transparente, rápido y gratis, pero no entiende contexto: en el corpus del curso la lista financiera se correlaciona 0,71 con el sentimiento verdadero, la general 0,35 y, con negaciones, la financiera cae a 0,21.
- El diccionario de Loughran y McDonald nació porque las listas generales confunden el vocabulario contable con mala noticia; es gratuito para investigación académica y su uso comercial necesita licencia.
- Los embeddings convierten una frase en un vector comparable por similitud coseno; con un modelo lineal encima y validación temporal, aprenden el tono, pero MiniLM es un modelo inglés y en español rinde menos.
- FinBERT, sin ver ninguna noticia del corpus, llega a 0,88 en las inglesas (el diccionario, 0,70) y no sirve en español. Su licencia (CC-BY-NC-SA-4.0) impide el uso comercial sin permiso.
- Los textos del curso salen de plantillas: las cifras de correlación son más altas que con noticias reales. Lo que se traslada al mundo real es el orden de los métodos y el tipo de error de cada uno.

## Recursos

### Documentación

- [Loughran y McDonald: Master Dictionary y listas de sentimiento (Universidad de Notre Dame)](https://sraf.nd.edu/loughranmcdonald-master-dictionary/) · sraf.nd.edu
- [Loughran y McDonald (2011): When Is a Liability Not a Liability?, Journal of Finance 66(1)](https://doi.org/10.1111/j.1540-6261.2010.01625.x) · doi.org
- [Araci (2019): FinBERT, Financial Sentiment Analysis with Pre-trained Language Models](https://arxiv.org/abs/1908.10063) · arxiv.org
- [Ficha de ProsusAI/finbert en Hugging Face (licencia CC-BY-NC-SA-4.0)](https://huggingface.co/ProsusAI/finbert) · huggingface.co
- [Hugging Face: algoritmos de tokenización (BPE, WordPiece, Unigram)](https://huggingface.co/docs/transformers/tokenizer_summary) · huggingface.co
- [Reimers y Gurevych (2019): Sentence-BERT](https://arxiv.org/abs/1908.10084) · arxiv.org

### Datos

- [Datos del nivel 6: léxico financiero inventado (CSV)](https://pythonparatrading.com/curso/datos/nivel-6/texto6_lexico.csv) · Descarga · texto6_lexico.csv

Lección siguiente: [6.1.2 · Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales](https://pythonparatrading.com/curso-python-trading/nivel-6/fuentes-de-texto-edgar-cnmv-transcripciones-y-redes/)
