#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Genera el notebook del Proyecto Integrador — Semana 7 (NRC-8774 PNL, Grupo 7).

Salida: Oviedo_Alexander_Proyecto_Integrador_S7.ipynb

El notebook resultante NO depende de archivos locales: descarga el corpus del
Quijote desde Project Gutenberg y lleva embebido el mini-corpus de clase como
respaldo, de modo que funciona igual en Google Colab que en local.
"""

import csv
import json
import os

AQUI = os.path.dirname(os.path.abspath(__file__))
CSV_CLASE = os.path.join(AQUI, "NRC-8774", "quijote_mini_corpus_.csv")
SALIDA = os.path.join(AQUI, "Oviedo_Alexander_Proyecto_Integrador_S7.ipynb")


# ----------------------------------------------------------------------------
# Mini-corpus de la docente (se embebe en el notebook para no depender de CSV)
# ----------------------------------------------------------------------------
def leer_mini_corpus_clase():
    """Lee el CSV de la docente y devuelve solo las frases únicas.

    El archivo original repite cada frase una segunda vez con la coletilla
    'El caballero y su escudero continuaron después su recorrido...'. Esa
    duplicación sesga el modelo, así que conservamos una sola aparición.
    """
    vistas = []
    with open(CSV_CLASE, encoding="utf-8") as fh:
        for fila in csv.DictReader(fh):
            texto = (fila.get("texto") or "").strip()
            if not texto:
                continue
            base = texto.split(" El caballero y su escudero continuaron")[0].strip()
            if base and base not in vistas:
                vistas.append(base)
    return vistas


MINI_CORPUS = leer_mini_corpus_clase()


# ----------------------------------------------------------------------------
# Utilidades de construcción de celdas
# ----------------------------------------------------------------------------
CELDAS = []


def md(texto):
    CELDAS.append({
        "cell_type": "markdown",
        "metadata": {},
        "source": texto.strip("\n").splitlines(keepends=True),
    })


def code(texto):
    CELDAS.append({
        "cell_type": "code",
        "execution_count": None,
        "metadata": {},
        "outputs": [],
        "source": texto.strip("\n").splitlines(keepends=True),
    })


# ============================================================================
# PORTADA
# ============================================================================
md("""
# Proyecto integrador — Predicción y generación de texto con redes neuronales recurrentes

**Corporación Universitaria Minuto de Dios (UNIMINUTO)**
Especialización en Inteligencia Artificial
NRC-8774 — Procesamiento Natural del Lenguaje — Semana 7

**Grupo 7**

| Integrantes |
|---|
| Alexander Oviedo Fadul |
| Maria Fernanda Ruiz Paipilla |
| Neheman Samir Jaller Cerchiaro |
| William David Obando Lopez |

**Docente:** Nathalia Orozco Morales
**Fecha de entrega:** 17 de agosto de 2026

---

### Sobre este cuaderno

Tomamos como punto de partida el insumo que la docente compartió en la sesión sincrónica
(`semana_7_26022026.ipynb`, bloque *Generación de texto con una red neuronal LSTM*) y seguimos
su indicación explícita en clase: *"yo les doy un insumo de referencia y ustedes lo complementan,
lo vuelven más complejo y le aumentan los epochs… ¿cómo lo denotan? Con las métricas"*
(Orozco Morales, 2026). En concreto, lo que agregamos sobre el insumo original es:

1. **Corpus ampliado y en línea.** El ejemplo de clase entrenaba con 60 filas (≈1.354 tokens).
   Aquí descargamos el texto real de *Don Quijote de la Mancha* desde Project Gutenberg, lo que
   multiplica el corpus por más de cincuenta veces. El cuaderno **no necesita ningún archivo
   local**.
2. **Dos modelos, no uno.** La actividad pide literalmente *predecir caracteres de un texto*,
   mientras que el insumo de clase trabaja a nivel de palabra. Implementamos ambos con la misma
   arquitectura exigida: `LSTM(128)` + `Dense` con `softmax`.
3. **Más épocas y seguimiento por época.** Cada época imprime sus métricas y genera texto con
   cuatro temperaturas distintas, que es justo lo que solicita el punto 4 del enunciado.
4. **Un pipeline que sí escala.** Usamos ventanas deslizantes con etiquetas enteras y
   `sparse_categorical_crossentropy`, en lugar de prefijos crecientes con *one-hot*: esto evita
   la explosión de memoria cuando el vocabulario crece.
""")

md("""
## Preguntas orientadoras

**¿Cuáles son las principales aplicaciones de las redes neuronales recurrentes?**

Las RNN se emplean en cualquier problema donde el orden importa: traducción automática, análisis
de sentimiento, reconocimiento de voz, predicción de series temporales, generación y autocompletado
de texto, etiquetado morfosintáctico y detección de anomalías en secuencias de sensores. El
autocompletado del correo electrónico o de un editor de código es probablemente el caso que más
usamos sin darnos cuenta (Srinivasa-Desikan, 2018; Zhou, 2022).

**¿Cuál es la arquitectura de una red neuronal recurrente?**

Una RNN procesa la secuencia paso a paso y mantiene un **estado oculto** que se realimenta:
`h_t = f(W_x · x_t + W_h · h_{t-1} + b)`. Ese estado funciona como memoria de trabajo. El problema
es que, al propagar el gradiente hacia atrás en secuencias largas, el gradiente se desvanece. La
LSTM resuelve eso añadiendo una **celda de memoria** y tres puertas —olvido, entrada y salida— que
deciden qué se conserva, qué entra y qué sale en cada paso (Hochreiter y Schmidhuber, 1997;
Goodfellow et al., 2016). En este proyecto la salida de la LSTM pasa a una capa `Dense` con
`softmax`, que convierte los 128 valores del estado en una distribución de probabilidad sobre todo
el vocabulario.
""")

md("""
## Marco teórico breve

**LSTM (128 unidades).** Las 128 unidades no son 128 palabras memorizadas: son 128 valores
numéricos que resumen todo lo que la red ha procesado de la secuencia. A mayor número de unidades,
mayor capacidad de representación, pero también más parámetros que ajustar y más riesgo de
sobreajuste con un corpus pequeño.

**Dense + softmax.** La capa `Dense` proyecta esos 128 valores a una puntuación por cada elemento
del vocabulario. `softmax` normaliza esas puntuaciones en probabilidades que suman 1.

**Temperatura.** Antes de muestrear, dividimos los logaritmos de las probabilidades por un factor
`T`. Con `T < 1` la distribución se concentra en las opciones más probables (texto conservador y
repetitivo); con `T > 1` se aplana y da oportunidad a palabras poco frecuentes (texto más variado,
pero con riesgo de perder coherencia). Es el mismo mecanismo que controla la "creatividad" de los
modelos generativos actuales.

**Token OOV.** `Tokenizer(oov_token="<OOV>")` reserva un índice para las palabras que el modelo
nunca vio. Sin ese token, una palabra desconocida produciría una secuencia vacía y rompería la
inferencia; con él, el modelo la representa de forma consistente y sigue funcionando.

**Top-k.** Muestrear sobre todo el vocabulario introduce mucho ruido. Restringir la elección a las
`k` opciones más probables antes de aplicar la temperatura mejora notablemente la legibilidad.

**Una decisión de inferencia.** Como el vocabulario del modelo está acotado, `<OOV>` es un token
frecuente y el muestreo puede elegirlo. Al generar lo excluimos junto con el relleno del *padding*:
no es una modificación del modelo, sino una restricción en el momento de la inferencia, del mismo
tipo que el top-k. Sin ella, el texto sale salpicado de marcadores que no son palabras.
""")

# ============================================================================
# 1. CONFIGURACIÓN
# ============================================================================
md("""
---
## 1. Configuración del entorno

Fijamos las semillas para que el experimento sea reproducible y verificamos las versiones de las
librerías, un dato que conviene dejar registrado porque la API de Keras cambió bastante con la
versión 3.
""")

code('''
# -*- coding: utf-8 -*-
import os
import re
import json
import time
import urllib.request

import numpy as np
import matplotlib.pyplot as plt

import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Embedding, LSTM, Dense
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences

# ------------------------------------------------------------------
# Reproducibilidad
# ------------------------------------------------------------------
SEMILLA = 42
np.random.seed(SEMILLA)
tf.random.set_seed(SEMILLA)

try:
    import keras
    version_keras = keras.__version__
except Exception:
    version_keras = getattr(tf.keras, "__version__", "desconocida")

print("TensorFlow :", tf.__version__)
print("Keras      :", version_keras)
print("NumPy      :", np.__version__)
print("GPU        :", tf.config.list_physical_devices("GPU") or "no disponible (se usará CPU)")
''')

# ============================================================================
# 2. CORPUS
# ============================================================================
md("""
---
## 2. Corpus: descarga en línea, sin archivos locales

Una de las molestias del insumo original es que obliga a subir un CSV con el botón de Colab cada
vez que se reinicia el entorno. Aquí resolvemos eso de dos formas complementarias:

- **Fuente principal:** el texto íntegro de *Don Quijote de la Mancha* (Cervantes, 1605/2004) desde
  Project Gutenberg, que es dominio público. Recortamos desde `"En un lugar de la Mancha"` hasta el
  marcador de fin de la licencia y limitamos el volumen para que el entrenamiento sea viable.
- **Respaldo embebido:** el mini-corpus que la docente usó en clase, incluido como constante de
  Python. Si no hay red, el cuaderno sigue corriendo con él.

> **Nota crítica sobre el mini-corpus de clase.** El archivo original (`quijote_mini_corpus_.csv`)
> tiene 60 filas, pero en realidad son 30 frases repetidas: la segunda mitad es la primera mitad
> con la coletilla *"El caballero y su escudero continuaron después su recorrido con esperanza y
> curiosidad"* añadida al final. Esa repetición explica por qué en la demostración de clase el
> modelo tendía a producir cadenas como *"su su su y y y"*: estaba memorizando el relleno. Aquí
> conservamos las 30 frases únicas.
""")

corpus_json = json.dumps(MINI_CORPUS, ensure_ascii=False, indent=4)
code(f'''
# ==================================================================
# 2.1 Mini-corpus de clase (embebido: no requiere subir ningún CSV)
#     Fuente: Orozco Morales (2026), quijote_mini_corpus_.csv
#     Se conservan las 30 frases únicas de las 60 filas originales.
# ==================================================================
MINI_CORPUS_CLASE = {corpus_json}

print("Frases del mini-corpus de clase:", len(MINI_CORPUS_CLASE))
print("Caracteres                     :", sum(len(f) for f in MINI_CORPUS_CLASE))
''')

code('''
# ==================================================================
# 2.2 Descarga del Quijote desde Project Gutenberg
# ==================================================================
URL_QUIJOTE = "https://www.gutenberg.org/cache/epub/2000/pg2000.txt"
URL_ESPEJO  = "https://www.gutenberg.org/files/2000/2000-0.txt"

MAX_CARACTERES = 400_000     # recorte para que el entrenamiento quepa en una sesión de clase


def descargar_quijote(url, timeout=45):
    """Descarga el texto y devuelve el cuerpo de la novela, ya recortado."""
    peticion = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(peticion, timeout=timeout) as respuesta:
        crudo = respuesta.read().decode("utf-8", errors="ignore")

    # Recorte determinista: desde la primera línea de la novela hasta el pie de licencia
    inicio = crudo.find("En un lugar de la Mancha, de cuyo nombre")
    if inicio == -1:
        inicio = crudo.find("*** START OF THE PROJECT GUTENBERG")
    fin = crudo.find("*** END OF THE PROJECT GUTENBERG")
    if fin == -1:
        fin = len(crudo)

    return crudo[inicio:fin]


texto_gutenberg = ""
for url in (URL_QUIJOTE, URL_ESPEJO):
    try:
        print(f"Descargando corpus desde: {url}")
        texto_gutenberg = descargar_quijote(url)
        print(f"  -> descarga correcta: {len(texto_gutenberg):,} caracteres")
        break
    except Exception as error:                      # sin red, mirror caído, timeout...
        print(f"  -> falló ({type(error).__name__}: {error})")

if not texto_gutenberg:
    print("\\nNo fue posible descargar el corpus. Se continúa solo con el mini-corpus de clase.")
''')

code('''
# ==================================================================
# 2.3 Limpieza y unificación del corpus
# ==================================================================
def limpiar(texto):
    """Normaliza el texto conservando la puntuación relevante para el modelo."""
    texto = texto.replace("\\r", " ")
    texto = re.sub(r"\\[\\d+\\]", " ", texto)          # notas al pie del editor
    texto = re.sub(r"[«»_]", " ", texto)
    texto = re.sub(r"\\s+", " ", texto)
    return texto.strip()


partes = [limpiar(" ".join(MINI_CORPUS_CLASE))]

if texto_gutenberg:
    cuerpo = limpiar(texto_gutenberg)[:MAX_CARACTERES]
    partes.append(cuerpo)

corpus = " ".join(partes).lower()

palabras = corpus.split()
print("=" * 62)
print("CORPUS DE TRABAJO")
print("=" * 62)
print(f"Caracteres            : {len(corpus):,}")
print(f"Palabras              : {len(palabras):,}")
print(f"Vocabulario (únicas)  : {len(set(palabras)):,}")
print(f"Factor vs. clase (60 filas / 1.354 tokens): x{len(palabras) / 1354:.1f}")
print()
print("Primeros 400 caracteres:")
print("-" * 62)
print(corpus[:400])
''')

# ============================================================================
# 3. MODELO A - NIVEL PALABRA
# ============================================================================
md("""
---
## 3. Modelo A — LSTM a nivel de palabra

Este es el modelo que replica y amplía el insumo de la docente. La arquitectura es exactamente la
que pide el enunciado: **LSTM con 128 unidades internas más una capa `Dense` con `softmax`**.

Un detalle importante del diseño: en lugar de generar prefijos crecientes y codificar la salida con
*one-hot* (que en un vocabulario de miles de palabras produce matrices imposibles de sostener en
memoria), usamos **ventanas deslizantes de longitud fija** y etiquetas enteras con
`sparse_categorical_crossentropy`. El resultado es matemáticamente equivalente y consume varios
órdenes de magnitud menos memoria.
""")

code('''
# ==================================================================
# 3.1 Tokenización a nivel de palabra
# ==================================================================
VOCAB_MAX = 6000                 # se conservan las 6.000 palabras más frecuentes
SEQ_LEN   = 20                   # se usan 20 palabras de contexto para predecir la siguiente

tokenizer = Tokenizer(num_words=VOCAB_MAX, oov_token="<OOV>")
tokenizer.fit_on_texts([corpus])

tokens = tokenizer.texts_to_sequences([corpus])[0]
vocab_size = min(VOCAB_MAX, len(tokenizer.word_index) + 1)

print(f"Tokens totales      : {len(tokens):,}")
print(f"Palabras distintas  : {len(tokenizer.word_index):,}")
print(f"Vocabulario del modelo: {vocab_size:,} (recortado a las más frecuentes)")

print("\\nDiez palabras más frecuentes:")
for palabra, indice in list(tokenizer.word_index.items())[:10]:
    print(f"  {indice:>3}  {palabra}")
''')

code('''
# ==================================================================
# 3.2 Dataset con ventanas deslizantes
# ==================================================================
X, y = [], []
for i in range(SEQ_LEN, len(tokens)):
    X.append(tokens[i - SEQ_LEN:i])     # contexto
    y.append(tokens[i])                 # palabra siguiente

X = np.array(X, dtype=np.int32)
y = np.array(y, dtype=np.int32)

# División temporal (no aleatoria): el final del corpus queda como validación
FRAC_VAL = 0.15
corte = int(len(X) * (1 - FRAC_VAL))
X_tr, X_va = X[:corte], X[corte:]
y_tr, y_va = y[:corte], y[corte:]

BATCH = 128
AUTOTUNE = tf.data.AUTOTUNE

ds_train = (tf.data.Dataset.from_tensor_slices((X_tr, y_tr))
            .shuffle(min(len(X_tr), 20000), seed=SEMILLA)
            .batch(BATCH).prefetch(AUTOTUNE))
ds_val = (tf.data.Dataset.from_tensor_slices((X_va, y_va))
          .batch(BATCH).prefetch(AUTOTUNE))

print(f"Ejemplos generados : {len(X):,}")
print(f"Entrenamiento      : {len(X_tr):,}")
print(f"Validación         : {len(X_va):,}")
print(f"Forma de X         : {X.shape}")
print(f"\\nEjemplo de contexto: {' '.join(tokenizer.index_word.get(t, '<OOV>') for t in X[0])}")
print(f"Palabra objetivo   : {tokenizer.index_word.get(int(y[0]), '<OOV>')}")
''')

code('''
# ==================================================================
# 3.3 Arquitectura solicitada: Embedding -> LSTM(128) -> Dense(softmax)
# ==================================================================
modelo_palabra = Sequential([
    Embedding(input_dim=vocab_size, output_dim=128, name="embedding"),
    # 128 unidades internas; dropout regulariza sin añadir capas a la arquitectura pedida
    LSTM(128, dropout=0.2, name="lstm_128"),
    Dense(vocab_size, activation="softmax", name="salida_softmax"),
], name="LSTM_128_nivel_palabra")

modelo_palabra.compile(
    optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3),
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)

modelo_palabra.build(input_shape=(None, SEQ_LEN))
modelo_palabra.summary()
''')

code('''
# ==================================================================
# 3.4 Muestreo con temperatura y top-k
# ==================================================================
# Índices que nunca queremos emitir: 0 es el relleno del padding y 1 es <OOV>.
# Excluirlos en la inferencia no cambia el modelo, solo evita que el texto generado
# se llene de marcadores que no son palabras reales.
PROHIBIDOS = (0, 1)


def muestrear(probabilidades, k=30, temperatura=1.0):
    """Elige un índice aplicando primero temperatura y luego top-k."""
    p = np.asarray(probabilidades).astype(np.float64)

    # Se anulan padding y <OOV> antes de renormalizar
    for indice in PROHIBIDOS:
        if indice < len(p):
            p[indice] = 0.0
    p = p / p.sum()

    # Temperatura: aplana o agudiza la distribución
    logits = np.log(p + 1e-12) / max(temperatura, 1e-6)
    p = np.exp(logits - logits.max())
    p /= p.sum()

    # Top-k: se muestrea solo entre las k opciones más probables
    k = min(k, len(p))
    indices = np.argpartition(p, -k)[-k:]
    probs_k = p[indices] / p[indices].sum()
    return int(np.random.choice(indices, p=probs_k))


def generar_palabras(semilla, n_palabras=25, k=30, temperatura=0.8):
    """Genera texto palabra a palabra a partir de una frase semilla."""
    generado = str(semilla).strip()
    for _ in range(n_palabras):
        secuencia = tokenizer.texts_to_sequences([generado])[0][-SEQ_LEN:]
        entrada = pad_sequences([secuencia], maxlen=SEQ_LEN, padding="pre")
        # model(...) en lugar de model.predict(...): mucho más rápido en bucles cortos
        probas = modelo_palabra(entrada, training=False).numpy()[0]
        generado += " " + tokenizer.index_word.get(muestrear(probas, k, temperatura), "<OOV>")
    return generado


def top_k_siguiente(semilla, k=5):
    """Devuelve las k palabras más probables tras la frase dada."""
    secuencia = tokenizer.texts_to_sequences([semilla])[0][-SEQ_LEN:]
    entrada = pad_sequences([secuencia], maxlen=SEQ_LEN, padding="pre")
    probas = modelo_palabra(entrada, training=False).numpy()[0]
    mejores = [i for i in np.argsort(probas)[::-1] if i not in PROHIBIDOS][:k]
    return [(tokenizer.index_word.get(int(i), "<OOV>"), float(probas[i])) for i in mejores]
''')

code('''
# ==================================================================
# 3.5 Callback: métricas y generación de texto en CADA época
#     (requisito 4 del enunciado)
# ==================================================================
TEMPERATURAS = [0.3, 0.7, 1.0, 1.2]
SEMILLA_DEMO = "el caballero andante se lanzó a la aventura"

registro_epocas = []          # se conserva para el informe escrito


class GeneracionPorEpoca(tf.keras.callbacks.Callback):
    def __init__(self, semilla, n_palabras=22):
        super().__init__()
        self.semilla = semilla
        self.n_palabras = n_palabras

    def on_epoch_end(self, epoch, logs=None):
        logs = logs or {}
        print("\\n" + "=" * 74)
        print(f"GENERACIÓN DESPUÉS DE LA ÉPOCA {epoch + 1}")
        print("=" * 74)
        print(f"loss={logs.get('loss', 0):.4f} | val_loss={logs.get('val_loss', 0):.4f} | "
              f"accuracy={logs.get('accuracy', 0):.4f} | val_accuracy={logs.get('val_accuracy', 0):.4f}")

        muestras = {}
        for temperatura in TEMPERATURAS:
            texto = generar_palabras(self.semilla, self.n_palabras, k=30, temperatura=temperatura)
            muestras[str(temperatura)] = texto
            print(f"\\n[T = {temperatura}] {texto}")
        print("=" * 74)

        registro_epocas.append({
            "epoca": epoch + 1,
            "loss": float(logs.get("loss", 0)),
            "val_loss": float(logs.get("val_loss", 0)),
            "accuracy": float(logs.get("accuracy", 0)),
            "val_accuracy": float(logs.get("val_accuracy", 0)),
            "textos": muestras,
        })


callbacks_palabra = [
    GeneracionPorEpoca(SEMILLA_DEMO),
    tf.keras.callbacks.ReduceLROnPlateau(monitor="val_loss", factor=0.5,
                                         patience=3, min_lr=1e-5, verbose=1),
    tf.keras.callbacks.ModelCheckpoint("mejor_lstm_palabra.keras", monitor="val_loss",
                                       save_best_only=True, verbose=0),
]
''')

code('''
# ==================================================================
# 3.6 Entrenamiento completo (salida época a época)
# ==================================================================
EPOCAS_PALABRA = 30

inicio = time.time()
historia_palabra = modelo_palabra.fit(
    ds_train,
    validation_data=ds_val,
    epochs=EPOCAS_PALABRA,
    callbacks=callbacks_palabra,
    shuffle=False,          # el barajado ya lo hace tf.data
    verbose=2,
)
print(f"\\nTiempo total de entrenamiento: {time.time() - inicio:.1f} s")
''')

code('''
# ==================================================================
# 3.7 Curvas de aprendizaje
# ==================================================================
h = historia_palabra.history
fig, ejes = plt.subplots(1, 2, figsize=(13, 4.5))

ejes[0].plot(h["loss"], label="entrenamiento", linewidth=2)
ejes[0].plot(h["val_loss"], label="validación", linewidth=2, linestyle="--")
ejes[0].set_title("Pérdida — modelo a nivel de palabra")
ejes[0].set_xlabel("Época"); ejes[0].set_ylabel("Loss")
ejes[0].legend(); ejes[0].grid(alpha=0.3)

ejes[1].plot(h["accuracy"], label="entrenamiento", linewidth=2)
ejes[1].plot(h["val_accuracy"], label="validación", linewidth=2, linestyle="--")
ejes[1].set_title("Precisión — modelo a nivel de palabra")
ejes[1].set_xlabel("Época"); ejes[1].set_ylabel("Accuracy")
ejes[1].legend(); ejes[1].grid(alpha=0.3)

plt.tight_layout()
plt.savefig("fig_curvas_palabra.png", dpi=150, bbox_inches="tight")
plt.show()

print(f"loss     : {h['loss'][0]:.4f} -> {h['loss'][-1]:.4f}")
print(f"accuracy : {h['accuracy'][0]:.4f} -> {h['accuracy'][-1]:.4f}")
print(f"val_loss : {h['val_loss'][0]:.4f} -> {h['val_loss'][-1]:.4f}  (mínimo: {min(h['val_loss']):.4f})")
''')

code('''
# ==================================================================
# 3.8 Tabla resumen del entrenamiento
# ==================================================================
print(f"{'Época':>6} {'loss':>10} {'val_loss':>10} {'accuracy':>10} {'val_acc':>10}")
print("-" * 50)
for i in range(len(h["loss"])):
    print(f"{i + 1:>6} {h['loss'][i]:>10.4f} {h['val_loss'][i]:>10.4f} "
          f"{h['accuracy'][i]:>10.4f} {h['val_accuracy'][i]:>10.4f}")
''')

code('''
# ==================================================================
# 3.9 Predicción de la siguiente palabra y comparación de temperaturas
# ==================================================================
consultas = [
    "en un lugar de la mancha de cuyo",
    "el caballero andante se lanzó a la",
    "sancho panza dijo a su",
]

for consulta in consultas:
    print(f"\\nConsulta: '{consulta}'")
    for palabra, probabilidad in top_k_siguiente(consulta, k=5):
        print(f"   {palabra:<18} p = {probabilidad:.4f}")

print("\\n" + "=" * 74)
print("COMPARACIÓN FINAL DE TEMPERATURAS (modelo a nivel de palabra)")
print("=" * 74)
comparacion_palabra = {}
for temperatura in TEMPERATURAS:
    texto = generar_palabras(SEMILLA_DEMO, n_palabras=40, k=30, temperatura=temperatura)
    comparacion_palabra[str(temperatura)] = texto
    print(f"\\n[T = {temperatura}]\\n{texto}")
''')

# ============================================================================
# 4. MODELO B - NIVEL CARACTER
# ============================================================================
md("""
---
## 4. Modelo B — LSTM a nivel de carácter

El enunciado pide, literalmente, *"predecir caracteres de un texto utilizando redes neuronales
recurrentes y LSTM"*. El insumo de clase trabaja a nivel de palabra, así que añadimos aquí la
versión por caracteres para cubrir la consigna al pie de la letra.

La diferencia práctica es interesante: el vocabulario cae de miles de palabras a unas pocas decenas
de símbolos, así que la capa de salida es mucho más pequeña; a cambio, el modelo debe aprender la
ortografía desde cero —dónde van los espacios, cómo se forman las palabras— antes de poder producir
algo legible. Es un buen contraste para discutir qué unidad de tokenización conviene según la tarea.
""")

code('''
# ==================================================================
# 4.1 Preparación del corpus a nivel de carácter
# ==================================================================
MAX_CARACTERES_CHAR = 100_000        # subconjunto para que el entrenamiento sea razonable
VENTANA_CHAR = 60                    # 60 caracteres de contexto
PASO_CHAR = 3                        # solapamiento entre ventanas

texto_char = corpus[:MAX_CARACTERES_CHAR]

# Se conserva solo el juego de caracteres relevante del español
permitidos = set("abcdefghijklmnopqrstuvwxyzáéíóúüñ .,;:¿?¡!-'\\n")
texto_char = "".join(c for c in texto_char if c in permitidos)
texto_char = re.sub(r"\\s+", " ", texto_char)

vocab_char = sorted(set(texto_char))
char_a_idx = {c: i for i, c in enumerate(vocab_char)}
idx_a_char = {i: c for c, i in char_a_idx.items()}

print(f"Caracteres del corpus : {len(texto_char):,}")
print(f"Vocabulario           : {len(vocab_char)} símbolos")
print("Símbolos              :", "".join(vocab_char))
''')

code('''
# ==================================================================
# 4.2 Dataset de caracteres
# ==================================================================
secuencia = np.array([char_a_idx[c] for c in texto_char], dtype=np.int32)

Xc, yc = [], []
for i in range(0, len(secuencia) - VENTANA_CHAR, PASO_CHAR):
    Xc.append(secuencia[i:i + VENTANA_CHAR])
    yc.append(secuencia[i + VENTANA_CHAR])

Xc = np.array(Xc, dtype=np.int32)
yc = np.array(yc, dtype=np.int32)

corte_c = int(len(Xc) * 0.85)
ds_train_c = (tf.data.Dataset.from_tensor_slices((Xc[:corte_c], yc[:corte_c]))
              .shuffle(min(corte_c, 20000), seed=SEMILLA).batch(BATCH).prefetch(AUTOTUNE))
ds_val_c = (tf.data.Dataset.from_tensor_slices((Xc[corte_c:], yc[corte_c:]))
            .batch(BATCH).prefetch(AUTOTUNE))

print(f"Ejemplos generados : {len(Xc):,}")
print(f"Entrenamiento      : {corte_c:,}")
print(f"Validación         : {len(Xc) - corte_c:,}")
print(f"Forma de Xc        : {Xc.shape}")
''')

code('''
# ==================================================================
# 4.3 Modelo de caracteres: Embedding -> LSTM(128) -> Dense(softmax)
# ==================================================================
n_chars = len(vocab_char)

modelo_char = Sequential([
    Embedding(input_dim=n_chars, output_dim=32, name="embedding_char"),
    LSTM(128, name="lstm_128_char"),                              # mismas 128 unidades internas
    Dense(n_chars, activation="softmax", name="salida_softmax_char"),
], name="LSTM_128_nivel_caracter")

modelo_char.compile(
    optimizer=tf.keras.optimizers.Adam(learning_rate=2e-3),
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)

modelo_char.build(input_shape=(None, VENTANA_CHAR))
modelo_char.summary()
''')

code('''
# ==================================================================
# 4.4 Generación de caracteres con temperatura
# ==================================================================
def generar_caracteres(semilla, n_caracteres=200, temperatura=0.8):
    """Genera texto carácter a carácter a partir de una semilla."""
    contexto = [char_a_idx.get(c, char_a_idx[" "]) for c in semilla.lower()][-VENTANA_CHAR:]
    contexto = [char_a_idx[" "]] * (VENTANA_CHAR - len(contexto)) + contexto
    generado = semilla

    for _ in range(n_caracteres):
        entrada = np.array([contexto], dtype=np.int32)
        probas = modelo_char(entrada, training=False).numpy()[0]

        logits = np.log(probas.astype(np.float64) + 1e-12) / max(temperatura, 1e-6)
        p = np.exp(logits - logits.max())
        p /= p.sum()

        idx = int(np.random.choice(len(p), p=p))
        generado += idx_a_char[idx]
        contexto = contexto[1:] + [idx]

    return generado


registro_epocas_char = []


class GeneracionPorEpocaChar(tf.keras.callbacks.Callback):
    def __init__(self, semilla, n_caracteres=140):
        super().__init__()
        self.semilla = semilla
        self.n_caracteres = n_caracteres

    def on_epoch_end(self, epoch, logs=None):
        logs = logs or {}
        print("\\n" + "=" * 74)
        print(f"GENERACIÓN DE CARACTERES — ÉPOCA {epoch + 1}")
        print("=" * 74)
        print(f"loss={logs.get('loss', 0):.4f} | val_loss={logs.get('val_loss', 0):.4f} | "
              f"accuracy={logs.get('accuracy', 0):.4f} | val_accuracy={logs.get('val_accuracy', 0):.4f}")

        muestras = {}
        for temperatura in TEMPERATURAS:
            texto = generar_caracteres(self.semilla, self.n_caracteres, temperatura)
            muestras[str(temperatura)] = texto
            print(f"\\n[T = {temperatura}] {texto}")
        print("=" * 74)

        registro_epocas_char.append({
            "epoca": epoch + 1,
            "loss": float(logs.get("loss", 0)),
            "val_loss": float(logs.get("val_loss", 0)),
            "accuracy": float(logs.get("accuracy", 0)),
            "val_accuracy": float(logs.get("val_accuracy", 0)),
            "textos": muestras,
        })


SEMILLA_CHAR = "en un lugar de la mancha, de cuyo nombre no quiero acordarme"
''')

code('''
# ==================================================================
# 4.5 Entrenamiento del modelo de caracteres
# ==================================================================
EPOCAS_CHAR = 28

inicio = time.time()
historia_char = modelo_char.fit(
    ds_train_c,
    validation_data=ds_val_c,
    epochs=EPOCAS_CHAR,
    callbacks=[
        GeneracionPorEpocaChar(SEMILLA_CHAR),
        tf.keras.callbacks.ReduceLROnPlateau(monitor="val_loss", factor=0.5,
                                             patience=3, min_lr=1e-5, verbose=1),
        tf.keras.callbacks.ModelCheckpoint("mejor_lstm_caracter.keras", monitor="val_loss",
                                           save_best_only=True, verbose=0),
    ],
    shuffle=False,          # el barajado ya lo hace tf.data
    verbose=2,
)
print(f"\\nTiempo total de entrenamiento: {time.time() - inicio:.1f} s")
''')

code('''
# ==================================================================
# 4.6 Curvas y comparación de temperaturas (caracteres)
# ==================================================================
hc = historia_char.history
fig, ejes = plt.subplots(1, 2, figsize=(13, 4.5))

ejes[0].plot(hc["loss"], label="entrenamiento", linewidth=2)
ejes[0].plot(hc["val_loss"], label="validación", linewidth=2, linestyle="--")
ejes[0].set_title("Pérdida — modelo a nivel de carácter")
ejes[0].set_xlabel("Época"); ejes[0].set_ylabel("Loss")
ejes[0].legend(); ejes[0].grid(alpha=0.3)

ejes[1].plot(hc["accuracy"], label="entrenamiento", linewidth=2)
ejes[1].plot(hc["val_accuracy"], label="validación", linewidth=2, linestyle="--")
ejes[1].set_title("Precisión — modelo a nivel de carácter")
ejes[1].set_xlabel("Época"); ejes[1].set_ylabel("Accuracy")
ejes[1].legend(); ejes[1].grid(alpha=0.3)

plt.tight_layout()
plt.savefig("fig_curvas_caracter.png", dpi=150, bbox_inches="tight")
plt.show()

print("=" * 74)
print("COMPARACIÓN FINAL DE TEMPERATURAS (modelo a nivel de carácter)")
print("=" * 74)
comparacion_char = {}
for temperatura in TEMPERATURAS:
    texto = generar_caracteres(SEMILLA_CHAR, n_caracteres=300, temperatura=temperatura)
    comparacion_char[str(temperatura)] = texto
    print(f"\\n[T = {temperatura}]\\n{texto}")
''')

# ============================================================================
# 5. ANALISIS COMPARATIVO
# ============================================================================
md("""
---
## 5. Análisis comparativo

Dos cosas que queríamos ver con datos y no de oídas: cómo deforma la temperatura la distribución de
probabilidades, y qué gana o pierde cada nivel de tokenización.
""")

code('''
# ==================================================================
# 5.1 Efecto de la temperatura sobre la distribución de probabilidad
# ==================================================================
consulta = "en un lugar de la mancha de cuyo"
secuencia = tokenizer.texts_to_sequences([consulta])[0][-SEQ_LEN:]
entrada = pad_sequences([secuencia], maxlen=SEQ_LEN, padding="pre")
base = modelo_palabra(entrada, training=False).numpy()[0]

fig, ejes = plt.subplots(2, 2, figsize=(13, 8))
for eje, temperatura in zip(ejes.flatten(), TEMPERATURAS):
    logits = np.log(base.astype(np.float64) + 1e-12) / temperatura
    p = np.exp(logits - logits.max())
    p /= p.sum()

    top = np.argsort(p)[::-1][:10]
    etiquetas = [tokenizer.index_word.get(int(i), "<OOV>") for i in top][::-1]
    valores = [p[i] for i in top][::-1]

    eje.barh(range(len(etiquetas)), valores)
    eje.set_yticks(range(len(etiquetas)))
    eje.set_yticklabels(etiquetas)
    eje.set_title(f"Temperatura = {temperatura}")
    eje.set_xlabel("Probabilidad")
    eje.grid(alpha=0.3, axis="x")

fig.suptitle(f"Top-10 de palabras candidatas tras: '{consulta}'", fontsize=13)
plt.tight_layout()
plt.savefig("fig_temperaturas.png", dpi=150, bbox_inches="tight")
plt.show()
''')

code('''
# ==================================================================
# 5.2 Cuadro comparativo palabra vs. carácter
# ==================================================================
filas = [
    ("Unidad de predicción", "palabra", "carácter"),
    ("Tamaño del vocabulario", f"{vocab_size:,}", f"{n_chars}"),
    ("Contexto de entrada", f"{SEQ_LEN} palabras", f"{VENTANA_CHAR} caracteres"),
    ("Ejemplos de entrenamiento", f"{len(X):,}", f"{len(Xc):,}"),
    ("Parámetros del modelo", f"{modelo_palabra.count_params():,}",
     f"{modelo_char.count_params():,}"),
    ("Épocas", f"{EPOCAS_PALABRA}", f"{EPOCAS_CHAR}"),
    ("loss final (val)", f"{h['val_loss'][-1]:.4f}", f"{hc['val_loss'][-1]:.4f}"),
    ("accuracy final (val)", f"{h['val_accuracy'][-1]:.4f}", f"{hc['val_accuracy'][-1]:.4f}"),
]

ancho = max(len(f[0]) for f in filas) + 2
print(f"{'Criterio':<{ancho}} {'Nivel palabra':>22} {'Nivel carácter':>22}")
print("-" * (ancho + 46))
for criterio, a, b in filas:
    print(f"{criterio:<{ancho}} {a:>22} {b:>22}")

print("\\nNota: los valores de loss NO son comparables entre modelos, porque el tamaño del")
print("espacio de salida es distinto (miles de palabras frente a decenas de caracteres).")
''')

# ============================================================================
# 6. BLOQUE INTERACTIVO
# ============================================================================
md("""
---
## 6. Bloque interactivo

Una demostración con varias semillas, y debajo el modo interactivo por teclado que mostró la
docente. Este último queda desactivado por defecto (`EJECUTAR_INTERACTIVO = False`) para que el
cuaderno pueda ejecutarse de principio a fin sin quedarse esperando una entrada; basta cambiar la
bandera a `True` para probarlo.
""")

code('''
# ==================================================================
# 6.1 Demostración con varias semillas
# ==================================================================
semillas_demo = [
    ("en un lugar de la mancha de cuyo nombre", 0.7),
    ("el caballero andante se lanzó a la aventura", 0.8),
    ("sancho panza cabalgaba junto a su señor", 0.7),
    ("la justicia y el honor guiaban al hidalgo", 0.9),
]

for semilla, temperatura in semillas_demo:
    print(f"\\nSemilla: '{semilla}'  (T = {temperatura})")
    print(f"  -> {generar_palabras(semilla, n_palabras=30, k=30, temperatura=temperatura)}")
''')

code('''
# ==================================================================
# 6.2 Modo interactivo por teclado (desactivado por defecto)
# ==================================================================
EJECUTAR_INTERACTIVO = False        # cambie a True para escribir sus propios prompts

if EJECUTAR_INTERACTIVO:
    print("Escriba un prompt de 6 a 15 palabras. Escriba 'salir' para terminar.\\n")
    while True:
        entrada_usuario = input("Prompt > ").strip()
        if entrada_usuario.lower() in ("salir", "exit", "quit"):
            print("Fin del modo interactivo.")
            break
        if len(entrada_usuario) < 5:
            print("Escriba un prompt un poco más largo.\\n")
            continue
        print("\\n" + generar_palabras(entrada_usuario, n_palabras=40, k=30, temperatura=0.9) + "\\n")
else:
    print("Modo interactivo desactivado (EJECUTAR_INTERACTIVO = False).")
''')

# ============================================================================
# 7. EXPORTACION DE RESULTADOS
# ============================================================================
md("""
---
## 7. Exportación de resultados

Guardamos las métricas y los textos generados en un archivo JSON. Sirve para alimentar el informe
escrito con las cifras exactas del entrenamiento, sin transcribirlas a mano.
""")

code('''
# ==================================================================
# 7.1 Volcado de resultados
# ==================================================================
resultados = {
    "corpus": {
        "caracteres": len(corpus),
        "palabras": len(palabras),
        "vocabulario_unico": len(set(palabras)),
        "vocabulario_modelo": int(vocab_size),
        "frases_mini_corpus_clase": len(MINI_CORPUS_CLASE),
        "descarga_exitosa": bool(texto_gutenberg),
    },
    "modelo_palabra": {
        "seq_len": SEQ_LEN,
        "epocas": EPOCAS_PALABRA,
        "parametros": int(modelo_palabra.count_params()),
        "ejemplos": int(len(X)),
        "history": {k: [float(v) for v in vs] for k, vs in h.items()},
        "por_epoca": registro_epocas,
        "comparacion_temperaturas": comparacion_palabra,
        "top5": {c: top_k_siguiente(c, k=5) for c in consultas},
    },
    "modelo_caracter": {
        "ventana": VENTANA_CHAR,
        "epocas": EPOCAS_CHAR,
        "parametros": int(modelo_char.count_params()),
        "vocabulario": int(n_chars),
        "ejemplos": int(len(Xc)),
        "history": {k: [float(v) for v in vs] for k, vs in hc.items()},
        "por_epoca": registro_epocas_char,
        "comparacion_temperaturas": comparacion_char,
    },
}

with open("resultados_s7.json", "w", encoding="utf-8") as fh:
    json.dump(resultados, fh, ensure_ascii=False, indent=2)

print("Resultados guardados en resultados_s7.json")
print(f"  Épocas registradas (palabra) : {len(registro_epocas)}")
print(f"  Épocas registradas (carácter): {len(registro_epocas_char)}")
''')

# ============================================================================
# 8. CONCLUSIONES Y REFERENCIAS
# ============================================================================
md("""
---
## 8. Conclusiones

La arquitectura pedida —`LSTM(128)` seguida de `Dense` con `softmax`— funciona, y se nota desde las
primeras épocas: al comienzo el modelo apenas encadena artículos y conjunciones, y hacia el final ya
respeta concordancias y produce sintagmas reconocibles del castellano de Cervantes. No escribe
literatura, pero eso tampoco era lo que se buscaba.

Sobre el número de épocas, la analogía que usó la docente en clase —cuántas veces hay que leer un
libro para poder responder preguntas sobre él— se sostiene solo hasta cierto punto. A partir de la
época en que `val_loss` toca su mínimo, seguir entrenando mejora la métrica de entrenamiento pero
empeora la de validación: el modelo memoriza en lugar de generalizar. Conviene mirar las dos curvas,
no solo la de entrenamiento.

La temperatura resultó ser el parámetro más visible de todos. Con 0.3 el texto es correcto pero
monótono, y reaparecen las mismas construcciones; con 1.2 aparece vocabulario más rico a costa de
frases que se desarman a mitad de camino. Para este corpus, el rango 0.7–0.8 es el que mejor
equilibra ambas cosas.

Comparar palabra contra carácter dejó clara una diferencia de fondo: el modelo de caracteres tiene
que gastar buena parte de su capacidad en aprender ortografía —dónde termina una palabra, qué
letras pueden ir juntas— antes de ocuparse del significado. El de palabras arranca con esa parte
resuelta, pero paga el precio de un vocabulario enorme en la capa de salida y de no poder inventar
ni una sola palabra que no haya visto antes.

Queda una limitación honesta: con 160.000 caracteres el modelo no alcanza a capturar dependencias
largas. Para eso habría que ir hacia arquitecturas con atención, que es exactamente el tema de la
semana siguiente.

---

## Referencias

Arumugam, R., y Shanmugamani, R. (2018). TensorBoard visualization. En *Hands-on natural language
processing with Python: A practical guide to applying deep learning architectures to your NLP
applications* (pp. 224-236). Packt Publishing.

Campesato, O. (2021). Transformer, BERT, and GPT. En *Natural language processing fundamentals for
developers* (pp. 247-287). Mercury Learning and Information.

Cervantes Saavedra, M. de. (2004). *El ingenioso hidalgo don Quijote de la Mancha* [Libro
electrónico núm. 2000]. Project Gutenberg. https://www.gutenberg.org/ebooks/2000 (Obra original
publicada en 1605)

Ganegedara, T. (2018). Defining inputs in TensorFlow. En *Natural language processing with
TensorFlow: Teach language to machines using Python's deep learning library* (pp. 37-152). Packt
Publishing.

Goodfellow, I., Bengio, Y., y Courville, A. (2016). *Deep learning*. MIT Press.
https://www.deeplearningbook.org/

Hochreiter, S., y Schmidhuber, J. (1997). Long short-term memory. *Neural Computation, 9*(8),
1735-1780. https://doi.org/10.1162/neco.1997.9.8.1735

Orozco Morales, N. (2026). *Semana 7: Redes neuronales recurrentes, LSTM y generación de texto*
[Cuaderno de clase y sesión sincrónica]. NRC-8774 Procesamiento Natural del Lenguaje, Corporación
Universitaria Minuto de Dios.

Srinivasa-Desikan, B. (2018). Deep learning for text. En *Natural language processing and
computational linguistics: A practical guide to text analysis with Python, Gensim, spaCy and Keras*
(pp. 224-236). Packt Publishing.

Zhou, Y. (2022). Natural language processing with improved deep learning neural networks.
*Scientific Programming, 2022*, 1-8.
""")


# ----------------------------------------------------------------------------
# Ensamblado del notebook
# ----------------------------------------------------------------------------
notebook = {
    "cells": CELDAS,
    "metadata": {
        "colab": {"provenance": [], "toc_visible": True},
        "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
        "language_info": {"name": "python"},
    },
    "nbformat": 4,
    "nbformat_minor": 5,
}

with open(SALIDA, "w", encoding="utf-8") as fh:
    json.dump(notebook, fh, ensure_ascii=False, indent=1)

print(f"Notebook generado: {SALIDA}")
print(f"  Celdas totales : {len(CELDAS)}")
print(f"  Código         : {sum(1 for c in CELDAS if c['cell_type'] == 'code')}")
print(f"  Markdown       : {sum(1 for c in CELDAS if c['cell_type'] == 'markdown')}")
print(f"  Frases de clase embebidas: {len(MINI_CORPUS)}")
