#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Genera el informe .docx (APA 7) del Proyecto Integrador — Semana 7.

Requiere `resultados_s7.json`, producido por la ejecución del notebook: todas las
cifras del documento salen de ahí, ninguna se escribe a mano.

Salida: Oviedo_Alexander_Proyecto_Integrador_S7.docx
"""

import json
import os
import sys

from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt

AQUI = os.path.dirname(os.path.abspath(__file__))
RESULTADOS = os.path.join(AQUI, "resultados_s7.json")
SALIDA = os.path.join(AQUI, "Oviedo_Alexander_Proyecto_Integrador_S7.docx")

FUENTE = "Times New Roman"
TAMANO = 12
URL_COLAB = "<<PEGAR AQUÍ LA URL DE GOOGLE COLAB>>"

# ----------------------------------------------------------------------------
# Datos de la ejecución
# ----------------------------------------------------------------------------
if not os.path.exists(RESULTADOS):
    sys.exit(
        f"No se encontró {RESULTADOS}.\n"
        "Ejecute primero el notebook Oviedo_Alexander_Proyecto_Integrador_S7.ipynb: "
        "el informe se construye con las cifras reales del entrenamiento."
    )

with open(RESULTADOS, encoding="utf-8") as fh:
    R = json.load(fh)

C = R["corpus"]
MP = R["modelo_palabra"]
MC = R["modelo_caracter"]
HP = MP["history"]
HC = MC["history"]


def mil(n):
    """Formatea un entero con punto como separador de miles (convención en español)."""
    return f"{int(n):,}".replace(",", ".")


def epoca(registro, n):
    """Devuelve el registro de la época n (1-indexada)."""
    for item in registro:
        if item["epoca"] == n:
            return item
    return registro[-1]


def recortar(texto, limite=320):
    texto = " ".join(str(texto).split())
    return texto if len(texto) <= limite else texto[:limite].rsplit(" ", 1)[0] + "…"


EPOCA_MEJOR = int(min(range(len(HP["val_loss"])), key=lambda i: HP["val_loss"][i]) + 1)
EPOCA_MEJOR_C = int(min(range(len(HC["val_loss"])), key=lambda i: HC["val_loss"][i]) + 1)

# ----------------------------------------------------------------------------
# Documento y estilos base
# ----------------------------------------------------------------------------
doc = Document()

normal = doc.styles["Normal"]
normal.font.name = FUENTE
normal.font.size = Pt(TAMANO)
normal._element.rPr.rFonts.set(qn("w:eastAsia"), FUENTE)
normal.paragraph_format.line_spacing = 2.0
normal.paragraph_format.space_after = Pt(0)
normal.paragraph_format.space_before = Pt(0)

for seccion in doc.sections:
    seccion.top_margin = Cm(2.54)
    seccion.bottom_margin = Cm(2.54)
    seccion.left_margin = Cm(2.54)
    seccion.right_margin = Cm(2.54)


def numerar_paginas():
    """Inserta el número de página en el encabezado, alineado a la derecha (APA 7)."""
    encabezado = doc.sections[0].header
    parrafo = encabezado.paragraphs[0]
    parrafo.alignment = WD_ALIGN_PARAGRAPH.RIGHT
    run = parrafo.add_run()
    run.font.name = FUENTE
    run.font.size = Pt(TAMANO)

    inicio = OxmlElement("w:fldChar")
    inicio.set(qn("w:fldCharType"), "begin")
    instruccion = OxmlElement("w:instrText")
    instruccion.set(qn("xml:space"), "preserve")
    instruccion.text = "PAGE"
    fin = OxmlElement("w:fldChar")
    fin.set(qn("w:fldCharType"), "end")

    run._r.append(inicio)
    run._r.append(instruccion)
    run._r.append(fin)


numerar_paginas()


# ----------------------------------------------------------------------------
# Helpers de escritura
# ----------------------------------------------------------------------------
def _fuente(run, tamano=TAMANO, negrita=False, cursiva=False, mono=False):
    run.font.name = "Consolas" if mono else FUENTE
    run.font.size = Pt(tamano - 2 if mono else tamano)
    run.bold = negrita
    run.italic = cursiva
    return run


def centrado(texto, negrita=False, cursiva=False):
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    _fuente(p.add_run(texto), negrita=negrita, cursiva=cursiva)
    return p


def titulo(texto, nivel=1):
    """Encabezados APA 7: nivel 1 centrado negrita; nivel 2 izquierda negrita;
    nivel 3 izquierda negrita cursiva."""
    p = doc.add_paragraph()
    p.paragraph_format.space_before = Pt(6)
    if nivel == 1:
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        _fuente(p.add_run(texto), negrita=True)
    elif nivel == 2:
        p.alignment = WD_ALIGN_PARAGRAPH.LEFT
        _fuente(p.add_run(texto), negrita=True)
    else:
        p.alignment = WD_ALIGN_PARAGRAPH.LEFT
        _fuente(p.add_run(texto), negrita=True, cursiva=True)
    return p


def parrafo(texto, sangria=True):
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.LEFT          # APA 7 UNIMINUTO: sin justificar
    if sangria:
        p.paragraph_format.first_line_indent = Cm(1.27)
    _fuente(p.add_run(texto))
    return p


def vinieta(texto):
    p = doc.add_paragraph(style="List Bullet")
    p.alignment = WD_ALIGN_PARAGRAPH.LEFT
    p.paragraph_format.left_indent = Cm(1.27)
    p.paragraph_format.line_spacing = 2.0
    p.paragraph_format.space_after = Pt(0)
    _fuente(p.add_run(texto))
    return p


def bloque_codigo(lineas):
    """Bloque monoespaciado a interlineado sencillo, para fragmentos de código."""
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.LEFT
    p.paragraph_format.left_indent = Cm(1.27)
    p.paragraph_format.line_spacing = 1.0
    p.paragraph_format.space_after = Pt(6)
    for i, linea in enumerate(lineas):
        if i:
            p.add_run().add_break()
        _fuente(p.add_run(linea), mono=True)
    return p


def cita_bloque(texto):
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.LEFT
    p.paragraph_format.left_indent = Cm(1.27)
    _fuente(p.add_run(texto))
    return p


CONTADOR = {"tabla": 0, "figura": 0}


def encabezado_tabla(nombre):
    CONTADOR["tabla"] += 1
    p = doc.add_paragraph()
    p.paragraph_format.space_before = Pt(8)
    _fuente(p.add_run(f"Tabla {CONTADOR['tabla']}"), negrita=True)
    p2 = doc.add_paragraph()
    p2.paragraph_format.line_spacing = 1.0
    _fuente(p2.add_run(nombre), cursiva=True)
    return CONTADOR["tabla"]


def nota(texto):
    p = doc.add_paragraph()
    p.paragraph_format.line_spacing = 1.0
    p.paragraph_format.space_after = Pt(10)
    _fuente(p.add_run("Nota. "), cursiva=True, tamano=10)
    _fuente(p.add_run(texto), tamano=10)
    return p


def tabla(cabeceras, filas, anchos=None):
    t = doc.add_table(rows=1, cols=len(cabeceras))
    t.style = "Table Grid"
    for i, texto in enumerate(cabeceras):
        celda = t.rows[0].cells[i]
        celda.text = ""
        p = celda.paragraphs[0]
        p.paragraph_format.line_spacing = 1.0
        p.paragraph_format.space_after = Pt(0)
        _fuente(p.add_run(str(texto)), tamano=10, negrita=True)
    for fila in filas:
        celdas = t.add_row().cells
        for i, valor in enumerate(fila):
            celdas[i].text = ""
            p = celdas[i].paragraphs[0]
            p.paragraph_format.line_spacing = 1.0
            p.paragraph_format.space_after = Pt(0)
            _fuente(p.add_run(str(valor)), tamano=10)
    if anchos:
        for fila in t.rows:
            for i, ancho in enumerate(anchos):
                fila.cells[i].width = Cm(ancho)
    return t


def figura(archivo, nombre, nota_texto, ancho_cm=15.5):
    ruta = os.path.join(AQUI, archivo)
    if not os.path.exists(ruta):
        print(f"  aviso: falta la figura {archivo}, se omite")
        return
    CONTADOR["figura"] += 1
    p = doc.add_paragraph()
    p.paragraph_format.space_before = Pt(8)
    _fuente(p.add_run(f"Figura {CONTADOR['figura']}"), negrita=True)
    p2 = doc.add_paragraph()
    p2.paragraph_format.line_spacing = 1.0
    _fuente(p2.add_run(nombre), cursiva=True)
    p3 = doc.add_paragraph()
    p3.alignment = WD_ALIGN_PARAGRAPH.CENTER
    p3.paragraph_format.line_spacing = 1.0
    p3.add_run().add_picture(ruta, width=Cm(ancho_cm))
    nota(nota_texto)


def referencia(texto):
    """Entrada de referencia con sangría francesa."""
    p = doc.add_paragraph()
    p.alignment = WD_ALIGN_PARAGRAPH.LEFT
    p.paragraph_format.left_indent = Cm(1.27)
    p.paragraph_format.first_line_indent = Cm(-1.27)
    _fuente(p.add_run(texto))
    return p


# ============================================================================
# PORTADA
# ============================================================================
for _ in range(4):
    doc.add_paragraph()

centrado("Predicción y generación de texto con redes neuronales recurrentes LSTM:", negrita=True)
centrado("proyecto integrador sobre el corpus de Don Quijote de la Mancha", negrita=True)
doc.add_paragraph()

centrado("Alexander Oviedo Fadul")
centrado("Maria Fernanda Ruiz Paipilla")
centrado("Neheman Samir Jaller Cerchiaro")
centrado("William David Obando Lopez")
doc.add_paragraph()

centrado("Grupo 7")
centrado("Especialización en Inteligencia Artificial")
centrado("Corporación Universitaria Minuto de Dios (UNIMINUTO)")
doc.add_paragraph()

centrado("NRC-8774 — Procesamiento Natural del Lenguaje")
centrado("Semana 7 — Proyecto integrador")
doc.add_paragraph()

centrado("Docente: Nathalia Orozco Morales")
doc.add_paragraph()
centrado("17 de agosto de 2026")

doc.add_page_break()

# ============================================================================
# INTRODUCCIÓN
# ============================================================================
titulo("Introducción", nivel=1)

parrafo(
    "Escribir un texto que suene a Cervantes es fácil para una persona que haya leído el Quijote; "
    "para una máquina, no tanto. Este proyecto integrador aborda ese problema con la herramienta que "
    "corresponde a la Semana 7 del curso: una red neuronal recurrente de tipo LSTM que aprende a "
    "predecir el siguiente elemento de una secuencia y, encadenando esas predicciones, genera texto "
    "nuevo."
)
parrafo(
    "El punto de partida no fue una hoja en blanco. La docente compartió en la sesión sincrónica un "
    "cuaderno de referencia con la arquitectura exacta que pide la actividad, y pidió algo concreto: "
    "que lo tomáramos como insumo, lo enriqueciéramos y lo hiciéramos más complejo, aumentando las "
    "épocas y demostrando la mejora con métricas (Orozco Morales, 2026). Eso fue lo que hicimos. El "
    "corpus de 60 filas del ejemplo de clase pasó a un texto de "
    f"{mil(C['caracteres'])} caracteres descargado directamente de Project Gutenberg; las 30 épocas "
    f"del insumo pasaron a {MP['epocas']}; y donde había un modelo, ahora hay dos."
)
parrafo(
    "Ese segundo modelo responde a un detalle del enunciado que conviene no pasar por alto. La "
    "actividad pide, textualmente, predecir caracteres de un texto, mientras que el insumo de clase "
    "trabaja a nivel de palabra. En lugar de elegir una u otra lectura, implementamos las dos con la "
    "misma arquitectura solicitada —LSTM de 128 unidades internas seguida de una capa Dense con "
    "softmax— y las comparamos. La comparación terminó siendo una de las partes más instructivas del "
    "trabajo."
)
parrafo(
    "El documento recorre primero los fundamentos teóricos de las redes recurrentes y de la variante "
    "LSTM, describe después la metodología y explica el código bloque por bloque, presenta los "
    "resultados reales de los dos entrenamientos con sus métricas época a época, discute el efecto "
    "de la temperatura sobre el texto generado y cierra con las conclusiones y las limitaciones que "
    "encontramos. El cuaderno ejecutable, con todas las salidas, está enlazado en el anexo."
)

doc.add_page_break()

# ============================================================================
# DESARROLLO
# ============================================================================
titulo("Desarrollo", nivel=1)

# ---------------------------------------------------------------- marco teórico
titulo("Marco teórico", nivel=2)

titulo("De las redes feedforward a las recurrentes", nivel=3)
parrafo(
    "Una red feedforward procesa cada entrada de forma independiente: no guarda rastro de lo que vio "
    "antes. Para clasificar una imagen eso basta, pero para el lenguaje es un problema serio, porque "
    "el significado de una palabra depende de las que la preceden. Las redes recurrentes resuelven "
    "esa carencia introduciendo un estado oculto que se realimenta en cada paso de la secuencia, de "
    "modo que la salida en el instante t depende tanto de la entrada actual como de todo lo "
    "procesado hasta ese momento (Srinivasa-Desikan, 2018)."
)
parrafo(
    "El mecanismo tiene un costo. Al entrenar mediante retropropagación a través del tiempo, el "
    "gradiente se multiplica repetidamente por los mismos pesos y tiende a desvanecerse; en la "
    "práctica, una RNN simple olvida lo que ocurrió veinte o treinta pasos atrás. Hochreiter y "
    "Schmidhuber (1997) propusieron la LSTM justamente para eso."
)

titulo("Arquitectura de una red neuronal recurrente", nivel=3)
parrafo(
    "Esta es la primera de las dos preguntas orientadoras del enunciado. Una RNN se compone de una "
    "celda que se aplica de forma repetida sobre la secuencia. En cada paso recibe el elemento "
    "actual y el estado oculto anterior, y produce un nuevo estado: h(t) = f(Wx·x(t) + Wh·h(t−1) + "
    "b). Los pesos Wx y Wh son los mismos en todos los pasos, lo que permite procesar secuencias de "
    "cualquier longitud con un número fijo de parámetros."
)
parrafo(
    "La LSTM sustituye esa celda por una estructura con memoria explícita y tres puertas. La puerta "
    "de olvido decide qué parte del estado anterior se descarta; la de entrada, qué información "
    "nueva se incorpora; y la de salida, qué se expone al resto de la red. Gracias a ese diseño, el "
    "gradiente puede fluir por la celda de memoria sin desvanecerse, y la red aprende dependencias "
    "considerablemente más largas (Goodfellow et al., 2016)."
)
parrafo(
    "En nuestro caso la salida de la LSTM son 128 valores numéricos —las 128 unidades internas que "
    "exige la actividad—. Conviene insistir en algo que la docente subrayó en clase: esos 128 "
    "valores no son 128 palabras memorizadas, sino una representación comprimida de todo lo que la "
    "red ha leído de la secuencia. La capa Dense proyecta esa representación a una puntuación por "
    "cada elemento del vocabulario, y softmax convierte esas puntuaciones en una distribución de "
    "probabilidad que suma uno."
)

titulo("Aplicaciones de las redes neuronales recurrentes", nivel=3)
parrafo(
    "La segunda pregunta orientadora admite una respuesta corta: cualquier problema en el que el "
    "orden importe. Traducción automática, reconocimiento y síntesis de voz, análisis de "
    "sentimiento, etiquetado morfosintáctico, reconocimiento de entidades nombradas, predicción de "
    "series temporales, detección de anomalías en lecturas de sensores y, por supuesto, generación y "
    "autocompletado de texto (Zhou, 2022)."
)
parrafo(
    "Vale la pena señalar los casos que usamos a diario sin reparar en ellos: la sugerencia de "
    "continuación cuando escribimos un correo, el autocompletado de un editor de código, la "
    "corrección predictiva del teclado del teléfono. Todos ellos descienden conceptualmente del "
    "mismo problema que resolvemos aquí: dada una secuencia parcial, ¿cuál es el elemento más "
    "probable a continuación?"
)

titulo("Softmax, temperatura y token OOV", nivel=3)
parrafo(
    "Softmax entrega la distribución de probabilidad, pero elegir siempre la opción más probable "
    "produce un texto que se repite en bucle. La temperatura corrige eso dividiendo los logaritmos "
    "de las probabilidades por un factor T antes de muestrear. Con T menor que uno la distribución "
    "se concentra y el texto se vuelve conservador; con T mayor que uno se aplana y aparecen "
    "opciones poco frecuentes, a costa de la coherencia. Es el mismo mando de creatividad que llevan "
    "los modelos generativos actuales (Campesato, 2021)."
)
parrafo(
    "El token OOV, por su parte, es una pieza pequeña pero indispensable. Al declarar "
    "Tokenizer(oov_token=\"<OOV>\") se reserva un índice para las palabras que el modelo nunca vio. "
    "Sin él, una palabra desconocida generaría una secuencia vacía y rompería la inferencia; con él, "
    "el modelo la representa de forma consistente y la ejecución continúa."
)

# ---------------------------------------------------------------- metodología
titulo("Metodología", nivel=2)

parrafo(
    "El trabajo siguió el esquema de pipeline replicable que la docente insistió en clase: una línea "
    "base que funciona, y sobre ella las mejoras, documentando cada paso para que otro pueda "
    "reproducirlo. Las fases fueron cinco."
)

titulo("Fase 1. Construcción del corpus", nivel=3)
parrafo(
    "El insumo de clase obliga a subir un CSV con el botón de Colab cada vez que se reinicia el "
    "entorno, lo que rompe la reproducibilidad. Optamos por descargar el texto de El ingenioso "
    "hidalgo don Quijote de la Mancha desde Project Gutenberg, que es dominio público (Cervantes, "
    "1605/2004), recortarlo de forma determinista entre la primera línea de la novela y el pie de "
    "licencia, y limitarlo a un volumen manejable. Como respaldo, el mini-corpus de la docente quedó "
    "embebido en el propio cuaderno como constante de Python: si no hay red, el notebook sigue "
    "funcionando."
)
parrafo(
    "Al revisar ese mini-corpus encontramos algo que merece mención. El archivo tiene 60 filas, pero "
    "en realidad contiene 30 frases: la segunda mitad repite la primera añadiendo siempre la misma "
    "coletilla sobre el caballero y su escudero. Esa duplicación explica en buena medida por qué en "
    "la demostración de clase el modelo tendía a producir cadenas del tipo «su su su y y y»: estaba "
    "aprendiendo el relleno más que el estilo. Conservamos únicamente las 30 frases únicas."
)
parrafo(
    f"El corpus final quedó en {mil(C['caracteres'])} caracteres y {mil(C['palabras'])} palabras, "
    f"con {mil(C['vocabulario_unico'])} formas distintas. Frente a los 1.354 tokens del ejemplo de "
    f"clase, el factor de enriquecimiento es de aproximadamente {C['palabras'] / 1354:.0f} veces, "
    "por encima del «aliméntenlo por diez» que pidió la docente."
)

titulo("Fase 2. Preprocesamiento", nivel=3)
parrafo(
    "Para el modelo de palabras se tokenizó con la clase Tokenizer de Keras, limitando el "
    f"vocabulario a las {mil(C['vocabulario_modelo'])} formas más frecuentes y reservando el token "
    f"OOV. El dataset se construyó con ventanas deslizantes de {MP['seq_len']} palabras de contexto "
    "y la palabra siguiente como etiqueta."
)
parrafo(
    "Aquí tomamos una decisión de diseño que vale la pena justificar, porque nos costó un intento "
    "fallido. La aproximación intuitiva —generar prefijos crecientes y codificar la etiqueta con "
    "one-hot— es inviable en cuanto el vocabulario crece: con varios miles de clases y decenas de "
    "miles de ejemplos, la matriz de salida no cabe en memoria. Usar etiquetas enteras con "
    "sparse_categorical_crossentropy da exactamente el mismo resultado matemático consumiendo varios "
    "órdenes de magnitud menos memoria."
)
parrafo(
    f"Para el modelo de caracteres se usó una ventana de {MC['ventana']} caracteres con paso 3 sobre "
    f"un subconjunto del corpus, y un vocabulario de {MC['vocabulario']} símbolos del español."
)

titulo("Fase 3. Arquitectura", nivel=3)
parrafo(
    "Ambos modelos comparten la estructura que exige el enunciado: una capa Embedding que convierte "
    "los índices en vectores densos, una capa LSTM de 128 unidades internas y una capa Dense con "
    "activación softmax del tamaño del vocabulario. El optimizador fue Adam y la función de pérdida, "
    "entropía cruzada categórica dispersa."
)

titulo("Fase 4. Entrenamiento con seguimiento por época", nivel=3)
parrafo(
    "El punto 4 de la actividad pide entregar la salida de un entrenamiento completo con los textos "
    "generados época a época. Lo resolvimos con un callback propio que, al terminar cada época, "
    "imprime las cuatro métricas y genera texto con cuatro temperaturas distintas. También se "
    "incorporaron ReduceLROnPlateau, para bajar la tasa de aprendizaje cuando la pérdida de "
    "validación se estanca, y ModelCheckpoint, para conservar el mejor modelo."
)

titulo("Fase 5. Evaluación y análisis", nivel=3)
parrafo(
    "Se compararon las curvas de entrenamiento y validación de ambos modelos, se midió el efecto de "
    "las temperaturas 0.3, 0.7, 1.0 y 1.2 sobre el texto generado y se contrastaron los dos niveles "
    "de tokenización. Todas las cifras de este informe proceden del archivo de resultados que "
    "produce el propio cuaderno; ninguna se transcribió a mano."
)

# ---------------------------------------------------------------- código
titulo("Explicación de las líneas que integran el código", nivel=2)

parrafo(
    "El enunciado pide explicar las líneas que integran el código. Recorremos los bloques en el "
    "mismo orden en que aparecen en el cuaderno."
)

titulo("Configuración y reproducibilidad", nivel=3)
bloque_codigo([
    "SEMILLA = 42",
    "np.random.seed(SEMILLA)",
    "tf.random.set_seed(SEMILLA)",
])
parrafo(
    "Fijar las semillas de NumPy y de TensorFlow hace que la inicialización de pesos, el barajado de "
    "los lotes y el muestreo con temperatura sean reproducibles. Sin esto, dos ejecuciones del mismo "
    "cuaderno darían textos distintos y la comparación entre modelos perdería sentido."
)

titulo("Carga del corpus desde internet", nivel=3)
bloque_codigo([
    '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")',
    'inicio = crudo.find("En un lugar de la Mancha, de cuyo nombre")',
    'fin = crudo.find("*** END OF THE PROJECT GUTENBERG")',
    "return crudo[inicio:fin]",
])
parrafo(
    "La cabecera User-Agent evita que el servidor rechace la petición. El recorte por marcadores de "
    "texto es determinista: siempre devuelve el mismo fragmento, independientemente de cambios "
    "menores en la cabecera del archivo. Todo el bloque va dentro de un try/except que prueba dos "
    "URL y, si ambas fallan, continúa con el corpus embebido en lugar de abortar."
)

titulo("Tokenización", nivel=3)
bloque_codigo([
    'tokenizer = Tokenizer(num_words=VOCAB_MAX, oov_token="<OOV>")',
    "tokenizer.fit_on_texts([corpus])",
    "tokens = tokenizer.texts_to_sequences([corpus])[0]",
])
parrafo(
    "fit_on_texts recorre el corpus, cuenta frecuencias y asigna a cada palabra un índice entero, "
    "empezando por la más frecuente. num_words limita el vocabulario efectivo, lo que reduce el "
    "tamaño de la capa de salida y acelera el entrenamiento. texts_to_sequences traduce el texto a "
    "la lista de enteros con la que trabajará la red."
)

titulo("Construcción del dataset con ventanas deslizantes", nivel=3)
bloque_codigo([
    "for i in range(SEQ_LEN, len(tokens)):",
    "    X.append(tokens[i - SEQ_LEN:i])   # contexto",
    "    y.append(tokens[i])               # palabra siguiente",
])
parrafo(
    "Cada ejemplo es una ventana de longitud fija y su continuación. Al desplazar la ventana de uno "
    "en uno se obtienen tantos ejemplos como tokens tenga el corpus, y el modelo ve cada palabra en "
    "múltiples contextos. La división en entrenamiento y validación es temporal, no aleatoria: el "
    "último quince por ciento del corpus se reserva para validar, de modo que el modelo no valide "
    "sobre fragmentos que ya vio entremezclados."
)

titulo("Definición del modelo", nivel=3)
bloque_codigo([
    "modelo_palabra = Sequential([",
    "    Embedding(input_dim=vocab_size, output_dim=128),",
    "    LSTM(128),",
    '    Dense(vocab_size, activation="softmax"),',
    "])",
    "modelo_palabra.compile(",
    "    optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3),",
    '    loss="sparse_categorical_crossentropy",',
    '    metrics=["accuracy"])',
])
parrafo(
    "Embedding aprende un vector de 128 dimensiones por palabra; palabras que aparecen en contextos "
    "parecidos acaban con vectores parecidos, y eso es lo que permite generalizar. LSTM(128) es el "
    "requisito central de la actividad: 128 celdas internas que mantienen el estado de la secuencia. "
    "Dense con softmax produce la probabilidad de cada palabra del vocabulario. La pérdida "
    "sparse_categorical_crossentropy acepta la etiqueta como entero, lo que evita la matriz one-hot "
    "mencionada antes."
)

titulo("Muestreo con temperatura y top-k", nivel=3)
bloque_codigo([
    "for indice in PROHIBIDOS:        # 0 = padding, 1 = <OOV>",
    "    p[indice] = 0.0",
    "p = p / p.sum()",
    "logits = np.log(p + 1e-12) / max(temperatura, 1e-6)",
    "p = np.exp(logits - logits.max())",
    "p /= p.sum()",
    "indices = np.argpartition(p, -k)[-k:]",
    "return int(np.random.choice(indices, p=p[indices] / p[indices].sum()))",
])
parrafo(
    "Se pasa a logaritmos, se divide por la temperatura y se vuelve al dominio de probabilidades. El "
    "término 1e-12 evita el logaritmo de cero y la resta del máximo previene el desbordamiento de la "
    "exponencial. Después, top-k restringe el muestreo a las k opciones más probables: sin ese "
    "filtro, la larga cola de palabras improbables introduce demasiado ruido."
)
parrafo(
    "Las dos primeras líneas merecen una aclaración, porque son una decisión nuestra y no del "
    "enunciado. Como el vocabulario del modelo está acotado a las formas más frecuentes, el token "
    "<OOV> tiene una probabilidad apreciable y el muestreo lo elegía con frecuencia, dejando el "
    "texto salpicado de marcadores que no son palabras. Anularlo en el momento de generar —junto con "
    "el relleno del padding— no modifica el modelo ni sus métricas: es una restricción de "
    "inferencia, del mismo tipo que el top-k. Las cifras de pérdida y precisión que se reportan más "
    "adelante se calculan sobre el vocabulario completo, sin esta exclusión."
)

titulo("Callback de generación por época", nivel=3)
bloque_codigo([
    "class GeneracionPorEpoca(tf.keras.callbacks.Callback):",
    "    def on_epoch_end(self, epoch, logs=None):",
    "        for temperatura in TEMPERATURAS:",
    "            texto = generar_palabras(self.semilla, temperatura=temperatura)",
])
parrafo(
    "Keras invoca on_epoch_end al cerrar cada época, con las métricas en el diccionario logs. El "
    "callback las imprime y genera texto con cada temperatura, guardando además todo en una lista "
    "que después se exporta a JSON. Este bloque es el que materializa el requisito de mostrar la "
    "evolución época a época."
)
parrafo(
    "Un detalle de rendimiento: dentro del bucle de generación se llama a modelo(entrada, "
    "training=False) en lugar de modelo.predict(...). predict está pensado para lotes grandes y "
    "arrastra una sobrecarga considerable en llamadas sueltas; en un bucle que hace cientos de "
    "predicciones por época, la diferencia se nota."
)

# ---------------------------------------------------------------- ventajas
titulo("Ventajas y desventajas de este tipo de código", nivel=2)

titulo("Ventajas", nivel=3)
vinieta(
    "Es transparente. Cada etapa —tokenizar, ventanear, entrenar, muestrear— está a la vista y se "
    "puede inspeccionar por separado. Frente a una API cerrada, aquí se entiende exactamente qué "
    "hace el modelo."
)
vinieta(
    "Es liviano. Los dos modelos juntos rondan los "
    f"{(MP['parametros'] + MC['parametros']) / 1e6:.1f} millones de parámetros y se entrenan en una "
    "sesión de clase sin GPU dedicada, algo impensable con un transformer preentrenado."
)
vinieta(
    "Es reproducible y portátil. Con las semillas fijas y el corpus descargado desde una URL "
    "pública, el cuaderno da los mismos resultados en Colab y en local, sin depender de archivos "
    "que haya que subir a mano."
)
vinieta(
    "Es modular. Cambiar LSTM(128) por GRU(128), ajustar la ventana de contexto o sustituir el "
    "corpus son modificaciones de una línea, lo que facilita experimentar."
)

titulo("Desventajas", nivel=3)
vinieta(
    "El entrenamiento es secuencial por naturaleza: la LSTM debe procesar el paso t antes del t+1, "
    "así que no se paraleliza como un transformer. Es la limitación que motivó la arquitectura de "
    "atención."
)
vinieta(
    "La memoria efectiva es corta. Con una ventana de "
    f"{MP['seq_len']} palabras, el modelo no puede referirse a algo mencionado dos párrafos antes; "
    "genera frases locales plausibles, no discurso con hilo argumental."
)
vinieta(
    "La capa de salida crece con el vocabulario. En el modelo de palabras, la Dense final concentra "
    "la mayor parte de los parámetros, y ampliar el corpus encarece el modelo de forma directa."
)
vinieta(
    "No inventa palabras nuevas: el modelo de nivel palabra solo puede emitir formas que estén en "
    "su vocabulario, y cualquier otra queda reducida al token OOV."
)
vinieta(
    "Es sensible a la calidad del corpus. Como se vio con el mini-corpus duplicado de clase, un "
    "sesgo en los datos aparece de inmediato en el texto generado. El preprocesamiento no es un "
    "trámite."
)

# ---------------------------------------------------------------- resultados
doc.add_page_break()
titulo("Resultados", nivel=2)

titulo("Modelo a nivel de palabra", nivel=3)
parrafo(
    f"El modelo se entrenó durante {MP['epocas']} épocas sobre {mil(MP['ejemplos'])} ejemplos, con "
    f"{mil(MP['parametros'])} parámetros entrenables y un vocabulario efectivo de "
    f"{mil(C['vocabulario_modelo'])} palabras. La pérdida de entrenamiento bajó de "
    f"{HP['loss'][0]:.4f} a {HP['loss'][-1]:.4f} y la precisión subió de {HP['accuracy'][0]:.4f} a "
    f"{HP['accuracy'][-1]:.4f}. En validación, la pérdida partió de {HP['val_loss'][0]:.4f}, alcanzó "
    f"su mínimo de {min(HP['val_loss']):.4f} en la época {EPOCA_MEJOR} y terminó en "
    f"{HP['val_loss'][-1]:.4f}."
)

paso = max(1, MP["epocas"] // 10)
filas_metricas = []
for i in range(0, MP["epocas"], paso):
    filas_metricas.append([
        i + 1,
        f"{HP['loss'][i]:.4f}",
        f"{HP['val_loss'][i]:.4f}",
        f"{HP['accuracy'][i]:.4f}",
        f"{HP['val_accuracy'][i]:.4f}",
    ])
if filas_metricas[-1][0] != MP["epocas"]:
    i = MP["epocas"] - 1
    filas_metricas.append([
        i + 1,
        f"{HP['loss'][i]:.4f}",
        f"{HP['val_loss'][i]:.4f}",
        f"{HP['accuracy'][i]:.4f}",
        f"{HP['val_accuracy'][i]:.4f}",
    ])

encabezado_tabla("Evolución de las métricas del modelo a nivel de palabra")
tabla(["Época", "loss", "val_loss", "accuracy", "val_accuracy"], filas_metricas,
      anchos=[2.2, 3.2, 3.2, 3.2, 3.4])
nota(
    f"Se muestra una de cada {paso} épocas del entrenamiento completo de {MP['epocas']}. "
    f"El mínimo de val_loss se alcanzó en la época {EPOCA_MEJOR}. Elaboración propia."
)

figura(
    "fig_curvas_palabra.png",
    "Curvas de pérdida y precisión del modelo a nivel de palabra",
    "Entrenamiento en línea continua y validación en línea discontinua. Elaboración propia."
)

parrafo(
    "La progresión del texto generado es más elocuente que las cifras. Con la semilla «el caballero "
    "andante se lanzó a la aventura» y temperatura 0.7, estos fueron los resultados en tres momentos "
    "del entrenamiento:"
)

for n in sorted({1, max(2, MP["epocas"] // 2), MP["epocas"]}):
    registro = epoca(MP["por_epoca"], n)
    texto = registro["textos"].get("0.7") or list(registro["textos"].values())[0]
    parrafo(f"Época {registro['epoca']} (val_loss = {registro['val_loss']:.4f}):", sangria=False)
    cita_bloque(recortar(texto, 300))

titulo("Modelo a nivel de carácter", nivel=3)
parrafo(
    f"El modelo de caracteres trabajó con un vocabulario de apenas {MC['vocabulario']} símbolos y "
    f"{mil(MC['ejemplos'])} ejemplos, con {mil(MC['parametros'])} parámetros. Tras {MC['epocas']} "
    f"épocas, la pérdida de entrenamiento pasó de {HC['loss'][0]:.4f} a {HC['loss'][-1]:.4f} y la "
    f"precisión de {HC['accuracy'][0]:.4f} a {HC['accuracy'][-1]:.4f}; el mínimo de val_loss "
    f"({min(HC['val_loss']):.4f}) se alcanzó en la época {EPOCA_MEJOR_C}."
)
parrafo(
    "Conviene aclarar que la precisión de este modelo no es comparable con la del anterior: acertar "
    "un carácter entre unas decenas es mucho más fácil que acertar una palabra entre miles. Lo "
    "interesante es cualitativo. En las primeras épocas el modelo produce secuencias de letras sin "
    "espacios coherentes; hacia el final ya respeta la longitud típica de las palabras españolas y "
    "produce formas ortográficamente plausibles, aunque no siempre existentes."
)

for n in sorted({1, MC["epocas"]}):
    registro = epoca(MC["por_epoca"], n)
    texto = registro["textos"].get("0.7") or list(registro["textos"].values())[0]
    parrafo(f"Época {registro['epoca']} (val_loss = {registro['val_loss']:.4f}):", sangria=False)
    cita_bloque(recortar(texto, 300))

figura(
    "fig_curvas_caracter.png",
    "Curvas de pérdida y precisión del modelo a nivel de carácter",
    "Entrenamiento en línea continua y validación en línea discontinua. Elaboración propia."
)

titulo("Efecto de la temperatura", nivel=3)
parrafo(
    "Con el modelo de palabras ya entrenado, generamos cuarenta palabras a partir de la misma "
    "semilla variando únicamente la temperatura. El contraste es inmediato."
)

filas_temp = []
for temperatura in ["0.3", "0.7", "1.0", "1.2"]:
    texto = MP["comparacion_temperaturas"].get(temperatura, "")
    filas_temp.append([temperatura, recortar(texto, 260)])

encabezado_tabla("Texto generado según la temperatura de muestreo")
tabla(["T", "Texto generado (modelo a nivel de palabra)"], filas_temp, anchos=[1.6, 14.0])
nota(
    "Semilla común: «el caballero andante se lanzó a la aventura». Muestreo con top-k = 30. "
    "Elaboración propia."
)

figura(
    "fig_temperaturas.png",
    "Distribución de probabilidad de las diez palabras candidatas según la temperatura",
    "Contexto: «en un lugar de la mancha de cuyo». A menor temperatura, la probabilidad se "
    "concentra en las primeras candidatas. Elaboración propia."
)

titulo("Comparación entre los dos niveles de tokenización", nivel=3)
encabezado_tabla("Comparación de los modelos a nivel de palabra y de carácter")
tabla(
    ["Criterio", "Nivel palabra", "Nivel carácter"],
    [
        ["Tamaño del vocabulario", mil(C["vocabulario_modelo"]), f"{MC['vocabulario']}"],
        ["Contexto de entrada", f"{MP['seq_len']} palabras", f"{MC['ventana']} caracteres"],
        ["Ejemplos de entrenamiento", mil(MP["ejemplos"]), mil(MC["ejemplos"])],
        ["Parámetros", mil(MP["parametros"]), mil(MC["parametros"])],
        ["Épocas", f"{MP['epocas']}", f"{MC['epocas']}"],
        ["val_loss final", f"{HP['val_loss'][-1]:.4f}", f"{HC['val_loss'][-1]:.4f}"],
        ["val_accuracy final", f"{HP['val_accuracy'][-1]:.4f}", f"{HC['val_accuracy'][-1]:.4f}"],
    ],
    anchos=[6.0, 5.0, 4.6],
)
nota(
    "Los valores de pérdida no son comparables entre columnas: el espacio de salida tiene tamaños "
    "muy distintos. Elaboración propia."
)

# ---------------------------------------------------------------- discusión
titulo("Discusión", nivel=2)

if EPOCA_MEJOR <= max(2, MP["epocas"] // 5):
    momento = (
        f"muy pronto, en la época {EPOCA_MEJOR} de {MP['epocas']}, la pérdida de validación toca su "
        "mínimo y a partir de ahí empeora mientras la de entrenamiento sigue bajando"
    )
else:
    momento = (
        f"a partir de la época {EPOCA_MEJOR} la pérdida de entrenamiento sigue bajando mientras la "
        "de validación deja de mejorar"
    )

parrafo(
    "El resultado más útil del ejercicio no fue el texto generado, sino lo que revelan las dos "
    "curvas al mirarlas juntas. La analogía que usó la docente en clase —cuántas veces hay que leer "
    "un libro para poder responder preguntas sobre él— funciona bien como intuición, pero tiene un "
    f"límite que los datos muestran con claridad: {momento}. El modelo está memorizando, no "
    "aprendiendo. Aumentar las épocas sin vigilar la validación no mejora nada; solo consume tiempo, "
    "y por eso conviene conservar el punto de control del mejor modelo en lugar del último."
)
parrafo(
    "El segundo hallazgo tiene que ver con los datos. Al revisar el mini-corpus de clase encontramos "
    "que la mitad de sus filas repite la primera mitad añadiendo siempre la misma coletilla. Eso "
    "ofrece una explicación plausible de por qué en la demostración de la sesión el modelo tendía a "
    "producir cadenas repetitivas: una construcción que aparece en la mitad exacta de las muestras "
    "recibe un peso enorme durante el entrenamiento. No lo verificamos con un experimento "
    "controlado —habría requerido entrenar dos veces variando solo esa condición—, pero la relación "
    "es lo bastante directa como para justificar la limpieza que aplicamos. Un modelo bien diseñado "
    "sobre datos sesgados reproduce el sesgo con toda fidelidad."
)
parrafo(
    "Sobre la temperatura, la conclusión práctica es que no existe un valor universalmente óptimo. "
    "Para autocompletar una frase, donde importa acertar, conviene un valor bajo. Para generar "
    "variantes de un texto, un valor medio o alto amplía el repertorio. Lo que sí quedó claro es que "
    "por encima de 1.2 el texto empieza a desarmarse a mitad de frase."
)
parrafo(
    "La comparación entre niveles de tokenización arrojó una diferencia de fondo. El modelo de "
    "caracteres debe 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 ese problema resuelto, pero paga el precio de un vocabulario enorme en la salida y de no "
    "poder producir ninguna forma que no haya visto. Para un corpus de este tamaño, el nivel de "
    "palabra da resultados legibles antes; con corpus mucho mayores, la balanza puede inclinarse "
    "hacia unidades intermedias como las subpalabras que usan los modelos actuales."
)
parrafo(
    "Conviene ser honestos sobre el alcance. Con un corpus de este volumen y una ventana de "
    f"{MP['seq_len']} palabras, el modelo no captura dependencias largas: produce fragmentos "
    "plausibles, no discurso con hilo. Resolver eso exige mecanismos de atención, que es "
    "precisamente el contenido de la semana siguiente. Visto así, este proyecto funciona como el "
    "escalón previo: entender qué problema resuelve la recurrencia y dónde se queda corta explica "
    "por qué apareció el transformer."
)

# ---------------------------------------------------------------- conclusiones
doc.add_page_break()
titulo("Conclusiones", nivel=1)

parrafo(
    "Se implementó y entrenó la arquitectura solicitada —red neuronal recurrente LSTM con 128 "
    "unidades internas seguida de una capa Dense con activación softmax— en dos variantes, a nivel "
    f"de palabra y a nivel de carácter, sobre un corpus de {mil(C['caracteres'])} caracteres del "
    "Quijote descargado en línea. Ambas generan texto con sentido reconocible del castellano "
    "cervantino."
)
parrafo(
    f"El seguimiento época a época mostró una mejora sostenida: en el modelo de palabras la pérdida "
    f"de entrenamiento cayó de {HP['loss'][0]:.4f} a {HP['loss'][-1]:.4f} y la precisión pasó de "
    f"{HP['accuracy'][0]:.4f} a {HP['accuracy'][-1]:.4f} en {MP['epocas']} épocas. Ahora bien, el "
    f"mejor punto de generalización no fue el final sino la época {EPOCA_MEJOR}, donde la pérdida de "
    "validación tocó su mínimo. Más épocas no equivale automáticamente a mejor modelo."
)
parrafo(
    "El enriquecimiento del corpus, siguiendo la indicación de la docente, amplió el vocabulario "
    f"del modelo a {mil(C['vocabulario_modelo'])} "
    "formas frente a las poco más de doscientas del ejemplo de clase, y con ello el repertorio de "
    "continuaciones posibles. También detectamos y eliminamos la duplicación que arrastraba el "
    "mini-corpus original, un ajuste de preprocesamiento que no cambia la arquitectura pero sí lo "
    "que el modelo aprende."
)
parrafo(
    "La temperatura se confirmó como el parámetro más visible del muestreo. En este corpus, el rango "
    "0.7 a 0.8 ofreció el mejor equilibrio entre coherencia y variedad; por debajo el texto se "
    "vuelve repetitivo y por encima de 1.2 pierde estructura."
)
parrafo(
    "Finalmente, el pipeline quedó documentado y es replicable de principio a fin: el cuaderno "
    "descarga su propio corpus, fija las semillas, entrena, evalúa y exporta los resultados a un "
    "archivo con el que se construyó este informe. Ese cierre del circuito —que las cifras del "
    "documento provengan directamente de la ejecución y no de una transcripción manual— nos parece "
    "tan relevante como el modelo mismo, porque es lo que permite que otro equipo verifique lo que "
    "aquí se afirma."
)

# ---------------------------------------------------------------- referencias
doc.add_page_break()
titulo("Referencias", nivel=1)

for entrada in [
    "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.",
]:
    referencia(entrada)

# ---------------------------------------------------------------- anexo
doc.add_page_break()
titulo("Anexo. Cuaderno ejecutable", nivel=1)

parrafo(
    "El cuaderno con el código completo, las salidas de los dos entrenamientos época a época, las "
    "figuras y las comparaciones de temperatura está disponible en Google Colab:"
)
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
_fuente(p.add_run(URL_COLAB), negrita=True)
doc.add_paragraph()

parrafo(
    "El cuaderno es autónomo: descarga el corpus desde Project Gutenberg y lleva embebido el "
    "mini-corpus de clase como respaldo, de modo que no requiere subir ningún archivo al entorno de "
    "ejecución. Basta con abrirlo y ejecutar todas las celdas en orden."
)
texto_anexo = (
    "Archivo asociado: Oviedo_Alexander_Proyecto_Integrador_S7.ipynb. Entorno verificado: "
    "TensorFlow con Keras 3, ejecución en CPU."
)
if R.get("tiempo_total_min"):
    texto_anexo += f" Tiempo aproximado de ejecución completa: {R['tiempo_total_min']} minutos."
parrafo(texto_anexo)

# ----------------------------------------------------------------------------
doc.save(SALIDA)
print(f"Informe generado: {SALIDA}")
print(f"  Tablas : {CONTADOR['tabla']}")
print(f"  Figuras: {CONTADOR['figura']}")
print(f"  Recuerde reemplazar el marcador de la URL de Colab: {URL_COLAB}")
