{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "2ff9a9ee",
   "metadata": {},
   "source": [
    "# Del texto al vector: Machado contra Bécquer\n",
    "\n",
    "Cuaderno de apoyo a la pieza [Del texto al vector](https://www.maquinashablantes.com/taller/del-texto-al-vector). Recorre los pasos que da un texto antes de que un modelo lo clasifique: **tokenizar, normalizar, representar y clasificar**. En cada paso se comparan varias técnicas con los mismos poemas.\n",
    "\n",
    "La tarea final es adivinar si un fragmento de cuatro versos es de Antonio Machado o de Gustavo Adolfo Bécquer. Los textos vienen del Proyecto Gutenberg y son de dominio público.\n",
    "\n",
    "Ejecuta las celdas en orden con **Mayús + Intro**. Las cifras pueden variar un poco según la versión de las librerías."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d3a24896",
   "metadata": {},
   "source": [
    "## 0. Instalar y descargar recursos\n",
    "\n",
    "NLTK, spaCy y scikit-learn ya vienen en Colab. Falta `tiktoken`, el tokenizador que publica OpenAI, y el modelo mediano de español de spaCy, que incluye vectores de palabras."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "83023003",
   "metadata": {},
   "outputs": [],
   "source": [
    "!pip -q install tiktoken\n",
    "!python -m spacy download es_core_news_md -q\n",
    "\n",
    "import nltk\n",
    "nltk.download(\"punkt_tab\", quiet=True)\n",
    "nltk.download(\"stopwords\", quiet=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "394a0513",
   "metadata": {},
   "source": [
    "## 1. Preparar el corpus\n",
    "\n",
    "Descargamos los dos libros y nos quedamos solo con los poemas. Igual que en cualquier corpus real, hay que quitar el aparato: licencia, índices, notas del transcriptor y anuncios de la editorial.\n",
    "\n",
    "- Machado: *Poesías completas* (1917), Gutenberg n.º 68525.\n",
    "- Bécquer: *Obras escogidas*, Gutenberg n.º 53552. Usamos solo las *Rimas*."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "87fa85d2",
   "metadata": {},
   "outputs": [],
   "source": [
    "import re\n",
    "from urllib.request import urlopen\n",
    "\n",
    "def descargar(numero):\n",
    "    url = f\"https://www.gutenberg.org/cache/epub/{numero}/pg{numero}.txt\"\n",
    "    return urlopen(url).read().decode(\"utf-8\").replace(\"\\r\\n\", \"\\n\")\n",
    "\n",
    "def entre(texto, inicio, fin):\n",
    "    i = texto.find(inicio)\n",
    "    return texto[i:texto.find(fin, i)]\n",
    "\n",
    "machado = entre(descargar(68525), \"SOLEDADES\\n\\n                                   I\", \"PUBLICACIONES DE LA\\n\")\n",
    "becquer = entre(descargar(53552), \"RIMAS\\n\\n\\n\\n\\n[Ilustración]\", \"ÍNDICE\")\n",
    "print(len(machado), \"caracteres de Machado\")\n",
    "print(len(becquer), \"caracteres de Bécquer\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3c7fc025",
   "metadata": {},
   "source": [
    "Cada poema empieza con un número romano en una línea propia. Partimos por ahí, descartamos títulos en mayúsculas e ilustraciones y guardamos los versos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "09cc68f9",
   "metadata": {},
   "outputs": [],
   "source": [
    "def poemas(texto):\n",
    "    resultado = []\n",
    "    for trozo in re.split(r\"(?m)^\\s*[IVXLC]+\\.?\\s*$\", texto):\n",
    "        versos = [l.strip() for l in trozo.splitlines()\n",
    "                  if l.strip()\n",
    "                  and not l.strip().isupper()          # títulos\n",
    "                  and not l.strip().startswith(\"[\")    # [Ilustración]\n",
    "                  and not set(l.strip()) <= set(\". *\")]  # líneas de puntos\n",
    "        if len(versos) >= 4:\n",
    "            resultado.append(versos)\n",
    "    return resultado\n",
    "\n",
    "poemas_m, poemas_b = poemas(machado), poemas(becquer)\n",
    "print(len(poemas_m), \"poemas de Machado,\", sum(map(len, poemas_m)), \"versos\")\n",
    "print(len(poemas_b), \"rimas de Bécquer,\", sum(map(len, poemas_b)), \"versos\")\n",
    "print()\n",
    "print(\"\\n\".join(poemas_b[52][:4]))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9433cf2a",
   "metadata": {},
   "source": [
    "Machado escribió casi cuatro veces más versos. Si no lo corregimos, un clasificador aprendería que «casi todo es Machado». Partimos los poemas en fragmentos de cuatro versos y tomamos el mismo número de fragmentos de cada autor."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9965f2a3",
   "metadata": {},
   "outputs": [],
   "source": [
    "import random\n",
    "\n",
    "def fragmentos(lista_poemas, autor, n=4):\n",
    "    # cada fragmento recuerda de qué poema viene: lo necesitaremos al evaluar\n",
    "    # el poema se identifica por su primer verso\n",
    "    return [(\"\\n\".join(p[i:i + n]), autor, f\"{autor}: «{p[0]}»\")\n",
    "            for p in lista_poemas for i in range(0, len(p) - n + 1, n)]\n",
    "\n",
    "def preparar_corpus(n=4):\n",
    "    frag_m, frag_b = fragmentos(poemas_m, \"Machado\", n), fragmentos(poemas_b, \"Bécquer\", n)\n",
    "    random.seed(0)\n",
    "    frag_m = random.sample(frag_m, len(frag_b))   # equilibrar las dos clases\n",
    "    datos = frag_m + frag_b\n",
    "    return [d[0] for d in datos], [d[1] for d in datos], [d[2] for d in datos]\n",
    "\n",
    "textos, autores, poema = preparar_corpus(n=4)\n",
    "print(len(textos) // 2, \"fragmentos de cada autor\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5c28c7a3",
   "metadata": {},
   "source": [
    "## 2. Tokenizar: ¿qué es una palabra?\n",
    "\n",
    "Tokenizar es partir el texto en unidades. Parece trivial hasta que hay que decidir qué hacer con la puntuación, los saltos de línea o las palabras largas. Probamos seis técnicas con dos versos de la rima LIII."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "146e7bff",
   "metadata": {},
   "outputs": [],
   "source": [
    "from nltk.tokenize import WordPunctTokenizer\n",
    "import spacy, tiktoken\n",
    "\n",
    "verso = \"Volverán las oscuras golondrinas\\nEn tu balcón sus nidos á colgar,\"\n",
    "\n",
    "nlp = spacy.load(\"es_core_news_md\")\n",
    "gpt = tiktoken.get_encoding(\"o200k_base\")   # el tokenizador de GPT-4o\n",
    "\n",
    "tecnicas = {\n",
    "    \"espacios\": verso.split(),\n",
    "    \"expresión regular\": re.findall(r\"\\w+\", verso),\n",
    "    \"NLTK WordPunct\": WordPunctTokenizer().tokenize(verso),\n",
    "    \"NLTK word_tokenize\": nltk.word_tokenize(verso, language=\"spanish\"),\n",
    "    \"spaCy\": [t.text for t in nlp(verso)],\n",
    "    \"subpalabras (o200k)\": [gpt.decode([i]) for i in gpt.encode(verso)],\n",
    "}\n",
    "for nombre, tokens in tecnicas.items():\n",
    "    print(f\"{nombre:22} {len(tokens):3}  {tokens}\")\n",
    "print(f\"{'caracteres':22} {len(verso):3}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f9207287",
   "metadata": {},
   "source": [
    "Fíjate en tres cosas:\n",
    "\n",
    "- Con espacios, `colgar,` se queda pegado a la coma: para el ordenador ya no es la misma palabra que `colgar`.\n",
    "- spaCy conserva el salto de línea como token. En poesía puede ser útil: marca el fin de verso.\n",
    "- El tokenizador de subpalabras corta *golondrinas* en `gol` + `ond` + `r` + `inas`. No son morfemas: son trozos frecuentes en los textos con los que se construyó su vocabulario."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5cec7739",
   "metadata": {},
   "outputs": [],
   "source": [
    "for palabra in [\"golondrina\", \"golondrinas\", \"volverán\", \"volvemos\", \"caminante\", \"andar\"]:\n",
    "    print(f\"{palabra:12}\", [gpt.decode([i]) for i in gpt.encode(\" \" + palabra)])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4df8afd4",
   "metadata": {},
   "source": [
    "> **Ejercicio.** Prueba un verso tuyo con muchas palabras derivadas o raras. ¿Alguna vez coinciden los trozos con morfemas?\n",
    "\n",
    "## 3. Normalizar: ¿cuándo son iguales dos palabras?\n",
    "\n",
    "Normalizar es decidir qué diferencias ignoramos: mayúsculas, tildes, flexión, palabras gramaticales. Cada decisión borra información. Lo vemos con Machado."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "06edb139",
   "metadata": {},
   "outputs": [],
   "source": [
    "from nltk.stem.snowball import SnowballStemmer\n",
    "\n",
    "proverbio = (\"Caminante, son tus huellas\\nel camino, y nada más;\\n\"\n",
    "             \"caminante, no hay camino,\\nse hace camino al andar.\")\n",
    "stemmer = SnowballStemmer(\"spanish\")\n",
    "\n",
    "print(f\"{'forma':12}{'raíz (Snowball)':17}{'lema (spaCy)':14}categoría\")\n",
    "for t in nlp(proverbio):\n",
    "    if t.is_alpha:\n",
    "        print(f\"{t.text:12}{stemmer.stem(t.text):17}{t.lemma_:14}{t.pos_}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c7f06881",
   "metadata": {},
   "source": [
    "Revisa la tabla con ojo de lingüista:\n",
    "\n",
    "- El *stemmer* reduce *caminante* y *camino* a la misma raíz, `camin`. Para buscar documentos puede servir; para estudiar el léxico, mezcla un sustantivo de persona con uno de lugar.\n",
    "- El lematizador acierta con *son* → *ser* y *hay* → *haber*, pero analiza el primer *Caminante* como nombre propio (por la mayúscula) y el segundo como un verbo inventado, *caminantar*. El modelo se entrenó con prensa, no con poesía.\n",
    "\n",
    "Ahora, las palabras vacías (*stopwords*): la lista de NLTK para el español tiene más de 300."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4648f7ee",
   "metadata": {},
   "outputs": [],
   "source": [
    "from nltk.corpus import stopwords\n",
    "vacias = set(stopwords.words(\"spanish\"))\n",
    "print(len(vacias), \"palabras vacías\")\n",
    "print(\"¿Están no, nada, yo, me?\", [p for p in [\"no\", \"nada\", \"yo\", \"me\"] if p in vacias])\n",
    "print([p for p in nltk.word_tokenize(proverbio.lower(), language=\"spanish\")\n",
    "       if p.isalpha() and p not in vacias])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9a5f2da1",
   "metadata": {},
   "source": [
    "Al quitar las palabras vacías desaparece *no*: «no hay camino» se queda en «camino». Para clasificar por tema puede dar igual; para analizar sentimiento o estilo es un desastre.\n",
    "\n",
    "Por último, la ortografía de la edición. La edición de Bécquer usa normas antiguas: la preposición *á* con tilde y mayúscula al principio de cada verso. Machado, en la de 1917, no."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6dfd40ee",
   "metadata": {},
   "outputs": [],
   "source": [
    "for forma in [\"á\", \"ó\", \"fué\"]:\n",
    "    m = len(re.findall(rf\"\\b{forma}\\b\", machado))\n",
    "    b = len(re.findall(rf\"\\b{forma}\\b\", becquer))\n",
    "    print(f\"«{forma}»: Machado {m}, Bécquer {b}\")\n",
    "\n",
    "def normalizar(texto):\n",
    "    texto = texto.lower().replace(\"_\", \"\")   # «_» marca la cursiva en Gutenberg\n",
    "    for antigua, actual in {\"á\": \"a\", \"ó\": \"o\", \"é\": \"e\", \"fué\": \"fue\"}.items():\n",
    "        texto = re.sub(rf\"\\b{antigua}\\b\", actual, texto)\n",
    "    return texto\n",
    "\n",
    "print(normalizar(\"En tu balcón sus nidos á colgar,\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c92f4ba5",
   "metadata": {},
   "source": [
    "Guarda este dato: será importante en la clasificación.\n",
    "\n",
    "## 4. Representar: del texto a los números\n",
    "\n",
    "Un modelo no lee letras, solo números. Hay que convertir cada fragmento en un vector.\n",
    "\n",
    "### One-hot y bolsa de palabras\n",
    "\n",
    "En **one-hot**, cada palabra del vocabulario es una columna: un 1 en la suya y 0 en las demás. La **bolsa de palabras** (BoW) suma esos vectores: cuenta cuántas veces aparece cada palabra y olvida el orden."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "80991a85",
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "from sklearn.feature_extraction.text import CountVectorizer\n",
    "\n",
    "frases = [\"Una rosa es roja, una violeta es azul\",\n",
    "          \"Mi amor es rojo, como una rosa roja\"]\n",
    "bow = CountVectorizer()\n",
    "matriz = bow.fit_transform(frases)\n",
    "pd.DataFrame(matriz.toarray(), columns=bow.get_feature_names_out(), index=[\"frase 1\", \"frase 2\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cf2e8814",
   "metadata": {},
   "source": [
    "*roja* y *rojo* son columnas distintas: sin lematizar, la bolsa no sabe que son la misma palabra. Y el vocabulario de todo el corpus crece con cada palabra nueva: una columna por palabra, casi todas a cero."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ef8a8d1d",
   "metadata": {},
   "outputs": [],
   "source": [
    "bow_corpus = CountVectorizer().fit(textos)\n",
    "print(len(bow_corpus.vocabulary_), \"columnas para\", len(textos), \"fragmentos\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ba06ed03",
   "metadata": {},
   "source": [
    "### TF-IDF\n",
    "\n",
    "TF-IDF pondera cada palabra: sube si es frecuente en el fragmento (TF) y baja si aparece en muchos fragmentos (IDF). Así *que* o *la* pesan menos que *golondrinas*.\n",
    "\n",
    "La fórmula clásica del IDF es log(N / df): número de documentos entre documentos que contienen la palabra. scikit-learn usa por defecto una versión suavizada, ln((1 + N) / (1 + df)) + 1, y además normaliza cada vector. Por eso un cálculo a mano no coincide con el suyo si no se tiene en cuenta."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "83416294",
   "metadata": {},
   "outputs": [],
   "source": [
    "import math\n",
    "from sklearn.feature_extraction.text import TfidfVectorizer\n",
    "\n",
    "tfidf = TfidfVectorizer().fit(textos)\n",
    "N = len(textos)\n",
    "for palabra in [\"que\", \"tierra\", \"golondrinas\"]:\n",
    "    df = sum(1 for t in textos if re.search(rf\"\\b{palabra}\\b\", t.lower()))\n",
    "    clasico = math.log(N / df)\n",
    "    sklearn_idf = tfidf.idf_[tfidf.vocabulary_[palabra]]\n",
    "    print(f\"{palabra:12} aparece en {df:3} fragmentos · IDF clásico {clasico:.2f} · scikit-learn {sklearn_idf:.2f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ee6fedef",
   "metadata": {},
   "source": [
    "### Embeddings\n",
    "\n",
    "Un *embedding* es un vector denso, de unas 300 dimensiones, aprendido a partir de qué palabras aparecen cerca de cuáles. Las palabras que se usan en contextos parecidos quedan cerca. Las dimensiones por separado no significan nada interpretable: lo que importa son las distancias."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a8e01103",
   "metadata": {},
   "outputs": [],
   "source": [
    "pares = [(\"rosa\", \"violeta\"), (\"rosa\", \"tierra\"), (\"amor\", \"beso\"), (\"amor\", \"tierra\"),\n",
    "         (\"noche\", \"tarde\"), (\"golondrina\", \"pájaro\")]\n",
    "for a, b in pares:\n",
    "    print(f\"{a:11} ~ {b:8} {nlp(a).similarity(nlp(b)):.2f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e481c2c3",
   "metadata": {},
   "source": [
    "*golondrina* y *pájaro* dan 1,00: son idénticos. No es que el modelo sepa mucho de aves. Para ahorrar espacio, el modelo mediano guarda vectores propios solo para las palabras más frecuentes y a las demás les asigna el vector de la más parecida. Conviene comprobar estas cosas antes de fiarse de una cifra.\n",
    "\n",
    "Estos vectores de spaCy son **estáticos**: *banco* tiene el mismo vector en «me senté en el banco» y en «abrí una cuenta en el banco». Los modelos actuales, basados en Transformers, calculan un vector distinto según el contexto.\n",
    "\n",
    "## 5. Clasificar: ¿Machado o Bécquer?\n",
    "\n",
    "Entrenamos una regresión logística, un clasificador lineal sencillo que además deja ver qué palabras usa como pistas. Antes, dos decisiones de método que cambian los resultados más que cualquier técnica.\n",
    "\n",
    "**La línea base.** Con el mismo número de fragmentos de cada autor, responder al azar acierta el 50 %. Cualquier cifra hay que leerla frente a ese 50 %, no frente al 0 %.\n",
    "\n",
    "**Separar por poema.** Si partimos los fragmentos al azar, cuatro versos de un poema pueden caer en el entrenamiento y los cuatro siguientes en la prueba. El modelo reconocerá palabras de ese poema concreto (*golondrinas*, *Alvargonzález*) y parecerá mejor de lo que es. Es una **fuga de datos**. Lo correcto es que cada poema entero esté o en un lado o en el otro.\n",
    "\n",
    "Para no depender de una sola partición, usamos **validación cruzada**: repartimos los poemas en cinco grupos, entrenamos cinco veces dejando fuera un grupo distinto y promediamos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a0379766",
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "from sklearn.linear_model import LogisticRegression\n",
    "from sklearn.model_selection import GroupKFold, StratifiedKFold, cross_val_score\n",
    "from sklearn.pipeline import make_pipeline\n",
    "\n",
    "palabras = r\"(?u)\\b\\w+\\b\"   # cuenta también palabras de una letra, como «y» o «á»\n",
    "\n",
    "def evaluar(nombre, vectorizador, preparar=None, al_azar=False, n=4):\n",
    "    X, y, grupos = preparar_corpus(n)\n",
    "    if preparar:\n",
    "        X = [preparar(t) for t in X]\n",
    "    modelo = make_pipeline(vectorizador, LogisticRegression(max_iter=2000))\n",
    "    if al_azar:   # partición ingenua: fragmentos sueltos\n",
    "        notas = cross_val_score(modelo, X, y, cv=StratifiedKFold(5, shuffle=True, random_state=0))\n",
    "    else:         # partición correcta: poemas enteros\n",
    "        notas = cross_val_score(modelo, X, y, cv=GroupKFold(5), groups=grupos)\n",
    "    print(f\"{nombre:38} {notas.mean():.0%} (entre {notas.min():.0%} y {notas.max():.0%})\")\n",
    "\n",
    "def pistas(vectorizador, preparar=None, cuantas=10):\n",
    "    X = [preparar(t) for t in textos] if preparar else textos\n",
    "    modelo = LogisticRegression(max_iter=2000).fit(vectorizador.fit_transform(X), autores)\n",
    "    rasgos = np.array(vectorizador.get_feature_names_out())\n",
    "    orden = np.argsort(modelo.coef_[0])\n",
    "    print(f\"pistas de {modelo.classes_[0]}:\", \", \".join(rasgos[orden[:cuantas]]))\n",
    "    print(f\"pistas de {modelo.classes_[1]}:\", \", \".join(rasgos[orden[-cuantas:]]))\n",
    "\n",
    "evaluar(\"TF-IDF normalizado, fragmentos al azar\", TfidfVectorizer(token_pattern=palabras), normalizar, al_azar=True)\n",
    "evaluar(\"TF-IDF normalizado, por poema\", TfidfVectorizer(token_pattern=palabras), normalizar)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1efec0ba",
   "metadata": {},
   "source": [
    "La diferencia entre las dos líneas es la fuga: varios puntos de exactitud que no eran reales. A partir de aquí, todo se evalúa por poema.\n",
    "\n",
    "### 5.1. Un 91 % por la razón equivocada\n",
    "\n",
    "Empezamos sin normalizar nada: mayúsculas y ortografía tal como vienen en cada edición."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "683e8081",
   "metadata": {},
   "outputs": [],
   "source": [
    "evaluar(\"Bolsa de palabras sin normalizar\", CountVectorizer(lowercase=False, token_pattern=palabras))\n",
    "pistas(CountVectorizer(lowercase=False, token_pattern=palabras))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "13e1b21d",
   "metadata": {},
   "source": [
    "Las pistas de Bécquer son *á*, *Que*, *De*, *Y*: la tilde antigua de la preposición y la mayúscula a principio de verso. El modelo no ha aprendido a Bécquer, ha aprendido **la edición**. Es un **atajo**: una regularidad del corpus que permite acertar sin resolver la tarea.\n",
    "\n",
    "¿Cómo se demuestra? Modernizamos los fragmentos de Bécquer (sin tilde en *a* ni mayúscula a principio de verso, salvo tras punto) y se los damos al mismo modelo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f57c59ff",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.model_selection import GroupShuffleSplit\n",
    "\n",
    "def modernizar(texto):\n",
    "    # ponytail: regla simple; baja también algún nombre propio a principio de verso\n",
    "    texto = re.sub(r\"\\bá\\b\", \"a\", re.sub(r\"\\bó\\b\", \"o\", texto))\n",
    "    versos = texto.split(\"\\n\")\n",
    "    nuevos = [versos[0]]\n",
    "    for anterior, verso in zip(versos, versos[1:]):\n",
    "        fin_de_frase = anterior.rstrip().endswith((\".\", \"?\", \"!\"))\n",
    "        nuevos.append(verso if fin_de_frase else verso[:1].lower() + verso[1:])\n",
    "    return \"\\n\".join(nuevos)\n",
    "\n",
    "ent, prueba = next(GroupShuffleSplit(1, test_size=0.25, random_state=0).split(textos, autores, poema))\n",
    "X_ent, y_ent = [textos[i] for i in ent], [autores[i] for i in ent]\n",
    "becquer_prueba = [textos[i] for i in prueba if autores[i] == \"Bécquer\"]\n",
    "\n",
    "print(becquer_prueba[0], \"\\n→\", modernizar(becquer_prueba[0]).replace(\"\\n\", \" / \"), \"\\n\")\n",
    "\n",
    "for nombre, vect, prep in [\n",
    "        (\"sin normalizar\", CountVectorizer(lowercase=False, token_pattern=palabras), lambda t: t),\n",
    "        (\"normalizado\", TfidfVectorizer(token_pattern=palabras), normalizar)]:\n",
    "    modelo = make_pipeline(vect, LogisticRegression(max_iter=2000)).fit([prep(t) for t in X_ent], y_ent)\n",
    "    original = np.mean(modelo.predict([prep(t) for t in becquer_prueba]) == \"Bécquer\")\n",
    "    moderno = np.mean(modelo.predict([prep(modernizar(t)) for t in becquer_prueba]) == \"Bécquer\")\n",
    "    print(f\"Modelo {nombre:15} reconoce a Bécquer: {original:.0%} en la edición antigua, {moderno:.0%} modernizado\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "17cf923d",
   "metadata": {},
   "source": [
    "El modelo sin normalizar se hunde al cambiar la ortografía: deja de reconocer a la mayoría de los fragmentos de Bécquer. El normalizado ni se inmuta, porque ya veía el texto sin esas marcas. Esta prueba, cambiar algo que no debería importar y ver si la predicción cambia, es la manera más directa de descubrir un atajo.\n",
    "\n",
    "### 5.2. Normalizar y comparar técnicas"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "286d2301",
   "metadata": {},
   "outputs": [],
   "source": [
    "evaluar(\"Bolsa de palabras normalizada\", CountVectorizer(token_pattern=palabras), normalizar)\n",
    "evaluar(\"TF-IDF normalizado\", TfidfVectorizer(token_pattern=palabras), normalizar)\n",
    "evaluar(\"TF-IDF sin palabras vacías\", TfidfVectorizer(token_pattern=palabras, stop_words=list(vacias)), normalizar)\n",
    "evaluar(\"TF-IDF de n-gramas de caracteres\", TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4)), normalizar)\n",
    "print()\n",
    "pistas(TfidfVectorizer(token_pattern=palabras, stop_words=list(vacias)), normalizar)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5a03cb7f",
   "metadata": {},
   "source": [
    "La exactitud baja respecto al 91 %, pero las pistas son literarias. Bécquer: *dolor*, *beso*, *espíritu*, *palabras*: el yo lírico ante la amada. Machado: *tierra*, *campo*, *montes*, *España*, *agua*, *tarde*: el paisaje.\n",
    "\n",
    "Los n-gramas de caracteres (trozos de dos a cuatro letras) quedan los primeros: captan terminaciones verbales y palabras cortas a la vez.\n",
    "\n",
    "### 5.3. Tema o estilo\n",
    "\n",
    "Las pistas anteriores son sobre todo **temas**. Pero dos poetas que escribieran sobre lo mismo seguirían distinguiéndose por su manera de usar la gramática. Es la idea central de la **estilometría**: las palabras gramaticales (pronombres, artículos, preposiciones, tiempos de *ser* y *estar*) se usan sin pensar y delatan al autor aunque cambie el tema. Probamos con un modelo que solo ve las palabras vacías, justo las que antes quitábamos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ba21eb65",
   "metadata": {},
   "outputs": [],
   "source": [
    "solo_gramatica = lambda: TfidfVectorizer(token_pattern=palabras, vocabulary=sorted(vacias))\n",
    "evaluar(\"Solo palabras vacías\", solo_gramatica(), normalizar)\n",
    "pistas(solo_gramatica(), normalizar, cuantas=12)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bfdc241d",
   "metadata": {},
   "source": [
    "Sin una sola palabra de contenido, el modelo supera con claridad el 50 %. Y las pistas cuentan algo: Bécquer es *me*, *yo*, *soy*, *mía*, *qué*, *cuando*, la primera persona en presente; Machado es *era*, *eran*, *está*, *hay*, *este*, *esta*, la descripción y el imperfecto narrativo. Esto es lo que se pierde al quitar las palabras vacías «porque no aportan».\n",
    "\n",
    "### 5.4. Cuánto texto hace falta"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "dd85e288",
   "metadata": {},
   "outputs": [],
   "source": [
    "for n in [2, 4, 8]:\n",
    "    evaluar(f\"TF-IDF normalizado, fragmentos de {n} versos\", TfidfVectorizer(token_pattern=palabras), normalizar, n=n)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "31d60b2f",
   "metadata": {},
   "source": [
    "Con dos versos hay muy poca información; con ocho, bastante más. Por eso la atribución de autoría se hace con textos largos y por eso conviene desconfiar de quien atribuye un tuit a alguien por su estilo.\n",
    "\n",
    "### 5.5. Embeddings\n",
    "\n",
    "El vector de un fragmento es la media de los vectores de sus palabras."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c7a37857",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.preprocessing import FunctionTransformer\n",
    "\n",
    "a_vector = FunctionTransformer(lambda docs: np.array([nlp(d).vector for d in docs]))\n",
    "evaluar(\"Embeddings de spaCy (media)\", a_vector, normalizar)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "01f048be",
   "metadata": {},
   "source": [
    "Los embeddings quedan a la altura de TF-IDF, no por encima, y se pierde algo valioso: ya no hay pistas que leer, porque sus 300 dimensiones no corresponden a palabras. Al promediar, además, se diluyen justo las palabras concretas (*me*, *yo*, *era*, *tierra*) que distinguen a los dos autores. La técnica más reciente no es siempre la mejor para una tarea concreta.\n",
    "\n",
    "## 6. Por qué decide lo que decide\n",
    "\n",
    "Entrenamos el modelo normalizado con todos los fragmentos y le pedimos que explique una predicción: cada palabra empuja hacia un autor con un peso."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "249e6f9f",
   "metadata": {},
   "outputs": [],
   "source": [
    "vect = TfidfVectorizer(token_pattern=palabras)\n",
    "modelo = LogisticRegression(max_iter=2000).fit(vect.fit_transform([normalizar(t) for t in textos]), autores)\n",
    "\n",
    "def quien_lo_escribio(texto, explicar=True):\n",
    "    x = vect.transform([normalizar(texto)])\n",
    "    prob = dict(zip(modelo.classes_, modelo.predict_proba(x)[0]))\n",
    "    print(\" · \".join(f\"{autor} {p:.0%}\" for autor, p in prob.items()))\n",
    "    if explicar:\n",
    "        pesos = x.toarray()[0] * modelo.coef_[0]   # negativo: Bécquer; positivo: Machado\n",
    "        rasgos = vect.get_feature_names_out()\n",
    "        usadas = sorted((pesos[i], rasgos[i]) for i in x.nonzero()[1])\n",
    "        print(\"  empujan hacia Bécquer:\", \", \".join(w for p, w in usadas[:5] if p < 0))\n",
    "        print(\"  empujan hacia Machado:\", \", \".join(w for p, w in usadas[::-1][:5] if p > 0))\n",
    "\n",
    "quien_lo_escribio(\"\"\"Yo voy soñando caminos\n",
    "de la tarde. ¡Las colinas\n",
    "doradas, los verdes pinos,\n",
    "las polvorientas encinas!\"\"\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a0570c59",
   "metadata": {},
   "source": [
    "Ahora prueba tú. Escribe cuatro versos a la manera de uno de los dos, o pega un poema de otro autor. El modelo siempre responderá Machado o Bécquer, porque no conoce otra opción: es un **conjunto cerrado**. Un poema de Rosalía de Castro o de Lorca saldrá atribuido a uno de los dos con toda tranquilidad. Una probabilidad cercana al 50 % es la única señal de que «no se parece a ninguno»."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "01ca1021",
   "metadata": {},
   "outputs": [],
   "source": [
    "quien_lo_escribio(\"\"\"Escribe aquí tus versos,\n",
    "uno por línea,\n",
    "como en un poema,\n",
    "y ejecuta la celda.\"\"\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "543559ba",
   "metadata": {},
   "source": [
    "## 7. Leer los errores\n",
    "\n",
    "Usamos la partición por poema del apartado 5.1 y miramos qué fragmentos falla el modelo normalizado, agrupados por poema."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a686c789",
   "metadata": {},
   "outputs": [],
   "source": [
    "from collections import Counter\n",
    "\n",
    "modelo_p = make_pipeline(TfidfVectorizer(token_pattern=palabras), LogisticRegression(max_iter=2000))\n",
    "modelo_p.fit([normalizar(t) for t in X_ent], y_ent)\n",
    "X_prueba = [textos[i] for i in prueba]\n",
    "y_prueba = [autores[i] for i in prueba]\n",
    "p_prueba = [poema[i] for i in prueba]\n",
    "pred = modelo_p.predict([normalizar(t) for t in X_prueba])\n",
    "errores = [(t, real, po) for t, real, po, p in zip(X_prueba, y_prueba, p_prueba, pred) if real != p]\n",
    "print(len(errores), \"errores de\", len(X_prueba))\n",
    "print(\"poemas con más errores:\", Counter(po for _, _, po in errores).most_common(4))\n",
    "for texto, real, po in errores[:3]:\n",
    "    print(f\"\\n{po} (era {real}):\\n{texto}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "af51ae99",
   "metadata": {},
   "source": [
    "¿Qué tienen en común los fragmentos mal atribuidos? Fíjate en los de Machado: el «Retrato» («soy, en el buen sentido de la palabra, bueno»), un poema de amor («Arde en tus ojos un misterio, virgen»), un diálogo en primera persona. Cuando Machado dice *yo* y habla de amor, el modelo oye a Bécquer. Y revisa también el corpus: alguna dedicatoria («A Juan Ramón Jiménez, por su libro _Platero y yo_») se ha colado como si fuera un verso. No todos los errores son del modelo. Ese análisis es lingüístico y es el que dice qué le falta al modelo.\n",
    "\n",
    "## Ejercicios\n",
    "\n",
    "1. **Tema frente a estilo.** Repite el apartado 5.3 con fragmentos de 8 versos. ¿Mejora más el modelo de solo palabras vacías o el de TF-IDF completo?\n",
    "2. **Busca otro atajo.** Quita `.replace(\"_\", \"\")` de `normalizar` y mira si las palabras en cursiva (marcadas con `_` en Gutenberg) aparecen como pistas.\n",
    "3. **Una tercera autora.** Descarga *En las orillas del Sar* de Rosalía de Castro con `descargar(70984)`, salta el prólogo en prosa y pasa varias estrofas por `quien_lo_escribio`. ¿A quién se parece más? ¿Tiene sentido literario? Su edición, de 1909, también escribe *á* con tilde: ¿qué haría con ella el modelo sin normalizar?\n",
    "\n",
    "## Al terminar\n",
    "\n",
    "Guarda una copia del cuaderno en Drive si quieres conservar los resultados. La máquina de Colab es temporal y los paquetes se instalan de nuevo en cada sesión."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
