Apuntes de Recuperación de Información Web

Índice

Instalación de requerimientos

Verificación de Python

Antes de instalar las dependencias, verifique que el sistema cuente con Python versión 3.9 o superior.

python --version

En caso de que el comando anterior no esté disponible, intente:

python3 --version

Creación del entorno virtual

Se recomienda crear un entorno virtual para aislar las dependencias del proyecto y evitar conflictos entre versiones de librerías.

python -m venv mlp_env

Activación del entorno virtual

La activación del entorno virtual depende del sistema operativo y del intérprete de comandos utilizado.

GNU/Linux y macOS

source mlp_env/bin/activate

Windows – Símbolo del sistema (CMD)

Si se utiliza el Símbolo del sistema (cmd.exe), ejecute:

mlp_env\Scripts\activate.bat

Windows – PowerShell

Si se utiliza Windows PowerShell, ejecute:

mlp_env\Scripts\Activate.ps1

En caso de que la ejecución de scripts esté deshabilitada, habilítela temporalmente con:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process

Posteriormente, vuelva a ejecutar el comando de activación.

Windows – Git Bash

Si se utiliza Git Bash, ejecute:

source mlp_env/Scripts/activate

Una vez activado el entorno virtual, el nombre del entorno aparecerá entre paréntesis al inicio de la línea de comandos.

Actualización del gestor de paquetes

Antes de instalar las librerías, se recomienda actualizar el gestor de paquetes pip.

pip install --upgrade pip

Instalación de dependencias

Ejecute el siguiente comando para instalar los requerimientos del módulo Perceptrón Multicapa feedforward.

pip install numpy matplotlib scikit-learn feedparser

Verificación de la instalación

Para verificar que las dependencias se instalaron correctamente, ejecute Python en modo interactivo:

python

Posteriormente, importe las librerías:

import numpy
import matplotlib
import sklearn
import feedparser
print("Instalación de requerimientos completada correctamente")

Desactivación del entorno virtual

Una vez finalizado el trabajo, el entorno virtual puede desactivarse con el siguiente comando:

deactivate

Manual de Entornos Virtuales en Python

Introducción

El uso de entornos virtuales es esencial para mantener las dependencias de tus proyectos aisladas. En este manual aprenderás a gestionarlos usando el módulo estándar venv.

Flujo de Trabajo Básico

1. Creación del entorno

Para crear un entorno virtual, navega a la raíz de tu proyecto en la terminal (o dentro de un buffer de Emacs con M-x shell) y ejecuta:

python -m venv .venv

Nota: El nombre .venv es una convención que hace que el directorio sea oculto en sistemas Unix.

2. Activación

La activación depende de tu sistema operativo:

  • En Windows (PowerShell)
    .\.venv\Scripts\Activate.ps1
    
  • En macOS / Linux
    source .venv/bin/activate
    

Gestión de paquetes

Una vez activado (verás el prefijo (.venv) en tu prompt), puedes instalar librerías:

pip install requests pandas

El archivo de Requerimientos

Es fundamental para la reproducibilidad del proyecto.

Exportar dependencias

pip freeze > requirements.txt

Instalar desde el archivo

pip install -r requirements.txt

Tips de Limpieza

Para salir del entorno virtual:

deactivate

Para borrar el entorno, simplemente elimina la carpeta:

rm -rf .venv  # En Linux/macOS
rmdir /s /q .venv  # En Windows

"Keep your global Python clean, keep your projects isolated."

Entornos Virtuales Python (Edición Windows)

Requisitos Previos

  1. Tener instalado Python (descargado de python.org o la Microsoft Store).
  2. Durante la instalación, asegúrate de marcar la casilla: "Add Python to PATH".

Flujo de Trabajo en Windows

1. Crear el Entorno Virtual

Abre tu terminal (PowerShell o CMD) en la carpeta de tu proyecto. El comando es el mismo para ambos:

python -m venv venv

2. El Paso Crítico: La Activación

En Windows, la activación depende de qué terminal estés usando.

  • Opción A: PowerShell (Recomendado)

    Si es la primera vez que usas scripts en Windows, podrías recibir un error de seguridad. Primero, ejecuta esto como administrador (solo una vez):

    Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
    

    Luego, para activar el entorno:

    .\venv\Scripts\Activate.ps1
    
  • Opción B: Símbolo del Sistema (CMD)
    .\venv\Scripts\activate.bat
    

3. Confirmación

Sabrás que el entorno está activo porque el nombre (venv) aparecerá a la izquierda de la ruta en tu terminal: #+example (venv) C:\Proyectos\MiProyecto> #+example

Gestión de Librerías con PIP

Instalación de paquetes

Una vez activo el entorno, instala lo que necesites:

pip install pandas requests openpyxl

Congelar dependencias (Compartir proyecto)

Para que otros participantes tengan exactamente lo mismo que tú:

pip freeze > requirements.txt

Instalar desde un archivo recibido

Si un compañero te pasa su requirements.txt:

pip install -r requirements.txt

Uso de Entornos en Emacs (Windows)

Para que Emacs en Windows gestione bien el entorno, añade esto a tu archivo de configuración (init.el o .emacs):

Instalación del paquete pyvenv

(use-package pyvenv
  :ensure t
  :config
  (pyvenv-mode 1))

Cómo activarlo dentro de Emacs

  1. Presiona M-x pyvenv-activate.
  2. Emacs te pedirá la ruta. Navega hasta la carpeta venv de tu proyecto.
  3. Al seleccionarla, Emacs usará ese intérprete de Python para todos los scripts que ejecutes.

Solución de Problemas Comunes en Windows

Error / Problema Solución
:--- :---
"python" no se reconoce Reinstala Python y marca "Add to PATH" o usa el comando `py`.
Error de "Execution Policy" Ejecuta `Set-ExecutionPolicy RemoteSigned -Scope CurrentUser`.
No aparece el (venv) Asegúrate de usar el comando de activación correcto para tu terminal (.ps1 vs .bat).

Desactivación y Limpieza

Para salir del entorno:

deactivate

Si quieres borrar el entorno por completo (para empezar de cero):

rmdir /s /q venv

Recordatorio: Nunca incluyas la carpeta venv en tus archivos compartidos o en tu repositorio de Git. Solo comparte el código y el archivo requirements.txt.

Agregar a jupyter notebook

pip install ipykernel
python -m ipykernel install --user --name=redes --display-name="redes"

Sistemas de recuperación de información

Introducción a los sistemas de recuperación de información (IRS)

Definición y alcance

Un Sistema de Recuperación de Información (IRS, por sus siglas en inglés Information Retrieval System) es un sistema software diseñado para almacenar, representar y recuperar información relevante ante consultas de usuarios.

  • Características principales
    • Almacena grandes volúmenes de documentos (texto, multimedia, metadatos).
    • Permite consultas en lenguaje natural o estructurado.
    • Devuelve un ranking o lista ordenada de documentos según relevancia.
    • La relevancia suele ser subjetiva y dependiente del contexto del usuario.
  • Ejemplo de necesidad de información
    Usuario: "Necesito saber cómo se calcula la precisión en un sistema de búsqueda
    para poder comparar dos motores que estamos evaluando en mi empresa."
    
    El IRS no busca la frase exacta "cómo se calcula la precisión"; busca documentos
    que traten sobre *evaluación*, *precisión*, *sistemas de búsqueda* y los ordena
    según qué tan bien satisfacen esa necesidad.
    

Objetivo fundamental

El objetivo no es devolver todos los documentos que coincidan literalmente con la consulta, sino aquellos que el usuario consideraría útiles o relevantes para satisfacer su necesidad de información.

  • Diferencia con bases de datos
    Bases de datos Recuperación de información
    Consultas exactas (SQL) Consultas por similitud/relevancia
    Coincidencia exacta Ranking y ordenación
    Datos estructurados Texto libre, documentos
    Respuesta determinista Respuesta aproximada, probabilista

Componentes típicos de un IRS

  1. Índice :: Estructura que permite localizar documentos sin escanear todo el corpus.
  2. Motor de búsqueda :: Algoritmos que comparan consulta vs. documentos.
  3. Modelo de ranking :: Criterio para ordenar resultados (TF-IDF, BM25, etc.).
  4. Interfaz de usuario :: Formulario de búsqueda, resultados, filtros.
  5. Módulo de evaluación :: Métricas para medir calidad (precisión, recall, etc.).

Aplicaciones

  • Motores de búsqueda web (Google, Bing).
  • Búsqueda en bibliotecas digitales y repositorios.
  • Búsqueda en correo electrónico y documentos corporativos.
  • Sistemas de recomendación y búsqueda semántica.

Ejemplo de flujo completo en un IRS

  • Corpus mínimo de ejemplo

    Se tienen 3 documentos (para ilustrar; en la realidad son millones):

    ID Documento
    d1 "La recuperación de información usa índices para buscar documentos de forma rápida."
    d2 "Los modelos vectoriales calculan similitud entre consulta y documento."
    d3 "La evaluación mide precisión y recall del sistema de búsqueda."
  • Paso 1: Indexación
    • Se extraen términos (tras eliminar stopwords y aplicar stemming): recuperación, información, índices, documentos, modelos, vectoriales, similitud, consulta, evaluación, precisión, recall, búsqueda.
    • Se construye un índice invertido: para cada término, lista de documentos que lo contienen.
    • Ejemplo: precisión \(\rightarrow\) [d3], documentos \(\rightarrow\) [d1], recuperación \(\rightarrow\) [d1], etc.
  • Paso 2: Consulta
    • Usuario escribe: "evaluación precisión búsqueda".
  • Paso 3: Recuperación y ranking
    • El motor obtiene candidatos del índice (p. ej. d3 contiene los tres términos) y aplica el modelo (TF-IDF o BM25) para puntuar cada documento.
    • Resultado ordenado: d3 (mayor puntuación), y posiblemente d1, d2 si comparten términos.
  • Paso 4: Presentación
    • Se muestra al usuario: título/snippet de d3 primero, luego los demás, para que pueda elegir el documento que le resulta relevante.

Interfaces de usuario para búsqueda

Funciones de la interfaz

La interfaz de usuario en un IRS debe permitir:

  1. Formular la consulta (caja de búsqueda, operadores, filtros).
  2. Ver resultados ordenados y con información suficiente para decidir.
  3. Refinar o reformular la consulta (búsqueda iterativa).
  4. Acceder al documento completo cuando se considera relevante.

Tipos de interfaces

  • Interfaz de consulta por palabras clave
    • El usuario escribe términos (palabras clave).
    • Puede usar operadores: AND, OR, NOT, comillas para frases.
    • Ejemplo: información AND (recuperación OR búsqueda).
    • Ejemplos de consultas booleanas
      Consulta Interpretación
      recuperación información documentos que contengan ambos términos (AND implícito)
      "recuperación de información" documentos con la frase exacta
      TF-IDF OR BM25 documentos que contengan al menos uno de los dos
      evaluación NOT recall documentos sobre evaluación pero sin la palabra recall
      (precisión OR recall) AND métricas métricas y además precisión o recall
  • Interfaz de lenguaje natural
    • Consultas en forma de pregunta o frase.
    • El sistema interpreta la intención (a veces con NLP).
    • Ejemplo: "¿Cómo se evalúa un sistema de recuperación?"
    • Ejemplo
      Usuario escribe: "recetas de pastel de chocolate sin gluten"
      
      El sistema puede extraer: términos clave (recetas, pastel, chocolate, gluten),
      negación (sin gluten) y devolver documentos que hablen de pastel de chocolate
      y que mencionen "sin gluten" o recetas aptas para celíacos, aunque no aparezca
      la frase exacta.
      
  • Interfaz con filtros y facetas
    • Filtros por fecha, autor, tipo de documento, idioma.
    • Facetas: categorías o atributos para restringir resultados.
    • Muy común en tiendas online y bibliotecas digitales.
    • Ejemplo (biblioteca digital)
      Búsqueda: "recuperación de información"
      Facetas mostradas al lado:
        Tipo de documento: Libro (120), Artículo (85), Tesis (12)
        Año: 2020-2024 (45), 2015-2019 (98), anterior (74)
        Idioma: Español (150), Inglés (67)
      
      El usuario hace clic en "Artículo" y "2020-2024": la lista de resultados se
      restringe a artículos de esos años, sin cambiar la consulta textual.
      
  • Interfaz de búsqueda por ejemplo (Query by Example)
    • El usuario proporciona un documento o fragmento como "ejemplo" de lo que busca.
    • El sistema busca documentos similares (búsqueda por similitud).
    • Ejemplo
      Usuario pega un párrafo de un artículo que le gustó:
      "El modelo vectorial representa documentos como vectores en un espacio de términos.
      La similitud coseno mide el ángulo entre el vector de la consulta y el del documento."
      
      El sistema trata ese texto como una "consulta larga" o como documento de referencia,
      calcula su vector (o embedding) y busca en el corpus los documentos más similares,
      devolviendo por ejemplo artículos sobre modelos vectoriales, TF-IDF y similitud coseno.
      

Elementos de presentación de resultados

  • Lista de resultados (SERP)
    • Título, URL, snippet o fragmento del documento.
    • Destacados (bold) de los términos de la consulta.
    • Paginación o scroll infinito.
  • Snippets
    • Fragmentos cortos del documento donde aparecen los términos.
    • Ayudan al usuario a juzgar relevancia sin abrir el documento.
  • Agrupación y clustering
    • Resultados agrupados por sitio, fecha o tema.
    • Reduce redundancia y facilita la exploración.
  • Ejemplo de SERP (página de resultados)
    Consulta: "evaluación recuperación información"
    
    --- Resultado 1 ---
    Título: Evaluación de sistemas de recuperación de información - Wikipedia
    URL: https://es.wikipedia.org/wiki/...
    Snippet: ... La evaluación de la recuperación de información utiliza métricas como
    precisión, recall y F1. Se usan colecciones de prueba con juicios de relevancia ...
    
    --- Resultado 2 ---
    Título: Precisión y exhaustividad - Recuperación de información
    URL: https://...
    Snippet: ... Para evaluar un sistema de recuperación se definen la precisión (P) y
    la exhaustividad (recall R). La precisión mide cuántos de los recuperados son
    relevantes ...
    
    --- Resultado 3 ---
    ...
    

    Los términos "evaluación", "recuperación", "información" aparecerían resaltados en negrita en título y snippet para que el usuario juzgue la relevancia de un vistazo.

Usabilidad y experiencia de usuario

  • Tiempo de respuesta: resultados en milisegundos.
  • Claridad: que el usuario entienda por qué aparece cada resultado.
  • Opciones de refinamiento: sugerencias, "personas también buscaron", filtros.
  • Accesibilidad: uso con teclado, lectores de pantalla, diseño responsive.

Modelos de recuperación de información

Un modelo de recuperación define cómo se representan documentos y consultas, y cómo se calcula la relevancia o similitud entre ellos.

Modelo booleano

  • Idea
    • Documentos y consultas como conjuntos de términos.
    • La consulta es una expresión booleana (AND, OR, NOT).
    • Un documento es "relevante" si satisface la expresión (verdadero/falso).
  • Limitaciones
    • No hay ranking: todos los documentos que coinciden son iguales.
    • No captura importancia de términos (frecuencia, rareza).
    • Demasiado rígido para el usuario promedio.
  • Ejemplo numérico (modelo booleano)

    Corpus de 4 documentos (términos tras stemming):

    Doc Términos presentes
    d1 recuperación, información, índice, documento
    d2 modelo, vectorial, similitud, documento
    d3 evaluación, precisión, recall, búsqueda
    d4 evaluación, información, índice

    Consultas y resultados:

    • \(q_1\) = recuperación AND información \(\Rightarrow\) {d1} (solo d1 tiene ambos).
    • \(q_2\) = evaluación OR precisión \(\Rightarrow\) {d3, d4} (d3 tiene ambos; d4 tiene evaluación).
    • \(q_3\) = documento NOT vectorial \(\Rightarrow\) {d1} (d1 y d2 tienen "documento"; d2 tiene "vectorial", se excluye; d1 no tiene "vectorial", se incluye).

    Todos los documentos devueltos se consideran "iguales"; no hay orden de preferencia.

Modelo vectorial

  • Idea
    • Documentos y consultas como vectores en un espacio donde cada dimensión es un término.
    • Cada componente del vector es un peso (p. ej. TF-IDF).
    • Similitud = similitud coseno entre vector de consulta y vector del documento.
  • Fórmula de similitud coseno

    \[ \text{sim}(q, d) = \frac{\vec{q} \cdot \vec{d}}{|\vec{q}| \, |\vec{d}|} \]

  • Ventajas
    • Permite ranking (ordenar por similitud).
    • Simple y eficiente.
    • TF-IDF captura importancia de términos.
  • Ejemplo: espacio de términos

    Supongamos vocabulario = {recuperación, información, evaluación}. Cada documento y la consulta se representan como vectores de 3 dimensiones (pesos TF-IDF):

    • \(q\) = "recuperación información" \(\rightarrow\) \(\vec{q} = (0.8, 0.7, 0)\)
    • \(d_1\) = "recuperación de información e información" \(\rightarrow\) \(\vec{d_1} = (0.5, 0.9, 0)\)
    • \(d_2\) = "evaluación de la recuperación" \(\rightarrow\) \(\vec{d_2} = (0.6, 0, 0.8)\)

    Similitud coseno: \(\text{sim}(q,d) = \frac{\vec{q}\cdot\vec{d}}{|\vec{q}||\vec{d}|}\).

    • \(\vec{q}\cdot\vec{d_1} = 0.8\cdot 0.5 + 0.7\cdot 0.9 = 0.4 + 0.63 = 1.03\) (alto).
    • \(\vec{q}\cdot\vec{d_2} = 0.8\cdot 0.6 + 0 + 0 = 0.48\) (menor).

    Tras normalizar por las normas, \(d_1\) queda por encima de \(d_2\) en el ranking, que es lo esperado porque \(d_1\) comparte ambos términos de la consulta.

TF-IDF (Term Frequency - Inverse Document Frequency)

  • Frecuencia de término (TF)
    • Cuántas veces aparece el término en el documento.
    • Variantes: TF bruto, TF logarítmico (suavizado).
  • Frecuencia inversa de documento (IDF)
    • \(\text{IDF}(t) = \log \frac{N}{n_t}\), donde \(N\) = número de documentos, \(n_t\) = documentos que contienen \(t\).
    • Términos raros (en pocos documentos) tienen mayor IDF.
  • Peso TF-IDF

    \[ w_{t,d} = \text{TF}(t,d) \times \text{IDF}(t) \]

  • Ejemplo numérico TF-IDF

    Corpus: 3 documentos. Término "recuperación":

    • En d1 aparece 3 veces; en d2 aparece 1 vez; en d3 no aparece.
    • \(N = 3\), \(n_{\text{recuperación}} = 2\) (está en d1 y d2).
    • \(\text{IDF}(\text{recuperación}) = \log \frac{3}{2} \approx 0.41\).

    Para d1: \(\text{TF} = 3\) (o \(\log(1+3)\) si se suaviza). Peso \(\approx 3 \times 0.41 \approx 1.23\). Para d2: \(\text{TF} = 1\). Peso \(\approx 1 \times 0.41 \approx 0.41\).

    El término "recuperación" aporta más peso en d1 que en d2 porque es más frecuente en d1; y aporta más que un término que aparezca en los 3 documentos (IDF menor).

Modelo probabilístico (BM25, etc.)

  • Idea
    • Estimar la probabilidad de que un documento sea relevante dada la consulta.
    • Ordenar por \(P(\text{relevante} \mid d, q)\).
  • BM25
    • Extensión del modelo probabilístico; muy usado en práctica.
    • Incorpora longitud del documento (penalización por documentos muy largos).
    • Parámetros: \(k_1\), \(b\) para ajustar saturación de TF y efecto de la longitud.
  • Ejemplo intuitivo BM25

    Documento A: 50 palabras, "recuperación" aparece 2 veces. Documento B: 500 palabras, "recuperación" aparece 10 veces.

    En TF bruto, B tendría mayor TF. Pero BM25:

    • Satura el TF: 10 apariciones no aportan 5 veces más que 2 (crecimiento sublineal).
    • Penaliza por longitud: un documento muy largo tiene más probabilidad de contener el término por casualidad; BM25 reduce el peso según la longitud respecto a la media del corpus. Así, un documento corto y focalizado (A) puede rankear más alto.

Modelos basados en lenguaje (Language Models)

  • Idea
    • Modelar cada documento como una distribución de probabilidad sobre términos (language model).
    • La consulta se "genera" con cierta probabilidad desde el modelo del documento.
    • Ranking por \(P(q \mid d)\) o variantes (e.g. mezcla con modelo de colección).
  • Ventajas
    • Base teórica sólida (probabilidad).
    • Permite suavizado (smoothing) para términos no vistos.

Resumen comparativo

Modelo Ranking Complejidad Uso típico
Booleano No Baja Sistemas legados
Vectorial Media General, TF-IDF
Probabilístico Media BM25 en Elasticsearch
Language Model Mayor Investigación, NLP

Evaluación de la recuperación

La evaluación permite comparar sistemas o configuraciones y decidir mejoras.

Conjuntos de prueba (test collections)

  • Componentes
    1. Corpus: conjunto de documentos.
    2. Consultas (topics): necesidades de información representativas.
    3. Juicios de relevancia (qrels): para cada par (consulta, documento), si el documento es relevante o no (idealmente por humanos).
  • Ejemplos de colecciones
    • Cranfield, TREC, CLEF, NTCIR: estándar en investigación.
    • En la industria: datos propios con juicios implícitos (clics, tiempo en página).
  • Ejemplo de colección mínima
    • Corpus: 5 documentos d1, d2, d3, d4, d5.
    • Consulta: "evaluación de la recuperación".
    • Juicios de relevancia (qrels): un evaluador humano indica qué documentos son relevantes:

      • d1: no relevante.
      • d2: relevante.
      • d3: relevante.
      • d4: no relevante.
      • d5: relevante.

      Total relevante = 3 (d2, d3, d5).

Métricas principales

  • Precisión (Precision)

    \[ P = \frac{\text{documentos relevantes recuperados}}{\text{total de documentos recuperados}} \]

    • "De lo que devolví, ¿cuánto era relevante?"
  • Recall (Exhaustividad)

    \[ R = \frac{\text{documentos relevantes recuperados}}{\text{total de documentos relevantes en el corpus}} \]

    • "De todo lo relevante, ¿cuánto recuperé?"
  • F1 (F-measure)
    • Media armónica de precisión y recall:

    \[ F_1 = 2 \frac{P \cdot R}{P + R} \]

  • Precisión en k (P@k)
    • Precisión considerando solo los primeros \(k\) resultados.
    • Útil cuando el usuario solo mira los primeros resultados (p. ej. P@10).
  • Ejemplo numérico: precisión, recall, F1, P@k

    Con la colección anterior: 3 documentos relevantes (d2, d3, d5). Supongamos que el sistema devuelve, en orden: [d2, d1, d3, d4, d5].

    • Recuperados: 5. Relevantes recuperados: d2, d3, d5 \(\Rightarrow\) 3.
    • Precisión \(P = 3/5 = 0.6\) (de los 5 devueltos, 3 son relevantes).
    • Recall \(R = 3/3 = 1.0\) (recuperamos todos los relevantes).
    • \(F_1 = 2 \cdot \frac{0.6 \cdot 1}{0.6 + 1} = \frac{1.2}{1.6} \approx 0.75\).

    P@k:

    • P@1 = 1/1 = 1 (el primero es relevante).
    • P@2 = 1/2 = 0.5 (de los dos primeros, uno es relevante).
    • P@3 = 2/3 \(\approx\) 0.67.
    • P@5 = 3/5 = 0.6.
  • Precisión promedia (AP) y MAP
    • Average Precision (AP): para una consulta, promedio de precisiones en cada punto de recall donde se recupera un documento relevante.
    • MAP (Mean Average Precision): media de AP sobre todas las consultas.
    • Muy usada en competiciones y literatura.
  • Ejemplo: Average Precision (AP)

    Mismo ranking: [d2, d1, d3, d4, d5]; relevantes = {d2, d3, d5}.

    En las posiciones 1, 2, 3, 4, 5 vamos marcando si el documento es relevante (R) o no (N): pos 1: d2 R \(\rightarrow\) precisión hasta aquí = 1/1 = 1.0 pos 2: d1 N pos 3: d3 R \(\rightarrow\) precisión hasta aquí = 2/3 \(\approx\) 0.67 pos 4: d4 N pos 5: d5 R \(\rightarrow\) precisión hasta aquí = 3/5 = 0.6

    AP = promedio de las precisiones en cada "hit" relevante: \[ \text{AP} = \frac{1 + 2/3 + 3/5}{3} = \frac{1 + 0.67 + 0.6}{3} \approx 0.76 \] Un ranking perfecto [d2, d3, d5, …] tendría AP = 1.0.

  • NDCG (Normalized Discounted Cumulative Gain)
    • Tiene en cuenta la posición en el ranking: más relevante arriba es mejor.
    • El "gain" se descuenta según la posición (discounted).
    • NDCG normaliza por el DCG ideal (ranking perfecto).
    • Adecuado cuando hay grados de relevancia (no solo relevante/no relevante).
    • Ejemplo NDCG (intuitivo)

      Supongamos relevancia en escala 0–3: 0 = no relevante, 3 = muy relevante. Ranking del sistema: pos1 rel=2, pos2 rel=0, pos3 rel=3, pos4 rel=1.

      DCG suma el "gain" (relevancia) descontado por la posición: \(\frac{\text{rel}}{\log_2(\text{pos}+1)}\).

      • Pos 1: \(2/\log_2 2 = 2\)
      • Pos 2: \(0\)
      • Pos 3: \(3/\log_2 4 = 3/2 = 1.5\)
      • Pos 4: \(1/\log_2 5 \approx 0.43\)

      DCG \(\approx 2 + 0 + 1.5 + 0.43 \approx 3.93\).

      El DCG ideal ordenaría por relevancia descendente: [3, 2, 1, 0] \(\Rightarrow\) IDCG. NDCG = DCG / IDCG (normalizado entre 0 y 1). Así se premia que los más relevantes estén arriba en el ranking.

Evaluación con usuarios

  • Estudios de usabilidad: tiempo para completar tareas, satisfacción, número de clics.
  • A/B testing: comparar dos versiones del sistema con usuarios reales.
  • Juicios de relevancia: coste humano; a veces se usan juicios implícitos (clics, dwell time).

Trade-off precisión vs. recall

  • Aumentar resultados mostrados \(\Rightarrow\) recall sube, precisión puede bajar.
  • Ser más estricto en el ranking \(\Rightarrow\) precisión sube, recall puede bajar.
  • Depende del dominio: en legal/medicina a veces se prioriza recall; en web comercial, precisión en los primeros resultados.
  • Ejemplo
    Corpus: 100 documentos, 10 relevantes para la consulta.
    
    - Si el sistema devuelve solo los 5 primeros y los 5 son relevantes:
      P = 5/5 = 1.0, R = 5/10 = 0.5 (precisión perfecta, recall bajo).
    
    - Si devuelve 50 documentos y 10 son relevantes:
      P = 10/50 = 0.2, R = 10/10 = 1.0 (recall perfecto, precisión baja).
    
    - Objetivo típico: devolver unos 10–20 resultados con varios relevantes arriba,
      equilibrando P y R (p. ej. P@10 alto y recall razonable).
    

Referencias

  • Manning, Raghavan, Schütze: Information Retrieval: Implementing and Evaluating Search Engines (MIT Press).
  • Baeza-Yates, Ribeiro-Neto: Modern Information Retrieval (Addison Wesley).
  • TREC: https://trec.nist.gov/ (benchmarks y métricas).

Ejemplos IRS — Recuperación de información con feeds RSS

Un Sistema de Recuperación de Información (IRS, o IR en inglés) es un sistema que permite almacenar, organizar y recuperar documentos (o ítems) relevantes ante una consulta del usuario. Los componentes típicos son:

  1. Colección: conjunto de documentos (en nuestras actividades = ítems de feeds RSS).
  2. Consulta: necesidad de información expresada por el usuario (query).
  3. Proceso de recuperación: matching entre consulta y documentos (ranking, filtrado).
  4. Interfaz: forma en que el usuario formula consultas y ve resultados.

En estas actividades usaremos feeds RSS como colección de documentos: cada entrada (item) es un “documento” con título, descripción, fecha y enlace. Así practicamos extracción, limpieza e indexación como en un IRS real.

Un buscador introductorio con feeds RSS

En este material construimos un buscador muy sencillo que usa feeds RSS como fuente de documentos: tú escribes una consulta y el sistema te devuelve ítems relevantes (título, enlace, fecha). Para que el buscador tenga algo que buscar, primero hay que encontrar URLs de feeds RSS. Por eso la primera actividad es usar hacks de búsqueda en Google (o DuckDuckGo, etc.) para descubrir esas páginas; después extraemos, limpiamos e indexamos esos feeds y probamos la búsqueda.

Hacks de búsqueda: encontrar páginas y URLs de feeds RSS

El uso de operadores de búsqueda (también conocidos como hacks de búsqueda) permite refinar las consultas en buscadores web con el objetivo de localizar páginas que publican feeds RSS. Mediante estos operadores es posible identificar de forma eficiente las URLs de dichos feeds, los cuales constituyen la fuente primaria de documentos para el sistema de recuperación de información. Sin una colección de feeds RSS adecuadamente identificada, no es posible construir ni alimentar el buscador de noticias propuesto.

  • Operadores útiles Google Search
    Operador Ejemplo Descripción
    site: site:bbc.com rss Busca RSS dentro de un dominio
    inurl: inurl:rss noticias Busca URLs que contengan "rss"
    intitle: intitle:rss technology Busca páginas con "rss" en el título
    filetype: filetype:xml rss Localiza archivos XML (feeds)
    "frase exacta" "RSS feed" site:news Coincidencia exacta
    OR rss OR "xml feed" Búsqueda alternativa
    - rss -podcast Excluye términos
    allinurl: allinurl:rss feeds news Todas las palabras en la URL
  • Operadores útiles DuckDuckGo
    Operador Ejemplo Descripción
    site: site:reuters.com rss Limita búsqueda a un dominio
    inurl: inurl:rss Busca "rss" en la URL
    intitle: intitle:"rss feed" Busca en el título
    filetype: filetype:xml rss Archivos XML
    "frase exacta" "news rss feed" Coincidencia exacta
    - rss -video Excluir términos
  • Bing Search
    Operador Ejemplo Descripción
    site: site:elpais.com rss Buscar RSS por dominio
    inurl: inurl:feed URLs que contienen "feed"
    intitle: intitle:rss Buscar en títulos
    filetype: filetype:xml rss Feeds XML
    "frase exacta" "rss noticias" Coincidencia exacta
    - rss -audio Exclusión
  • Hacks combinados (muy útiles para IRS)
    Consulta ejemplo
    site:news "rss feed"
    inurl:rss filetype:xml
    site:.org intitle:rss
    site:gov filetype:xml rss
    "rss feed" "news"

Consultas de ejemplo para probar en el buscador

inurl:rss noticias tecnología
inurl:feed blog educación
"rss" "suscribirse" site:elpais.com
filetype:xml rss
inurl:atom feed

Desde el punto de vista del IRS, la consulta que se escribe en el buscador (p. ej. inurl:rss noticias) es la “consulta” del sistema de recuperación; los resultados que muestra Google (o DuckDuckGo, Bing) son la “interfaz de búsqueda”: un listado de documentos (páginas) que el modelo de recuperación del buscador consideró relevantes. Las URLs de feeds que el usuario anota pasan a ser la colección que alimentará el pequeño buscador sobre RSS.

Extraer datos de un feed RSS y guardarlos

En un IRS la colección es el conjunto de documentos sobre el que el sistema responde a las consultas. Sin colección no hay recuperación. En nuestro caso, los “documentos” son los ítems de un feed RSS: cada entrada del feed (título, enlace, fecha, resumen) equivale a un documento indexable. Extraer esos ítems y representarlos en una estructura uniforme (p. ej. lista de diccionarios) es el primer paso para que el IRS pueda comparar después la consulta del usuario contra cada documento. Los feeds RSS son idóneos porque ya vienen estructurados (XML con <item> o entradas Atom), con campos de texto (título, descripción) que el IRS puede usar para búsqueda.

  • IRS — Colección: la lista de ítems extraídos del feed es la colección del sistema.
  • RSS: el feed es la fuente externa; cada entry o <item> es un documento.
  • Código: descargar el feed (HTTP), parsear el XML y mapear cada ítem a campos (título, link, fecha, resumen) construye esa colección en memoria.

La función fetch_rss(url, max_entries) recibe la URL del feed y un límite de entradas. feedparser.parse(url) descarga y parsea el feed (RSS o Atom) y devuelve un objeto con .entries: lista de ítems. Para cada entrada se extraen title, link, published (o updated) y summary (o description). Esos campos son los que un IRS necesita para identificar el documento y para indexar texto (título y resumen). El resultado es una lista de diccionarios: cada uno representa un documento de la colección. Si hay error de red o XML inválido, se devuelve un ítem con error para no romper el flujo. Al final, items es la colección sobre la que más adelante se aplicará la consulta.

import feedparser
import json

def fetch_rss(url, max_entries=20):
    """Extrae ítems de un feed RSS/Atom. Devuelve lista de diccionarios."""
    try:
        feed = feedparser.parse(url)
        items = []
        for e in feed.entries[:max_entries]:
            items.append({
                "title": e.get("title", ""),
                "link": e.get("link", ""),
                "published": e.get("published", e.get("updated", "")),
                "summary": e.get("summary", e.get("description", "")),
            })
        return items
    except Exception as err:
        return [{"error": str(err), "url": url}]

url = "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada"
items = fetch_rss(url, max_entries=15)
print(json.dumps(items, indent=2, ensure_ascii=False))
[
  {
    "title": "El viaje de Ayuso a México: las inconsistencias y exageraciones del relato de la presidenta madrileña",
    "link": "https://elpais.com/espana/madrid/2026-05-13/el-viaje-de-ayuso-a-mexico-las-inconsistencias-y-exageraciones-del-relato-de-la-presidenta-madrilena.html",
    "published": "Wed, 13 May 2026 08:41:59 GMT",
    "summary": "Ayuso, a su regreso a España, no aclara cuáles fueron las amenazas que recibió ni dónde estuvo durante más de dos días"
  },
  {
    "title": "Entre el intervencionismo y el narco partido: Morena y la oposición atizan el fuego",
    "link": "https://elpais.com/mexico/2026-05-13/entre-el-intervencionismo-y-el-narco-partido-morena-y-la-oposicion-atizan-el-fuego.html",
    "published": "Wed, 13 May 2026 04:00:00 GMT",
    "summary": "A un año de las elecciones, Chihuahua y Sinaloa se convierten en el eje de la contienda política con Maru Campos y Rubén Rocha en el banquillo"
  },
  {
    "title": "Mario Zamora: “En Sinaloa se cruzaron líneas rojas y el fuera de la ley se convirtió en la ley”",
    "link": "https://elpais.com/mexico/2026-05-13/mario-zamora-en-sinaloa-se-cruzaron-lineas-rojas-y-el-fuera-de-la-ley-se-convirtio-en-la-ley.html",
    "published": "Wed, 13 May 2026 04:00:00 GMT",
    "summary": "El excandidato del PRI sostiene que la elección de 2021 fue “un operativo criminal” y que la crisis de Sinaloa no es culpa solo de Rubén Rocha, sino de todo un sistema de poder construido por Morena"
  },
  {
    "title": "La CIA desmiente haber participado en un atentado en el Estado de México contra un operador del Cartel de Sinaloa",
    "link": "https://elpais.com/mexico/2026-05-13/la-cia-desmiente-haber-participado-en-un-atentado-en-el-estado-de-mexico-contra-un-operador-del-cartel-de-sinaloa.html",
    "published": "Wed, 13 May 2026 00:43:27 GMT",
    "summary": "El secretario de Seguridad mexicano, Omar García Harfuch, rechaza la investigación de CNN, y asegura que la cooperación con EE UU se da en términos de intercambio de inteligencia"
  },
  {
    "title": "Desde Del Río a Laredo: la travesía de siete migrantes que murieron sofocados en un tren de Union Pacific en Texas",
    "link": "https://elpais.com/us/migracion/2026-05-13/desde-del-rio-a-laredo-la-travesia-de-siete-migrantes-que-murieron-sofocados-en-un-tren-de-union-pacific-en-texas.html",
    "published": "Wed, 13 May 2026 04:00:00 GMT",
    "summary": "Entre los fallecidos estaban un adolescente de 14 años y una mujer mexicana que alertó a un familiar sobre el calor extremo dentro de la caja, informaron las autoridades"
  },
  {
    "title": "Afrontar las desapariciones en México",
    "link": "https://elpais.com/opinion/2026-05-13/afrontar-las-desapariciones-en-mexico.html",
    "published": "Wed, 13 May 2026 03:30:01 GMT",
    "summary": "Las organizaciones internacionales advierten de que la impunidad sigue siendo absoluta en este horror cotidiano"
  },
  {
    "title": "Consulados mexicanos: sin posibilidad de defensa",
    "link": "https://elpais.com/mexico/opinion/2026-05-13/consulados-mexicanos-sin-posibilidad-de-defensa.html",
    "published": "Wed, 13 May 2026 04:00:00 GMT",
    "summary": "El futuro de nuestras legaciones en suelo gringo nacerá del huevo de la temeridad"
  },
  {
    "title": "Por el bien de la educación, primero la política",
    "link": "https://elpais.com/mexico/opinion/2026-05-13/por-el-bien-de-la-educacion-primero-la-politica.html",
    "published": "Wed, 13 May 2026 04:00:00 GMT",
    "summary": "Qué jugada pretendía el Gobierno calando a la sociedad a ver si aceptaba un recorte del 15% de las clases con el pretexto del Mundial"
  },
  {
    "title": "La política del disimulo",
    "link": "https://elpais.com/mexico/opinion/2026-05-13/la-politica-del-disimulo.html",
    "published": "Wed, 13 May 2026 04:00:00 GMT",
    "summary": "Ocho años antes de asumir como gobernador de Sinaloa, Rubén Rocha Moya publicó una novela sobre el narcotráfico en el Estado en el que mandó"
  },
  {
    "title": "Donald Trump aterriza en China para una cumbre de alto voltaje con Xi Jinping",
    "link": "https://elpais.com/internacional/2026-05-13/donald-trump-aterriza-en-china-para-una-cumbre-de-alto-voltaje-con-xi-jinping.html",
    "published": "Wed, 13 May 2026 12:12:35 GMT",
    "summary": "Tras meses de tensiones, se espera que Pekín use el encuentro para trazar sus líneas rojas sobre Taiwán, mientras Washington subraya el carácter económico de la cita, y busca apoyo para negociar la paz con Irán"
  },
  {
    "title": "Encuentro entre el águila y el dragón",
    "link": "https://elpais.com/internacional/2026-05-13/encuentro-entre-el-aguila-y-el-dragon.html",
    "published": "Wed, 13 May 2026 03:40:00 GMT",
    "summary": "Trump y Xi se verán en Pekín con Irán y Taiwán como principales focos de choque en una cumbre marcada por la pugna entre estabilidad y confrontación"
  },
  {
    "title": "Trump, sin armas frente a Xi",
    "link": "https://elpais.com/opinion/2026-05-13/trump-sin-armas-frente-a-xi.html",
    "published": "Wed, 13 May 2026 03:30:01 GMT",
    "summary": "El presidente de Estados Unidos aterriza en China lastrado por una posición de debilidad provocada por su política errática"
  },
  {
    "title": "Esfuerzo global para controlar el brote de hantavirus, mientras los casos en España y Francia disparan la incertidumbre",
    "link": "https://elpais.com/sociedad/2026-05-13/esfuerzo-global-para-controlar-el-brote-de-hantavirus-mientras-los-casos-en-espana-y-francia-disparan-la-incertidumbre.html",
    "published": "Wed, 13 May 2026 03:30:01 GMT",
    "summary": "La OMS minimiza el riesgo de un brote mayor: “Todos los casos han sido aislados y se están gestionando bajo una supervisión médica estricta”"
  },
  {
    "title": "“Estado 51”: la última burla amenazante de la Casa Blanca a Venezuela",
    "link": "https://elpais.com/us/2026-05-13/estado-51-la-ultima-burla-amenazante-de-la-casa-blanca-a-venezuela.html",
    "published": "Wed, 13 May 2026 01:30:43 GMT",
    "summary": "La cuenta oficial de la presidencia de Estados Unidos publica una sucesión de memes en los que sugiere anexionar el país sudamericano"
  },
  {
    "title": "Marco Rubio posa en el avión rumbo a China con el chándal que vestía Maduro en su captura",
    "link": "https://elpais.com/internacional/2026-05-13/marco-rubio-posa-en-el-avion-rumbo-a-china-con-el-chandal-que-vestia-maduro-en-su-captura.html",
    "published": "Wed, 13 May 2026 07:30:59 GMT",
    "summary": "La fotografía subida a las redes por el jefe de comunicación de la Casa Blanca se viraliza gracias a activistas republicanos y funcionarios de la Administración"
  }
]

Ejemplo con mas de una URL

import feedparser
import json

def fetch_rss(url, max_entries=20):
    """Extrae ítems de un feed RSS/Atom."""
    feed = feedparser.parse(url)
    items = []
    for e in feed.entries[:max_entries]:
        items.append({
            "title": e.get("title", ""),
            "link": e.get("link", ""),
            "published": e.get("published", e.get("updated", "")),
            "summary": e.get("summary", e.get("description", "")),
            "source": url
        })
    return items

# 🔹 Lista de feeds
urls = [
    "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada",
    "https://www.reddit.com/r/python/.rss",
    "https://hnrss.org/frontpage"
]

all_items = []

for url in urls:
    try:
        all_items.extend(fetch_rss(url, max_entries=10))
    except Exception as err:
        all_items.append({"error": str(err), "url": url})

print(json.dumps(all_items, indent=2, ensure_ascii=False))
[
  {
    "title": "El viaje de Ayuso a México: las inconsistencias y exageraciones del relato de la presidenta madrileña",
    "link": "https://elpais.com/espana/madrid/2026-05-13/el-viaje-de-ayuso-a-mexico-las-inconsistencias-y-exageraciones-del-relato-de-la-presidenta-madrilena.html",
    "published": "Wed, 13 May 2026 08:41:59 GMT",
    "summary": "Ayuso, a su regreso a España, no aclara cuáles fueron las amenazas que recibió ni dónde estuvo durante más de dos días",
    "source": "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada"
  },
  {
    "title": "Entre el intervencionismo y el narco partido: Morena y la oposición atizan el fuego",
    "link": "https://elpais.com/mexico/2026-05-13/entre-el-intervencionismo-y-el-narco-partido-morena-y-la-oposicion-atizan-el-fuego.html",
    "published": "Wed, 13 May 2026 04:00:00 GMT",
    "summary": "A un año de las elecciones, Chihuahua y Sinaloa se convierten en el eje de la contienda política con Maru Campos y Rubén Rocha en el banquillo",
    "source": "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada"
  },
  {
    "title": "Mario Zamora: “En Sinaloa se cruzaron líneas rojas y el fuera de la ley se convirtió en la ley”",
    "link": "https://elpais.com/mexico/2026-05-13/mario-zamora-en-sinaloa-se-cruzaron-lineas-rojas-y-el-fuera-de-la-ley-se-convirtio-en-la-ley.html",
    "published": "Wed, 13 May 2026 04:00:00 GMT",
    "summary": "El excandidato del PRI sostiene que la elección de 2021 fue “un operativo criminal” y que la crisis de Sinaloa no es culpa solo de Rubén Rocha, sino de todo un sistema de poder construido por Morena",
    "source": "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada"
  },
  {
    "title": "La CIA desmiente haber participado en un atentado en el Estado de México contra un operador del Cartel de Sinaloa",
    "link": "https://elpais.com/mexico/2026-05-13/la-cia-desmiente-haber-participado-en-un-atentado-en-el-estado-de-mexico-contra-un-operador-del-cartel-de-sinaloa.html",
    "published": "Wed, 13 May 2026 00:43:27 GMT",
    "summary": "El secretario de Seguridad mexicano, Omar García Harfuch, rechaza la investigación de CNN, y asegura que la cooperación con EE UU se da en términos de intercambio de inteligencia",
    "source": "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada"
  },
  {
    "title": "Desde Del Río a Laredo: la travesía de siete migrantes que murieron sofocados en un tren de Union Pacific en Texas",
    "link": "https://elpais.com/us/migracion/2026-05-13/desde-del-rio-a-laredo-la-travesia-de-siete-migrantes-que-murieron-sofocados-en-un-tren-de-union-pacific-en-texas.html",
    "published": "Wed, 13 May 2026 04:00:00 GMT",
    "summary": "Entre los fallecidos estaban un adolescente de 14 años y una mujer mexicana que alertó a un familiar sobre el calor extremo dentro de la caja, informaron las autoridades",
    "source": "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada"
  },
  {
    "title": "Afrontar las desapariciones en México",
    "link": "https://elpais.com/opinion/2026-05-13/afrontar-las-desapariciones-en-mexico.html",
    "published": "Wed, 13 May 2026 03:30:01 GMT",
    "summary": "Las organizaciones internacionales advierten de que la impunidad sigue siendo absoluta en este horror cotidiano",
    "source": "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada"
  },
  {
    "title": "Consulados mexicanos: sin posibilidad de defensa",
    "link": "https://elpais.com/mexico/opinion/2026-05-13/consulados-mexicanos-sin-posibilidad-de-defensa.html",
    "published": "Wed, 13 May 2026 04:00:00 GMT",
    "summary": "El futuro de nuestras legaciones en suelo gringo nacerá del huevo de la temeridad",
    "source": "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada"
  },
  {
    "title": "Por el bien de la educación, primero la política",
    "link": "https://elpais.com/mexico/opinion/2026-05-13/por-el-bien-de-la-educacion-primero-la-politica.html",
    "published": "Wed, 13 May 2026 04:00:00 GMT",
    "summary": "Qué jugada pretendía el Gobierno calando a la sociedad a ver si aceptaba un recorte del 15% de las clases con el pretexto del Mundial",
    "source": "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada"
  },
  {
    "title": "La política del disimulo",
    "link": "https://elpais.com/mexico/opinion/2026-05-13/la-politica-del-disimulo.html",
    "published": "Wed, 13 May 2026 04:00:00 GMT",
    "summary": "Ocho años antes de asumir como gobernador de Sinaloa, Rubén Rocha Moya publicó una novela sobre el narcotráfico en el Estado en el que mandó",
    "source": "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada"
  },
  {
    "title": "Donald Trump aterriza en China para una cumbre de alto voltaje con Xi Jinping",
    "link": "https://elpais.com/internacional/2026-05-13/donald-trump-aterriza-en-china-para-una-cumbre-de-alto-voltaje-con-xi-jinping.html",
    "published": "Wed, 13 May 2026 12:12:35 GMT",
    "summary": "Tras meses de tensiones, se espera que Pekín use el encuentro para trazar sus líneas rojas sobre Taiwán, mientras Washington subraya el carácter económico de la cita, y busca apoyo para negociar la paz con Irán",
    "source": "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada"
  },
  {
    "title": "Sunday Daily Thread: What's everyone working on this week?",
    "link": "https://www.reddit.com/r/Python/comments/1t8r5sf/sunday_daily_thread_whats_everyone_working_on/",
    "published": "2026-05-10T00:00:12+00:00",
    "summary": "<!-- SC_OFF --><div class=\"md\"><h1>Weekly Thread: What's Everyone Working On This Week? 🛠️</h1> <p>Hello <a href=\"https://www.reddit.com/r/Python\">r/Python</a>! It's time to share what you've been working on! Whether it's a work-in-progress, a completed masterpiece, or just a rough idea, let us know what you're up to!</p> <h1>How it Works:</h1> <ol> <li><strong>Show &amp; Tell</strong>: Share your current projects, completed works, or future ideas.</li> <li><strong>Discuss</strong>: Get feedback, find collaborators, or just chat about your project.</li> <li><strong>Inspire</strong>: Your project might inspire someone else, just as you might get inspired here.</li> </ol> <h1>Guidelines:</h1> <ul> <li>Feel free to include as many details as you'd like. Code snippets, screenshots, and links are all welcome.</li> <li>Whether it's your job, your hobby, or your passion project, all Python-related work is welcome here.</li> </ul> <h1>Example Shares:</h1> <ol> <li><strong>Machine Learning Model</strong>: Working on a ML model to predict stock prices. Just cracked a 90% accuracy rate!</li> <li><strong>Web Scraping</strong>: Built a script to scrape and analyze news articles. It's helped me understand media bias better.</li> <li><strong>Automation</strong>: Automated my home lighting with Python and Raspberry Pi. My life has never been easier!</li> </ol> <p>Let's build and grow together! Share your journey and learn from others. Happy coding! 🌟</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/AutoModerator\"> /u/AutoModerator </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1t8r5sf/sunday_daily_thread_whats_everyone_working_on/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1t8r5sf/sunday_daily_thread_whats_everyone_working_on/\">[comments]</a></span>",
    "source": "https://www.reddit.com/r/python/.rss"
  },
  {
    "title": "Tuesday Daily Thread: Advanced questions",
    "link": "https://www.reddit.com/r/Python/comments/1takwge/tuesday_daily_thread_advanced_questions/",
    "published": "2026-05-12T00:00:08+00:00",
    "summary": "<!-- SC_OFF --><div class=\"md\"><h1>Weekly Wednesday Thread: Advanced Questions 🐍</h1> <p>Dive deep into Python with our Advanced Questions thread! This space is reserved for questions about more advanced Python topics, frameworks, and best practices.</p> <h2>How it Works:</h2> <ol> <li><strong>Ask Away</strong>: Post your advanced Python questions here.</li> <li><strong>Expert Insights</strong>: Get answers from experienced developers.</li> <li><strong>Resource Pool</strong>: Share or discover tutorials, articles, and tips.</li> </ol> <h2>Guidelines:</h2> <ul> <li>This thread is for <strong>advanced questions only</strong>. Beginner questions are welcome in our <a href=\"https://www.reddit.com/r/python/.rss#daily-beginner-thread-link\">Daily Beginner Thread</a> every Thursday.</li> <li>Questions that are not advanced may be removed and redirected to the appropriate thread.</li> </ul> <h2>Recommended Resources:</h2> <ul> <li>If you don't receive a response, consider exploring <a href=\"https://www.reddit.com/r/LearnPython\">r/LearnPython</a> or join the <a href=\"https://discord.gg/python\">Python Discord Server</a> for quicker assistance.</li> </ul> <h2>Example Questions:</h2> <ol> <li><strong>How can you implement a custom memory allocator in Python?</strong></li> <li><strong>What are the best practices for optimizing Cython code for heavy numerical computations?</strong></li> <li><strong>How do you set up a multi-threaded architecture using Python's Global Interpreter Lock (GIL)?</strong></li> <li><strong>Can you explain the intricacies of metaclasses and how they influence object-oriented design in Python?</strong></li> <li><strong>How would you go about implementing a distributed task queue using Celery and RabbitMQ?</strong></li> <li><strong>What are some advanced use-cases for Python's decorators?</strong></li> <li><strong>How can you achieve real-time data streaming in Python with WebSockets?</strong></li> <li><strong>What are the performance implications of using native Python data structures vs NumPy arrays for large-scale data?</strong></li> <li><strong>Best practices for securing a Flask (or similar) REST API with OAuth 2.0?</strong></li> <li><strong>What are the best practices for using Python in a microservices architecture? (..and more generally, should I even use microservices?)</strong></li> </ol> <p>Let's deepen our Python knowledge together. Happy coding! 🌟</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/AutoModerator\"> /u/AutoModerator </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1takwge/tuesday_daily_thread_advanced_questions/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1takwge/tuesday_daily_thread_advanced_questions/\">[comments]</a></span>",
    "source": "https://www.reddit.com/r/python/.rss"
  },
  {
    "title": "[Ann] Pyrefly v1.0 (fast type checker & language server)",
    "link": "https://www.reddit.com/r/Python/comments/1tbyd7m/ann_pyrefly_v10_fast_type_checker_language_server/",
    "published": "2026-05-13T12:36:22+00:00",
    "summary": "<!-- SC_OFF --><div class=\"md\"><p>Hi, Pyrefly maintainer here. Today we are pleased to share that <a href=\"https://pyrefly.org\">Pyrefly</a>, a fast type checker and language server for Python, has reached stable v1.0 status, meaning we are confident that <strong>Pyrefly is ready for production use</strong>.</p> <p>Pyrefly was first released as an alpha in mid-2025 and followed up with a beta in November of that year. Since then, we have shipped over 60 minor releases: fixing hundreds of bugs, adding the features you’ve been asking for, and improving performance to be one of the fastest tools out there.</p> <p>This would not have been possible without our amazing open-source community. To everyone who filed GitHub issues, submitted pull requests, gave us feedback at conferences, or joined us on Discord: thank you. Your contributions shaped this release, we’re grateful for every one of them, and we hope you continue being a part of the journey for future releases too.</p> <p>We've published a <a href=\"https://pyrefly.org/blog/v1.0/\">blog post</a> explaining what v1.0 means exactly, and what's next for Pyrefly.</p> <p>Below is a summary of the changes to Pyrefly since the Beta release. The full release notes for v1.0 can be read on our Github. </p> <h1>Pyrefly v1.0 Release Notes</h1> <h2>Performance Improvements</h2> <p>We've continued to push Pyrefly's performance since the <a href=\"https://pyrefly.org/blog/2026/02/06/performance-improvements/\">speed improvements we shared in February</a>. Since beta:</p> <ul> <li><strong>2–125x faster updated diagnostics</strong> after saving a file (no, that’s not a typo!). Thanks to fine-grained dependency tracking and streaming diagnostics, updates now consistently arrive in milliseconds</li> <li><strong>20–36% faster full type checking</strong> on large projects like PyTorch and Pandas</li> <li><strong>2–3x faster initial indexing</strong> when Pyrefly first scans your project</li> <li><strong>40–60% less memory usage</strong> during both indexing and incremental type checking</li> </ul> <p>(Tested on an M4 Macbook Pro using open-source benchmarks from <a href=\"https://github.com/lolpack/type_coverage_py\">type_coverage_py</a> and <a href=\"https://github.com/astral-sh/ruff/tree/e990dfd069fceef96f797b46161ef78862608449/scripts/ty_benchmark\">ty_benchmark</a>.)</p> <p>Compare the performance of Pyrefly and other Python type checkers on our regularly updated <a href=\"https://python-type-checking.com/typecheck_benchmark/\">benchmarking suite</a>, which runs against 53 popular Python packages.</p> <hr /> <h2>Configuration Presets</h2> <p>A new <code>preset</code> configuration option provides named bundles of error severities and behavior settings.</p> <table><thead> <tr> <th align=\"left\">Preset</th> <th align=\"left\">Description</th> </tr> </thead><tbody> <tr> <td align=\"left\"><code>off</code></td> <td align=\"left\">Silences all diagnostics. Useful for IDE-only users or if you want total control of which errors are enabled.</td> </tr> <tr> <td align=\"left\"><code>basic</code></td> <td align=\"left\">Low-noise, high-confidence diagnostics only (syntax errors, missing imports, unknown names, etc.). Ideal for unconfigured projects or IDE-first users.</td> </tr> <tr> <td align=\"left\"><code>legacy</code></td> <td align=\"left\">For codebases migrating from mypy. Disables checks mypy doesn't have. <code>pyrefly init</code> now emits this preset automatically when migrating from a mypy config.</td> </tr> <tr> <td align=\"left\"><code>default</code></td> <td align=\"left\">The standard Pyrefly experience. Equivalent to having no preset.</td> </tr> <tr> <td align=\"left\"><code>strict</code></td> <td align=\"left\">Enables additional strict checks on top of the <code>default</code> preset. For users who want to avoid <code>Any</code> types in their codebase.</td> </tr> </tbody></table> <p>See the <a href=\"https://pyrefly.org/en/docs/configuration/#preset\">configuration docs</a> for details.</p> <hr /> <h2>Onboarding Experience</h2> <p>We’ve made improvements to the out-of-the-box experience for projects without a <code>pyrefly.toml</code>.</p> <ul> <li><strong>Automatic config synthesis</strong> — if you have a mypy or pyright config, Pyrefly automatically migrates your settings and synthesizes an appropriate in-memory Pyrefly config. (This is the same migration that <code>pyrefly init</code> would commit to disk.)</li> <li><strong>Basic preset for unconfigured projects</strong> — projects with no type checker config get the lightweight “basic” preset, which surfaces only high-confidence errors.</li> <li><strong>VS Code status bar</strong> — the status bar shows the active preset — e.g. Pyrefly (Basic) or Pyrefly (Legacy) — so you always know which mode is active.</li> <li><strong>Type error display settings</strong> — new VS Code settings let you control which preset applies to unconfigured files and suppress all diagnostics workspace-wide.</li> </ul> <hr /> <h2>Type Checker Improvements</h2> <p>We've been hard at work making the type checker robust and feature-complete, with a focus on driving down false positives and improving type quality in real-world code bases. Here are some highlights:</p> <ul> <li>Across the board we've eliminated many sources of false positives in enums, dataclasses, ParamSpec, descriptors, and more.</li> <li>Support has been added for more type narrowing patterns, including preserving narrows in nested scopes and recognizing container membership checks.</li> <li>Overload resolution was substantially reworked to handle more real-world patterns.</li> <li>Pyrefly’s conformance to the <a href=\"https://typing.readthedocs.io/en/latest/spec/\">Python typing specification</a> has improved from 70% at beta to over 90% today.</li> <li>We've added experimental support for tracking tensor dimensions through PyTorch models — see &quot;What's Next&quot; below.</li> </ul> <hr /> <h2>LSP &amp; IDE Improvements</h2> <ul> <li>We've added new refactoring capabilities like Safe Delete (with reference checking) and bulk <code>source.fixAll</code>.</li> <li>Navigation is more precise, and hover cards surface richer information for imports, tuples, and NamedTuples.</li> <li>Workspace mode is more stable, with multiple crash fixes and improved diagnostic publishing.</li> </ul> <hr /> <h2>Framework &amp; Notebook Support</h2> <ul> <li><strong>Django</strong> — Pyrefly has improved support for model relationships, fields, and views, and understands <a href=\"https://factoryboy.readthedocs.io/\">factory_boy</a> factories.</li> <li><strong>Pydantic</strong> — Pyrefly models Pydantic's runtime behavior more faithfully, with support for lax mode and range constraint validation, and handles more of the Pydantic ecosystem: <code>RootModel</code>, <code>pydantic-settings</code>, and <code>pydantic.dataclasses</code>.</li> <li><strong>Pytest integration</strong> — We've added Code Lens run buttons for test functions, as well as code actions to annotate fixture return types and parameters.</li> <li><strong>Jupyter notebooks</strong> — <code>.ipynb</code> IDE support has reached full parity with <code>.py</code> files, with rename, find references, code actions, and document symbols all supported.</li> </ul> <hr /> <h2>Complementary Tooling</h2> <p>Pyrefly ships with tools to aid with adopting type checking in an existing codebase. Two new tools since beta:</p> <ul> <li><a href=\"https://pyrefly.org/en/docs/report/\"><strong><code>pyrefly coverage report</code></strong></a> outputs a JSON report with annotation completeness and type completeness metrics per function, class, and module, so you can track coverage over time.</li> <li><a href=\"https://pyrefly.org/en/docs/error-suppressions/#baseline-files-experimental\"><strong>Baseline files</strong></a> let you snapshot current errors into a JSON file so only <em>new</em> errors are reported, as an alternative to inline suppression comments.</li> </ul> <hr /> <h2>Updated Version Policy</h2> <p>Going forward, we’ll switch from a weekly to monthly cadence for minor (<code>1.x.0</code>) releases, with patch releases in between as-needed for critical fixes. We’ll continue providing <a href=\"https://github.com/facebook/pyrefly/releases\">release notes</a> for minor versions, so you can see what’s new in each release.</p> <hr /> <h2>What's Next</h2> <ul> <li><strong>Tensor shape checking</strong> — Experimental support for tracking tensor dimensions through PyTorch models and catching shape mismatches statically. <a href=\"https://pyrefly.org/en/docs/tensor-shapes/\">Learn more</a>.</li> <li><strong>Pyrefly + AI agents</strong> — Pyrefly's speed makes it a natural verification step in agentic workflows. See our guide on <a href=\"https://pyrefly.org/blog/pyrefly-agentic-loop/\">adding Pyrefly to your agentic loop</a>.</li> <li><strong>Continued improvements</strong> — We'll keep expanding library support, reducing false positives, and iterating on your feedback. Let us know what you need on Github or <a href=\"https://discord.gg/Cf7mFQtW7W\">Discord</a>.</li> </ul> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/BeamMeUpBiscotti\"> /u/BeamMeUpBiscotti </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tbyd7m/ann_pyrefly_v10_fast_type_checker_language_server/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tbyd7m/ann_pyrefly_v10_fast_type_checker_language_server/\">[comments]</a></span>",
    "source": "https://www.reddit.com/r/python/.rss"
  },
  {
    "title": "Pyrefly v1.0.0 is here!",
    "link": "https://www.reddit.com/r/Python/comments/1tbxzza/pyrefly_v100_is_here/",
    "published": "2026-05-13T12:20:47+00:00",
    "summary": "<!-- SC_OFF --><div class=\"md\"><p>Python LSP server implementation &quot;Pyrefly&quot; has reached v1.0:</p> <p><a href=\"https://pyrefly.org/blog/v1.0/\">https://pyrefly.org/blog/v1.0/</a></p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/eszlari\"> /u/eszlari </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tbxzza/pyrefly_v100_is_here/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tbxzza/pyrefly_v100_is_here/\">[comments]</a></span>",
    "source": "https://www.reddit.com/r/python/.rss"
  },
  {
    "title": "Direct kernel input injection via Python uinput on Android (GPad2Mouse)",
    "link": "https://www.reddit.com/r/Python/comments/1tb5zb0/direct_kernel_input_injection_via_python_uinput/",
    "published": "2026-05-12T16:02:03+00:00",
    "summary": "<!-- SC_OFF --><div class=\"md\"><p>Many developers working with Android automation hit a wall when dealing with input latency. Standard accessibility overlays are too slow. The native solution is injecting events directly into <code>/dev/uinput</code> using Python, but it comes with a major hurdle: <strong>Kernel Struct Padding.</strong></p> <p>When using <code>struct.unpack</code>, 64-bit Android kernels expect a 24-byte event struct (<code>llHHi</code>). However, if you run the same Python script on older 32-bit devices (like Android TV Boxes), it expects a 16-byte struct (<code>IIHHi</code>). Failing to handle this dynamically using <code>sys.maxsize</code> causes instant crash errors.</p> <p>I've implemented a full working architecture for this concept into an open-source project called <strong>GPad2Mouse</strong>. </p> <p>Instead of just mapping keys, it uses Python's <code>fcntl.ioctl</code> to grab exclusive hardware control (<code>EVIOCGRAB</code>), reads <code>VID:PID</code> directly from <code>/sys/class/input/</code>, and dynamically calculates analog deadzones to prevent controller drift—all running as a daemon with 0% CPU overhead.</p> <p><strong>How to study the code?</strong> Due to sub rules against dropping external links, I won't post direct links here. But if you want to see the source code implementation or watch the video demonstration of how the kernel injection works in real-time: </p> <p>👉 Just Google search: <strong>GPad2Mouse</strong></p> <p>Has anyone else here worked extensively with <code>fcntl</code> on Android? I’d love to hear your approach on handling sudden device disconnections gracefully without freezing the read loop. Cheers!</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/Hungry-Advisor-5152\"> /u/Hungry-Advisor-5152 </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tb5zb0/direct_kernel_input_injection_via_python_uinput/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tb5zb0/direct_kernel_input_injection_via_python_uinput/\">[comments]</a></span>",
    "source": "https://www.reddit.com/r/python/.rss"
  },
  {
    "title": "A production-focused Python guide for working with Binance REST/WebSocket APIs",
    "link": "https://www.reddit.com/r/Python/comments/1tbt1nk/a_productionfocused_python_guide_for_working_with/",
    "published": "2026-05-13T08:04:11+00:00",
    "summary": "<!-- SC_OFF --><div class=\"md\"><p>I wrote a long-form guide about building Python applications around a high-volume public API, using Binance as the concrete example.</p> <p>The focus is less on trading and more on the engineering problems:</p> <p>- REST vs WebSocket architecture</p> <p>- reconnect handling</p> <p>- stream lifecycle observability</p> <p>- local cache correctness</p> <p>- order-book synchronization</p> <p>- avoiding hidden stale-state bugs in long-running services</p> <p>Disclosure: I maintain one of the Python libraries discussed in the article, so that perspective is included. The guide also compares python-binance, official Binance connectors, and CCXT.</p> <p>Feedback from Python developers working with WebSockets, APIs, or long-running data services would be useful:</p> <p><a href=\"https://blog.technopathy.club/the-complete-binance-python-api-guide-2026\">https://blog.technopathy.club/the-complete-binance-python-api-guide-2026</a></p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/oliver-zehentleitner\"> /u/oliver-zehentleitner </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tbt1nk/a_productionfocused_python_guide_for_working_with/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tbt1nk/a_productionfocused_python_guide_for_working_with/\">[comments]</a></span>",
    "source": "https://www.reddit.com/r/python/.rss"
  },
  {
    "title": "I tested structured output from 288 LLM calls and logged every way JSON breaks. Here's what I found",
    "link": "https://www.reddit.com/r/Python/comments/1tagc2g/i_tested_structured_output_from_288_llm_calls_and/",
    "published": "2026-05-11T21:00:01+00:00",
    "summary": "<!-- SC_OFF --><div class=\"md\"><p>I've been building Python services that consume LLM output for the past few years, and I kept accumulating the same pile of regex fixups for broken JSON in every project. Markdown fences, trailing commas, Python booleans inside JSON, truncated objects, unescaped quotes, the usual.</p> <p>Instead of keeping a private junk drawer of string manipulations, I decided to actually study the problem. Ran structured output prompts through 288 model calls across every major provider and catalogued what breaks, how often, and whether the failure modes are consistent across model families. (Spoiler: they are. Weirdly consistent.)</p> <p>Wrote it up here: <a href=\"https://thecrosswalk.news/what-breaks-when-you-ask-an-llm-for-json\">What Breaks When You Ask an LLM for JSON</a></p> <p>The article covers:</p> <ul> <li>A taxonomy of the 8 most common structured output failures</li> <li>Why the order you apply repairs in matters (this was the part that surprised me most)</li> <li>Why JSON mode helps but doesn't solve the problem</li> <li>What changes when you need to support YAML and TOML alongside JSON</li> </ul> <p>The findings eventually turned into a library (outputguard), but the article stands on its own if you just want to understand the failure modes. Curious if other people are seeing the same patterns.</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/kexxty\"> /u/kexxty </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tagc2g/i_tested_structured_output_from_288_llm_calls_and/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tagc2g/i_tested_structured_output_from_288_llm_calls_and/\">[comments]</a></span>",
    "source": "https://www.reddit.com/r/python/.rss"
  },
  {
    "title": "Library dependency version specifiers aren't for fixing vulnerabilities",
    "link": "https://www.reddit.com/r/Python/comments/1ta3zpz/library_dependency_version_specifiers_arent_for/",
    "published": "2026-05-11T13:53:40+00:00",
    "summary": "<!-- SC_OFF --><div class=\"md\"><p><a href=\"https://sethmlarson.dev/library-version-specifiers-not-for-vulnerabilities\">https://sethmlarson.dev/library-version-specifiers-not-for-vulnerabilities</a></p> <p>A blog post from Seth Larson, the Security-in-Residence Developer for the Python Software Foundation.</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/AlSweigart\"> /u/AlSweigart </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1ta3zpz/library_dependency_version_specifiers_arent_for/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1ta3zpz/library_dependency_version_specifiers_arent_for/\">[comments]</a></span>",
    "source": "https://www.reddit.com/r/python/.rss"
  },
  {
    "title": "Looking to connect with fellow Python developers and make friends in the community",
    "link": "https://www.reddit.com/r/Python/comments/1tarsl5/looking_to_connect_with_fellow_python_developers/",
    "published": "2026-05-12T05:14:54+00:00",
    "summary": "<!-- SC_OFF --><div class=\"md\"><p>Hey everyone,</p> <p>I’ve been learning and working with Python for a while and realized I also want to connect with more people in the community, make friends, collaborate on projects, and just talk tech/programming in general.</p> <p>Most of my learning has been solo, so I thought I’d post here and see if anyone else is interested in networking, building stuff together, sharing ideas, or even just chatting about Python and development.</p> <p>I’m also interested in hearing how you all met people in the programming world because sometimes it feels difficult to find genuine connections online.</p> <p>Would love to connect with fellow Python devs :)</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/Gentleman-45\"> /u/Gentleman-45 </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tarsl5/looking_to_connect_with_fellow_python_developers/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tarsl5/looking_to_connect_with_fellow_python_developers/\">[comments]</a></span>",
    "source": "https://www.reddit.com/r/python/.rss"
  },
  {
    "title": "[ Removed by Reddit ]",
    "link": "https://www.reddit.com/r/Python/comments/1tase87/removed_by_reddit/",
    "published": "2026-05-12T05:46:19+00:00",
    "summary": "<!-- SC_OFF --><div class=\"md\"><p>[ Removed by Reddit on account of violating the <a href=\"https://www.reddit.com/help/contentpolicy\">content policy</a>. ]</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/Fair-Kaleidoscope677\"> /u/Fair-Kaleidoscope677 </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tase87/removed_by_reddit/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tase87/removed_by_reddit/\">[comments]</a></span>",
    "source": "https://www.reddit.com/r/python/.rss"
  },
  {
    "title": "The US Is Winning the AI Race",
    "link": "https://avkcode.github.io/blog/us-winning-ai-race.html",
    "published": "Wed, 13 May 2026 13:53:53 +0000",
    "summary": "<p>Article URL: <a href=\"https://avkcode.github.io/blog/us-winning-ai-race.html\">https://avkcode.github.io/blog/us-winning-ai-race.html</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48121929\">https://news.ycombinator.com/item?id=48121929</a></p>\n<p>Points: 11</p>\n<p># Comments: 0</p>",
    "source": "https://hnrss.org/frontpage"
  },
  {
    "title": "Software Developers Say AI Is Rotting Their Brains",
    "link": "https://www.404media.co/software-developers-say-ai-is-rotting-their-brains/",
    "published": "Wed, 13 May 2026 13:34:51 +0000",
    "summary": "<p>Article URL: <a href=\"https://www.404media.co/software-developers-say-ai-is-rotting-their-brains/\">https://www.404media.co/software-developers-say-ai-is-rotting-their-brains/</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48121717\">https://news.ycombinator.com/item?id=48121717</a></p>\n<p>Points: 26</p>\n<p># Comments: 6</p>",
    "source": "https://hnrss.org/frontpage"
  },
  {
    "title": "Dutch suicide prevention website shares data with tech companies without consent",
    "link": "https://nltimes.nl/2026/05/13/dutch-suicide-prevention-hotline-shares-visitor-data-tech-companies",
    "published": "Wed, 13 May 2026 12:57:42 +0000",
    "summary": "<p>Article URL: <a href=\"https://nltimes.nl/2026/05/13/dutch-suicide-prevention-hotline-shares-visitor-data-tech-companies\">https://nltimes.nl/2026/05/13/dutch-suicide-prevention-hotline-shares-visitor-data-tech-companies</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48121299\">https://news.ycombinator.com/item?id=48121299</a></p>\n<p>Points: 80</p>\n<p># Comments: 33</p>",
    "source": "https://hnrss.org/frontpage"
  },
  {
    "title": "Why I'm leaving GitHub for Forgejo",
    "link": "https://jorijn.com/en/blog/leaving-github-for-forgejo/",
    "published": "Wed, 13 May 2026 12:54:00 +0000",
    "summary": "<p>Article URL: <a href=\"https://jorijn.com/en/blog/leaving-github-for-forgejo/\">https://jorijn.com/en/blog/leaving-github-for-forgejo/</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48121266\">https://news.ycombinator.com/item?id=48121266</a></p>\n<p>Points: 136</p>\n<p># Comments: 93</p>",
    "source": "https://hnrss.org/frontpage"
  },
  {
    "title": "Substrate (YC S24) Is Hiring a Technical Success Manager",
    "link": "https://www.ycombinator.com/companies/substrate/jobs/T2fMBhD-technical-success-manager",
    "published": "Wed, 13 May 2026 12:00:30 +0000",
    "summary": "<p>Article URL: <a href=\"https://www.ycombinator.com/companies/substrate/jobs/T2fMBhD-technical-success-manager\">https://www.ycombinator.com/companies/substrate/jobs/T2fMBhD-technical-success-manager</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48120776\">https://news.ycombinator.com/item?id=48120776</a></p>\n<p>Points: 0</p>\n<p># Comments: 0</p>",
    "source": "https://hnrss.org/frontpage"
  },
  {
    "title": "I Moved My Digital Stack to Europe",
    "link": "https://monokai.com/articles/how-i-moved-my-digital-stack-to-europe/",
    "published": "Wed, 13 May 2026 11:42:20 +0000",
    "summary": "<p>Article URL: <a href=\"https://monokai.com/articles/how-i-moved-my-digital-stack-to-europe/\">https://monokai.com/articles/how-i-moved-my-digital-stack-to-europe/</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48120629\">https://news.ycombinator.com/item?id=48120629</a></p>\n<p>Points: 374</p>\n<p># Comments: 261</p>",
    "source": "https://hnrss.org/frontpage"
  },
  {
    "title": "Using OR-Tools CP-SAT for Scheduling Problems",
    "link": "https://atalaykutlay.com/or-tools-cp-sat-for-scheduling-problems.html",
    "published": "Wed, 13 May 2026 11:02:57 +0000",
    "summary": "<p>Article URL: <a href=\"https://atalaykutlay.com/or-tools-cp-sat-for-scheduling-problems.html\">https://atalaykutlay.com/or-tools-cp-sat-for-scheduling-problems.html</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48120351\">https://news.ycombinator.com/item?id=48120351</a></p>\n<p>Points: 28</p>\n<p># Comments: 4</p>",
    "source": "https://hnrss.org/frontpage"
  },
  {
    "title": "Cost of enum-to-string: C++26 reflection vs. the old ways",
    "link": "https://vittorioromeo.com/index/blog/refl_enum_to_string.html",
    "published": "Wed, 13 May 2026 08:41:30 +0000",
    "summary": "<p>Article URL: <a href=\"https://vittorioromeo.com/index/blog/refl_enum_to_string.html\">https://vittorioromeo.com/index/blog/refl_enum_to_string.html</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48119326\">https://news.ycombinator.com/item?id=48119326</a></p>\n<p>Points: 33</p>\n<p># Comments: 22</p>",
    "source": "https://hnrss.org/frontpage"
  },
  {
    "title": "SecurityBaseline.eu",
    "link": "https://internetcleanup.foundation/2026/05/european-governments-3000-tracking-sites-1000-phpmyadmins-and-99pct-poorly-encrypted-email-introducing-securitybaseline-eu/",
    "published": "Wed, 13 May 2026 07:11:17 +0000",
    "summary": "<p>Article URL: <a href=\"https://internetcleanup.foundation/2026/05/european-governments-3000-tracking-sites-1000-phpmyadmins-and-99pct-poorly-encrypted-email-introducing-securitybaseline-eu/\">https://internetcleanup.foundation/2026/05/european-governments-3000-tracking-sites-1000-phpmyadmins-and-99pct-poorly-encrypted-email-introducing-securitybaseline-eu/</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48118763\">https://news.ycombinator.com/item?id=48118763</a></p>\n<p>Points: 200</p>\n<p># Comments: 100</p>",
    "source": "https://hnrss.org/frontpage"
  },
  {
    "title": "Deterministic Fully-Static Whole-Binary Translation Without Heuristics",
    "link": "https://arxiv.org/abs/2605.08419",
    "published": "Wed, 13 May 2026 04:25:03 +0000",
    "summary": "<p>Article URL: <a href=\"https://arxiv.org/abs/2605.08419\">https://arxiv.org/abs/2605.08419</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48117810\">https://news.ycombinator.com/item?id=48117810</a></p>\n<p>Points: 244</p>\n<p># Comments: 58</p>",
    "source": "https://hnrss.org/frontpage"
  }
]

Ejemplo clasificando la salida

import feedparser
import json

def fetch_rss(url, max_entries=20):
    """Devuelve una lista de ítems de un feed RSS/Atom."""
    feed = feedparser.parse(url)
    items = []
    for e in feed.entries[:max_entries]:
        items.append({
            "title": e.get("title", ""),
            "link": e.get("link", ""),
            "published": e.get("published", e.get("updated", "")),
            "summary": e.get("summary", e.get("description", "")),
        })
    return items

# 🔹 Lista de feeds RSS / Atom
urls = [
    "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada",
    "https://www.reddit.com/r/python/.rss",
    "https://hnrss.org/frontpage"
]

#  Diccionario: una clave por feed
feeds_data = {}

for url in urls:
    try:
        feeds_data[url] = fetch_rss(url, max_entries=10)
    except Exception as err:
        feeds_data[url] = [{"error": str(err)}]

#  Salida legible en Org / consola
print(json.dumps(feeds_data, indent=2, ensure_ascii=False))
{
  "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada": [
    {
      "title": "El viaje de Ayuso a México: las inconsistencias y exageraciones del relato de la presidenta madrileña",
      "link": "https://elpais.com/espana/madrid/2026-05-13/el-viaje-de-ayuso-a-mexico-las-inconsistencias-y-exageraciones-del-relato-de-la-presidenta-madrilena.html",
      "published": "Wed, 13 May 2026 08:41:59 GMT",
      "summary": "Ayuso, a su regreso a España, no aclara cuáles fueron las amenazas que recibió ni dónde estuvo durante más de dos días"
    },
    {
      "title": "Entre el intervencionismo y el narco partido: Morena y la oposición atizan el fuego",
      "link": "https://elpais.com/mexico/2026-05-13/entre-el-intervencionismo-y-el-narco-partido-morena-y-la-oposicion-atizan-el-fuego.html",
      "published": "Wed, 13 May 2026 04:00:00 GMT",
      "summary": "A un año de las elecciones, Chihuahua y Sinaloa se convierten en el eje de la contienda política con Maru Campos y Rubén Rocha en el banquillo"
    },
    {
      "title": "Mario Zamora: “En Sinaloa se cruzaron líneas rojas y el fuera de la ley se convirtió en la ley”",
      "link": "https://elpais.com/mexico/2026-05-13/mario-zamora-en-sinaloa-se-cruzaron-lineas-rojas-y-el-fuera-de-la-ley-se-convirtio-en-la-ley.html",
      "published": "Wed, 13 May 2026 04:00:00 GMT",
      "summary": "El excandidato del PRI sostiene que la elección de 2021 fue “un operativo criminal” y que la crisis de Sinaloa no es culpa solo de Rubén Rocha, sino de todo un sistema de poder construido por Morena"
    },
    {
      "title": "La CIA desmiente haber participado en un atentado en el Estado de México contra un operador del Cartel de Sinaloa",
      "link": "https://elpais.com/mexico/2026-05-13/la-cia-desmiente-haber-participado-en-un-atentado-en-el-estado-de-mexico-contra-un-operador-del-cartel-de-sinaloa.html",
      "published": "Wed, 13 May 2026 00:43:27 GMT",
      "summary": "El secretario de Seguridad mexicano, Omar García Harfuch, rechaza la investigación de CNN, y asegura que la cooperación con EE UU se da en términos de intercambio de inteligencia"
    },
    {
      "title": "Desde Del Río a Laredo: la travesía de siete migrantes que murieron sofocados en un tren de Union Pacific en Texas",
      "link": "https://elpais.com/us/migracion/2026-05-13/desde-del-rio-a-laredo-la-travesia-de-siete-migrantes-que-murieron-sofocados-en-un-tren-de-union-pacific-en-texas.html",
      "published": "Wed, 13 May 2026 04:00:00 GMT",
      "summary": "Entre los fallecidos estaban un adolescente de 14 años y una mujer mexicana que alertó a un familiar sobre el calor extremo dentro de la caja, informaron las autoridades"
    },
    {
      "title": "Afrontar las desapariciones en México",
      "link": "https://elpais.com/opinion/2026-05-13/afrontar-las-desapariciones-en-mexico.html",
      "published": "Wed, 13 May 2026 03:30:01 GMT",
      "summary": "Las organizaciones internacionales advierten de que la impunidad sigue siendo absoluta en este horror cotidiano"
    },
    {
      "title": "Consulados mexicanos: sin posibilidad de defensa",
      "link": "https://elpais.com/mexico/opinion/2026-05-13/consulados-mexicanos-sin-posibilidad-de-defensa.html",
      "published": "Wed, 13 May 2026 04:00:00 GMT",
      "summary": "El futuro de nuestras legaciones en suelo gringo nacerá del huevo de la temeridad"
    },
    {
      "title": "Por el bien de la educación, primero la política",
      "link": "https://elpais.com/mexico/opinion/2026-05-13/por-el-bien-de-la-educacion-primero-la-politica.html",
      "published": "Wed, 13 May 2026 04:00:00 GMT",
      "summary": "Qué jugada pretendía el Gobierno calando a la sociedad a ver si aceptaba un recorte del 15% de las clases con el pretexto del Mundial"
    },
    {
      "title": "La política del disimulo",
      "link": "https://elpais.com/mexico/opinion/2026-05-13/la-politica-del-disimulo.html",
      "published": "Wed, 13 May 2026 04:00:00 GMT",
      "summary": "Ocho años antes de asumir como gobernador de Sinaloa, Rubén Rocha Moya publicó una novela sobre el narcotráfico en el Estado en el que mandó"
    },
    {
      "title": "Donald Trump aterriza en China para una cumbre de alto voltaje con Xi Jinping",
      "link": "https://elpais.com/internacional/2026-05-13/donald-trump-aterriza-en-china-para-una-cumbre-de-alto-voltaje-con-xi-jinping.html",
      "published": "Wed, 13 May 2026 12:12:35 GMT",
      "summary": "Tras meses de tensiones, se espera que Pekín use el encuentro para trazar sus líneas rojas sobre Taiwán, mientras Washington subraya el carácter económico de la cita, y busca apoyo para negociar la paz con Irán"
    }
  ],
  "https://www.reddit.com/r/python/.rss": [
    {
      "title": "Sunday Daily Thread: What's everyone working on this week?",
      "link": "https://www.reddit.com/r/Python/comments/1t8r5sf/sunday_daily_thread_whats_everyone_working_on/",
      "published": "2026-05-10T00:00:12+00:00",
      "summary": "<!-- SC_OFF --><div class=\"md\"><h1>Weekly Thread: What's Everyone Working On This Week? 🛠️</h1> <p>Hello <a href=\"https://www.reddit.com/r/Python\">r/Python</a>! It's time to share what you've been working on! Whether it's a work-in-progress, a completed masterpiece, or just a rough idea, let us know what you're up to!</p> <h1>How it Works:</h1> <ol> <li><strong>Show &amp; Tell</strong>: Share your current projects, completed works, or future ideas.</li> <li><strong>Discuss</strong>: Get feedback, find collaborators, or just chat about your project.</li> <li><strong>Inspire</strong>: Your project might inspire someone else, just as you might get inspired here.</li> </ol> <h1>Guidelines:</h1> <ul> <li>Feel free to include as many details as you'd like. Code snippets, screenshots, and links are all welcome.</li> <li>Whether it's your job, your hobby, or your passion project, all Python-related work is welcome here.</li> </ul> <h1>Example Shares:</h1> <ol> <li><strong>Machine Learning Model</strong>: Working on a ML model to predict stock prices. Just cracked a 90% accuracy rate!</li> <li><strong>Web Scraping</strong>: Built a script to scrape and analyze news articles. It's helped me understand media bias better.</li> <li><strong>Automation</strong>: Automated my home lighting with Python and Raspberry Pi. My life has never been easier!</li> </ol> <p>Let's build and grow together! Share your journey and learn from others. Happy coding! 🌟</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/AutoModerator\"> /u/AutoModerator </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1t8r5sf/sunday_daily_thread_whats_everyone_working_on/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1t8r5sf/sunday_daily_thread_whats_everyone_working_on/\">[comments]</a></span>"
    },
    {
      "title": "Tuesday Daily Thread: Advanced questions",
      "link": "https://www.reddit.com/r/Python/comments/1takwge/tuesday_daily_thread_advanced_questions/",
      "published": "2026-05-12T00:00:08+00:00",
      "summary": "<!-- SC_OFF --><div class=\"md\"><h1>Weekly Wednesday Thread: Advanced Questions 🐍</h1> <p>Dive deep into Python with our Advanced Questions thread! This space is reserved for questions about more advanced Python topics, frameworks, and best practices.</p> <h2>How it Works:</h2> <ol> <li><strong>Ask Away</strong>: Post your advanced Python questions here.</li> <li><strong>Expert Insights</strong>: Get answers from experienced developers.</li> <li><strong>Resource Pool</strong>: Share or discover tutorials, articles, and tips.</li> </ol> <h2>Guidelines:</h2> <ul> <li>This thread is for <strong>advanced questions only</strong>. Beginner questions are welcome in our <a href=\"https://www.reddit.com/r/python/.rss#daily-beginner-thread-link\">Daily Beginner Thread</a> every Thursday.</li> <li>Questions that are not advanced may be removed and redirected to the appropriate thread.</li> </ul> <h2>Recommended Resources:</h2> <ul> <li>If you don't receive a response, consider exploring <a href=\"https://www.reddit.com/r/LearnPython\">r/LearnPython</a> or join the <a href=\"https://discord.gg/python\">Python Discord Server</a> for quicker assistance.</li> </ul> <h2>Example Questions:</h2> <ol> <li><strong>How can you implement a custom memory allocator in Python?</strong></li> <li><strong>What are the best practices for optimizing Cython code for heavy numerical computations?</strong></li> <li><strong>How do you set up a multi-threaded architecture using Python's Global Interpreter Lock (GIL)?</strong></li> <li><strong>Can you explain the intricacies of metaclasses and how they influence object-oriented design in Python?</strong></li> <li><strong>How would you go about implementing a distributed task queue using Celery and RabbitMQ?</strong></li> <li><strong>What are some advanced use-cases for Python's decorators?</strong></li> <li><strong>How can you achieve real-time data streaming in Python with WebSockets?</strong></li> <li><strong>What are the performance implications of using native Python data structures vs NumPy arrays for large-scale data?</strong></li> <li><strong>Best practices for securing a Flask (or similar) REST API with OAuth 2.0?</strong></li> <li><strong>What are the best practices for using Python in a microservices architecture? (..and more generally, should I even use microservices?)</strong></li> </ol> <p>Let's deepen our Python knowledge together. Happy coding! 🌟</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/AutoModerator\"> /u/AutoModerator </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1takwge/tuesday_daily_thread_advanced_questions/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1takwge/tuesday_daily_thread_advanced_questions/\">[comments]</a></span>"
    },
    {
      "title": "[Ann] Pyrefly v1.0 (fast type checker & language server)",
      "link": "https://www.reddit.com/r/Python/comments/1tbyd7m/ann_pyrefly_v10_fast_type_checker_language_server/",
      "published": "2026-05-13T12:36:22+00:00",
      "summary": "<!-- SC_OFF --><div class=\"md\"><p>Hi, Pyrefly maintainer here. Today we are pleased to share that <a href=\"https://pyrefly.org\">Pyrefly</a>, a fast type checker and language server for Python, has reached stable v1.0 status, meaning we are confident that <strong>Pyrefly is ready for production use</strong>.</p> <p>Pyrefly was first released as an alpha in mid-2025 and followed up with a beta in November of that year. Since then, we have shipped over 60 minor releases: fixing hundreds of bugs, adding the features you’ve been asking for, and improving performance to be one of the fastest tools out there.</p> <p>This would not have been possible without our amazing open-source community. To everyone who filed GitHub issues, submitted pull requests, gave us feedback at conferences, or joined us on Discord: thank you. Your contributions shaped this release, we’re grateful for every one of them, and we hope you continue being a part of the journey for future releases too.</p> <p>We've published a <a href=\"https://pyrefly.org/blog/v1.0/\">blog post</a> explaining what v1.0 means exactly, and what's next for Pyrefly.</p> <p>Below is a summary of the changes to Pyrefly since the Beta release. The full release notes for v1.0 can be read on our Github. </p> <h1>Pyrefly v1.0 Release Notes</h1> <h2>Performance Improvements</h2> <p>We've continued to push Pyrefly's performance since the <a href=\"https://pyrefly.org/blog/2026/02/06/performance-improvements/\">speed improvements we shared in February</a>. Since beta:</p> <ul> <li><strong>2–125x faster updated diagnostics</strong> after saving a file (no, that’s not a typo!). Thanks to fine-grained dependency tracking and streaming diagnostics, updates now consistently arrive in milliseconds</li> <li><strong>20–36% faster full type checking</strong> on large projects like PyTorch and Pandas</li> <li><strong>2–3x faster initial indexing</strong> when Pyrefly first scans your project</li> <li><strong>40–60% less memory usage</strong> during both indexing and incremental type checking</li> </ul> <p>(Tested on an M4 Macbook Pro using open-source benchmarks from <a href=\"https://github.com/lolpack/type_coverage_py\">type_coverage_py</a> and <a href=\"https://github.com/astral-sh/ruff/tree/e990dfd069fceef96f797b46161ef78862608449/scripts/ty_benchmark\">ty_benchmark</a>.)</p> <p>Compare the performance of Pyrefly and other Python type checkers on our regularly updated <a href=\"https://python-type-checking.com/typecheck_benchmark/\">benchmarking suite</a>, which runs against 53 popular Python packages.</p> <hr /> <h2>Configuration Presets</h2> <p>A new <code>preset</code> configuration option provides named bundles of error severities and behavior settings.</p> <table><thead> <tr> <th align=\"left\">Preset</th> <th align=\"left\">Description</th> </tr> </thead><tbody> <tr> <td align=\"left\"><code>off</code></td> <td align=\"left\">Silences all diagnostics. Useful for IDE-only users or if you want total control of which errors are enabled.</td> </tr> <tr> <td align=\"left\"><code>basic</code></td> <td align=\"left\">Low-noise, high-confidence diagnostics only (syntax errors, missing imports, unknown names, etc.). Ideal for unconfigured projects or IDE-first users.</td> </tr> <tr> <td align=\"left\"><code>legacy</code></td> <td align=\"left\">For codebases migrating from mypy. Disables checks mypy doesn't have. <code>pyrefly init</code> now emits this preset automatically when migrating from a mypy config.</td> </tr> <tr> <td align=\"left\"><code>default</code></td> <td align=\"left\">The standard Pyrefly experience. Equivalent to having no preset.</td> </tr> <tr> <td align=\"left\"><code>strict</code></td> <td align=\"left\">Enables additional strict checks on top of the <code>default</code> preset. For users who want to avoid <code>Any</code> types in their codebase.</td> </tr> </tbody></table> <p>See the <a href=\"https://pyrefly.org/en/docs/configuration/#preset\">configuration docs</a> for details.</p> <hr /> <h2>Onboarding Experience</h2> <p>We’ve made improvements to the out-of-the-box experience for projects without a <code>pyrefly.toml</code>.</p> <ul> <li><strong>Automatic config synthesis</strong> — if you have a mypy or pyright config, Pyrefly automatically migrates your settings and synthesizes an appropriate in-memory Pyrefly config. (This is the same migration that <code>pyrefly init</code> would commit to disk.)</li> <li><strong>Basic preset for unconfigured projects</strong> — projects with no type checker config get the lightweight “basic” preset, which surfaces only high-confidence errors.</li> <li><strong>VS Code status bar</strong> — the status bar shows the active preset — e.g. Pyrefly (Basic) or Pyrefly (Legacy) — so you always know which mode is active.</li> <li><strong>Type error display settings</strong> — new VS Code settings let you control which preset applies to unconfigured files and suppress all diagnostics workspace-wide.</li> </ul> <hr /> <h2>Type Checker Improvements</h2> <p>We've been hard at work making the type checker robust and feature-complete, with a focus on driving down false positives and improving type quality in real-world code bases. Here are some highlights:</p> <ul> <li>Across the board we've eliminated many sources of false positives in enums, dataclasses, ParamSpec, descriptors, and more.</li> <li>Support has been added for more type narrowing patterns, including preserving narrows in nested scopes and recognizing container membership checks.</li> <li>Overload resolution was substantially reworked to handle more real-world patterns.</li> <li>Pyrefly’s conformance to the <a href=\"https://typing.readthedocs.io/en/latest/spec/\">Python typing specification</a> has improved from 70% at beta to over 90% today.</li> <li>We've added experimental support for tracking tensor dimensions through PyTorch models — see &quot;What's Next&quot; below.</li> </ul> <hr /> <h2>LSP &amp; IDE Improvements</h2> <ul> <li>We've added new refactoring capabilities like Safe Delete (with reference checking) and bulk <code>source.fixAll</code>.</li> <li>Navigation is more precise, and hover cards surface richer information for imports, tuples, and NamedTuples.</li> <li>Workspace mode is more stable, with multiple crash fixes and improved diagnostic publishing.</li> </ul> <hr /> <h2>Framework &amp; Notebook Support</h2> <ul> <li><strong>Django</strong> — Pyrefly has improved support for model relationships, fields, and views, and understands <a href=\"https://factoryboy.readthedocs.io/\">factory_boy</a> factories.</li> <li><strong>Pydantic</strong> — Pyrefly models Pydantic's runtime behavior more faithfully, with support for lax mode and range constraint validation, and handles more of the Pydantic ecosystem: <code>RootModel</code>, <code>pydantic-settings</code>, and <code>pydantic.dataclasses</code>.</li> <li><strong>Pytest integration</strong> — We've added Code Lens run buttons for test functions, as well as code actions to annotate fixture return types and parameters.</li> <li><strong>Jupyter notebooks</strong> — <code>.ipynb</code> IDE support has reached full parity with <code>.py</code> files, with rename, find references, code actions, and document symbols all supported.</li> </ul> <hr /> <h2>Complementary Tooling</h2> <p>Pyrefly ships with tools to aid with adopting type checking in an existing codebase. Two new tools since beta:</p> <ul> <li><a href=\"https://pyrefly.org/en/docs/report/\"><strong><code>pyrefly coverage report</code></strong></a> outputs a JSON report with annotation completeness and type completeness metrics per function, class, and module, so you can track coverage over time.</li> <li><a href=\"https://pyrefly.org/en/docs/error-suppressions/#baseline-files-experimental\"><strong>Baseline files</strong></a> let you snapshot current errors into a JSON file so only <em>new</em> errors are reported, as an alternative to inline suppression comments.</li> </ul> <hr /> <h2>Updated Version Policy</h2> <p>Going forward, we’ll switch from a weekly to monthly cadence for minor (<code>1.x.0</code>) releases, with patch releases in between as-needed for critical fixes. We’ll continue providing <a href=\"https://github.com/facebook/pyrefly/releases\">release notes</a> for minor versions, so you can see what’s new in each release.</p> <hr /> <h2>What's Next</h2> <ul> <li><strong>Tensor shape checking</strong> — Experimental support for tracking tensor dimensions through PyTorch models and catching shape mismatches statically. <a href=\"https://pyrefly.org/en/docs/tensor-shapes/\">Learn more</a>.</li> <li><strong>Pyrefly + AI agents</strong> — Pyrefly's speed makes it a natural verification step in agentic workflows. See our guide on <a href=\"https://pyrefly.org/blog/pyrefly-agentic-loop/\">adding Pyrefly to your agentic loop</a>.</li> <li><strong>Continued improvements</strong> — We'll keep expanding library support, reducing false positives, and iterating on your feedback. Let us know what you need on Github or <a href=\"https://discord.gg/Cf7mFQtW7W\">Discord</a>.</li> </ul> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/BeamMeUpBiscotti\"> /u/BeamMeUpBiscotti </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tbyd7m/ann_pyrefly_v10_fast_type_checker_language_server/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tbyd7m/ann_pyrefly_v10_fast_type_checker_language_server/\">[comments]</a></span>"
    },
    {
      "title": "Pyrefly v1.0.0 is here!",
      "link": "https://www.reddit.com/r/Python/comments/1tbxzza/pyrefly_v100_is_here/",
      "published": "2026-05-13T12:20:47+00:00",
      "summary": "<!-- SC_OFF --><div class=\"md\"><p>Python LSP server implementation &quot;Pyrefly&quot; has reached v1.0:</p> <p><a href=\"https://pyrefly.org/blog/v1.0/\">https://pyrefly.org/blog/v1.0/</a></p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/eszlari\"> /u/eszlari </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tbxzza/pyrefly_v100_is_here/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tbxzza/pyrefly_v100_is_here/\">[comments]</a></span>"
    },
    {
      "title": "Direct kernel input injection via Python uinput on Android (GPad2Mouse)",
      "link": "https://www.reddit.com/r/Python/comments/1tb5zb0/direct_kernel_input_injection_via_python_uinput/",
      "published": "2026-05-12T16:02:03+00:00",
      "summary": "<!-- SC_OFF --><div class=\"md\"><p>Many developers working with Android automation hit a wall when dealing with input latency. Standard accessibility overlays are too slow. The native solution is injecting events directly into <code>/dev/uinput</code> using Python, but it comes with a major hurdle: <strong>Kernel Struct Padding.</strong></p> <p>When using <code>struct.unpack</code>, 64-bit Android kernels expect a 24-byte event struct (<code>llHHi</code>). However, if you run the same Python script on older 32-bit devices (like Android TV Boxes), it expects a 16-byte struct (<code>IIHHi</code>). Failing to handle this dynamically using <code>sys.maxsize</code> causes instant crash errors.</p> <p>I've implemented a full working architecture for this concept into an open-source project called <strong>GPad2Mouse</strong>. </p> <p>Instead of just mapping keys, it uses Python's <code>fcntl.ioctl</code> to grab exclusive hardware control (<code>EVIOCGRAB</code>), reads <code>VID:PID</code> directly from <code>/sys/class/input/</code>, and dynamically calculates analog deadzones to prevent controller drift—all running as a daemon with 0% CPU overhead.</p> <p><strong>How to study the code?</strong> Due to sub rules against dropping external links, I won't post direct links here. But if you want to see the source code implementation or watch the video demonstration of how the kernel injection works in real-time: </p> <p>👉 Just Google search: <strong>GPad2Mouse</strong></p> <p>Has anyone else here worked extensively with <code>fcntl</code> on Android? I’d love to hear your approach on handling sudden device disconnections gracefully without freezing the read loop. Cheers!</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/Hungry-Advisor-5152\"> /u/Hungry-Advisor-5152 </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tb5zb0/direct_kernel_input_injection_via_python_uinput/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tb5zb0/direct_kernel_input_injection_via_python_uinput/\">[comments]</a></span>"
    },
    {
      "title": "A production-focused Python guide for working with Binance REST/WebSocket APIs",
      "link": "https://www.reddit.com/r/Python/comments/1tbt1nk/a_productionfocused_python_guide_for_working_with/",
      "published": "2026-05-13T08:04:11+00:00",
      "summary": "<!-- SC_OFF --><div class=\"md\"><p>I wrote a long-form guide about building Python applications around a high-volume public API, using Binance as the concrete example.</p> <p>The focus is less on trading and more on the engineering problems:</p> <p>- REST vs WebSocket architecture</p> <p>- reconnect handling</p> <p>- stream lifecycle observability</p> <p>- local cache correctness</p> <p>- order-book synchronization</p> <p>- avoiding hidden stale-state bugs in long-running services</p> <p>Disclosure: I maintain one of the Python libraries discussed in the article, so that perspective is included. The guide also compares python-binance, official Binance connectors, and CCXT.</p> <p>Feedback from Python developers working with WebSockets, APIs, or long-running data services would be useful:</p> <p><a href=\"https://blog.technopathy.club/the-complete-binance-python-api-guide-2026\">https://blog.technopathy.club/the-complete-binance-python-api-guide-2026</a></p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/oliver-zehentleitner\"> /u/oliver-zehentleitner </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tbt1nk/a_productionfocused_python_guide_for_working_with/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tbt1nk/a_productionfocused_python_guide_for_working_with/\">[comments]</a></span>"
    },
    {
      "title": "I tested structured output from 288 LLM calls and logged every way JSON breaks. Here's what I found",
      "link": "https://www.reddit.com/r/Python/comments/1tagc2g/i_tested_structured_output_from_288_llm_calls_and/",
      "published": "2026-05-11T21:00:01+00:00",
      "summary": "<!-- SC_OFF --><div class=\"md\"><p>I've been building Python services that consume LLM output for the past few years, and I kept accumulating the same pile of regex fixups for broken JSON in every project. Markdown fences, trailing commas, Python booleans inside JSON, truncated objects, unescaped quotes, the usual.</p> <p>Instead of keeping a private junk drawer of string manipulations, I decided to actually study the problem. Ran structured output prompts through 288 model calls across every major provider and catalogued what breaks, how often, and whether the failure modes are consistent across model families. (Spoiler: they are. Weirdly consistent.)</p> <p>Wrote it up here: <a href=\"https://thecrosswalk.news/what-breaks-when-you-ask-an-llm-for-json\">What Breaks When You Ask an LLM for JSON</a></p> <p>The article covers:</p> <ul> <li>A taxonomy of the 8 most common structured output failures</li> <li>Why the order you apply repairs in matters (this was the part that surprised me most)</li> <li>Why JSON mode helps but doesn't solve the problem</li> <li>What changes when you need to support YAML and TOML alongside JSON</li> </ul> <p>The findings eventually turned into a library (outputguard), but the article stands on its own if you just want to understand the failure modes. Curious if other people are seeing the same patterns.</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/kexxty\"> /u/kexxty </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tagc2g/i_tested_structured_output_from_288_llm_calls_and/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tagc2g/i_tested_structured_output_from_288_llm_calls_and/\">[comments]</a></span>"
    },
    {
      "title": "Library dependency version specifiers aren't for fixing vulnerabilities",
      "link": "https://www.reddit.com/r/Python/comments/1ta3zpz/library_dependency_version_specifiers_arent_for/",
      "published": "2026-05-11T13:53:40+00:00",
      "summary": "<!-- SC_OFF --><div class=\"md\"><p><a href=\"https://sethmlarson.dev/library-version-specifiers-not-for-vulnerabilities\">https://sethmlarson.dev/library-version-specifiers-not-for-vulnerabilities</a></p> <p>A blog post from Seth Larson, the Security-in-Residence Developer for the Python Software Foundation.</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/AlSweigart\"> /u/AlSweigart </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1ta3zpz/library_dependency_version_specifiers_arent_for/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1ta3zpz/library_dependency_version_specifiers_arent_for/\">[comments]</a></span>"
    },
    {
      "title": "Looking to connect with fellow Python developers and make friends in the community",
      "link": "https://www.reddit.com/r/Python/comments/1tarsl5/looking_to_connect_with_fellow_python_developers/",
      "published": "2026-05-12T05:14:54+00:00",
      "summary": "<!-- SC_OFF --><div class=\"md\"><p>Hey everyone,</p> <p>I’ve been learning and working with Python for a while and realized I also want to connect with more people in the community, make friends, collaborate on projects, and just talk tech/programming in general.</p> <p>Most of my learning has been solo, so I thought I’d post here and see if anyone else is interested in networking, building stuff together, sharing ideas, or even just chatting about Python and development.</p> <p>I’m also interested in hearing how you all met people in the programming world because sometimes it feels difficult to find genuine connections online.</p> <p>Would love to connect with fellow Python devs :)</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/Gentleman-45\"> /u/Gentleman-45 </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tarsl5/looking_to_connect_with_fellow_python_developers/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tarsl5/looking_to_connect_with_fellow_python_developers/\">[comments]</a></span>"
    },
    {
      "title": "[ Removed by Reddit ]",
      "link": "https://www.reddit.com/r/Python/comments/1tase87/removed_by_reddit/",
      "published": "2026-05-12T05:46:19+00:00",
      "summary": "<!-- SC_OFF --><div class=\"md\"><p>[ Removed by Reddit on account of violating the <a href=\"https://www.reddit.com/help/contentpolicy\">content policy</a>. ]</p> </div><!-- SC_ON --> &#32; submitted by &#32; <a href=\"https://www.reddit.com/user/Fair-Kaleidoscope677\"> /u/Fair-Kaleidoscope677 </a> <br /> <span><a href=\"https://www.reddit.com/r/Python/comments/1tase87/removed_by_reddit/\">[link]</a></span> &#32; <span><a href=\"https://www.reddit.com/r/Python/comments/1tase87/removed_by_reddit/\">[comments]</a></span>"
    }
  ],
  "https://hnrss.org/frontpage": [
    {
      "title": "The US Is Winning the AI Race",
      "link": "https://avkcode.github.io/blog/us-winning-ai-race.html",
      "published": "Wed, 13 May 2026 13:53:53 +0000",
      "summary": "<p>Article URL: <a href=\"https://avkcode.github.io/blog/us-winning-ai-race.html\">https://avkcode.github.io/blog/us-winning-ai-race.html</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48121929\">https://news.ycombinator.com/item?id=48121929</a></p>\n<p>Points: 11</p>\n<p># Comments: 0</p>"
    },
    {
      "title": "Software Developers Say AI Is Rotting Their Brains",
      "link": "https://www.404media.co/software-developers-say-ai-is-rotting-their-brains/",
      "published": "Wed, 13 May 2026 13:34:51 +0000",
      "summary": "<p>Article URL: <a href=\"https://www.404media.co/software-developers-say-ai-is-rotting-their-brains/\">https://www.404media.co/software-developers-say-ai-is-rotting-their-brains/</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48121717\">https://news.ycombinator.com/item?id=48121717</a></p>\n<p>Points: 26</p>\n<p># Comments: 6</p>"
    },
    {
      "title": "Dutch suicide prevention website shares data with tech companies without consent",
      "link": "https://nltimes.nl/2026/05/13/dutch-suicide-prevention-hotline-shares-visitor-data-tech-companies",
      "published": "Wed, 13 May 2026 12:57:42 +0000",
      "summary": "<p>Article URL: <a href=\"https://nltimes.nl/2026/05/13/dutch-suicide-prevention-hotline-shares-visitor-data-tech-companies\">https://nltimes.nl/2026/05/13/dutch-suicide-prevention-hotline-shares-visitor-data-tech-companies</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48121299\">https://news.ycombinator.com/item?id=48121299</a></p>\n<p>Points: 80</p>\n<p># Comments: 33</p>"
    },
    {
      "title": "Why I'm leaving GitHub for Forgejo",
      "link": "https://jorijn.com/en/blog/leaving-github-for-forgejo/",
      "published": "Wed, 13 May 2026 12:54:00 +0000",
      "summary": "<p>Article URL: <a href=\"https://jorijn.com/en/blog/leaving-github-for-forgejo/\">https://jorijn.com/en/blog/leaving-github-for-forgejo/</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48121266\">https://news.ycombinator.com/item?id=48121266</a></p>\n<p>Points: 136</p>\n<p># Comments: 93</p>"
    },
    {
      "title": "Substrate (YC S24) Is Hiring a Technical Success Manager",
      "link": "https://www.ycombinator.com/companies/substrate/jobs/T2fMBhD-technical-success-manager",
      "published": "Wed, 13 May 2026 12:00:30 +0000",
      "summary": "<p>Article URL: <a href=\"https://www.ycombinator.com/companies/substrate/jobs/T2fMBhD-technical-success-manager\">https://www.ycombinator.com/companies/substrate/jobs/T2fMBhD-technical-success-manager</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48120776\">https://news.ycombinator.com/item?id=48120776</a></p>\n<p>Points: 0</p>\n<p># Comments: 0</p>"
    },
    {
      "title": "I Moved My Digital Stack to Europe",
      "link": "https://monokai.com/articles/how-i-moved-my-digital-stack-to-europe/",
      "published": "Wed, 13 May 2026 11:42:20 +0000",
      "summary": "<p>Article URL: <a href=\"https://monokai.com/articles/how-i-moved-my-digital-stack-to-europe/\">https://monokai.com/articles/how-i-moved-my-digital-stack-to-europe/</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48120629\">https://news.ycombinator.com/item?id=48120629</a></p>\n<p>Points: 374</p>\n<p># Comments: 261</p>"
    },
    {
      "title": "Using OR-Tools CP-SAT for Scheduling Problems",
      "link": "https://atalaykutlay.com/or-tools-cp-sat-for-scheduling-problems.html",
      "published": "Wed, 13 May 2026 11:02:57 +0000",
      "summary": "<p>Article URL: <a href=\"https://atalaykutlay.com/or-tools-cp-sat-for-scheduling-problems.html\">https://atalaykutlay.com/or-tools-cp-sat-for-scheduling-problems.html</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48120351\">https://news.ycombinator.com/item?id=48120351</a></p>\n<p>Points: 28</p>\n<p># Comments: 4</p>"
    },
    {
      "title": "Cost of enum-to-string: C++26 reflection vs. the old ways",
      "link": "https://vittorioromeo.com/index/blog/refl_enum_to_string.html",
      "published": "Wed, 13 May 2026 08:41:30 +0000",
      "summary": "<p>Article URL: <a href=\"https://vittorioromeo.com/index/blog/refl_enum_to_string.html\">https://vittorioromeo.com/index/blog/refl_enum_to_string.html</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48119326\">https://news.ycombinator.com/item?id=48119326</a></p>\n<p>Points: 33</p>\n<p># Comments: 22</p>"
    },
    {
      "title": "SecurityBaseline.eu",
      "link": "https://internetcleanup.foundation/2026/05/european-governments-3000-tracking-sites-1000-phpmyadmins-and-99pct-poorly-encrypted-email-introducing-securitybaseline-eu/",
      "published": "Wed, 13 May 2026 07:11:17 +0000",
      "summary": "<p>Article URL: <a href=\"https://internetcleanup.foundation/2026/05/european-governments-3000-tracking-sites-1000-phpmyadmins-and-99pct-poorly-encrypted-email-introducing-securitybaseline-eu/\">https://internetcleanup.foundation/2026/05/european-governments-3000-tracking-sites-1000-phpmyadmins-and-99pct-poorly-encrypted-email-introducing-securitybaseline-eu/</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48118763\">https://news.ycombinator.com/item?id=48118763</a></p>\n<p>Points: 200</p>\n<p># Comments: 100</p>"
    },
    {
      "title": "Deterministic Fully-Static Whole-Binary Translation Without Heuristics",
      "link": "https://arxiv.org/abs/2605.08419",
      "published": "Wed, 13 May 2026 04:25:03 +0000",
      "summary": "<p>Article URL: <a href=\"https://arxiv.org/abs/2605.08419\">https://arxiv.org/abs/2605.08419</a></p>\n<p>Comments URL: <a href=\"https://news.ycombinator.com/item?id=48117810\">https://news.ycombinator.com/item?id=48117810</a></p>\n<p>Points: 244</p>\n<p># Comments: 58</p>"
    }
  ]
}

Guardar en json

import feedparser
import json

def fetch_rss(url, max_entries=20):
    """Devuelve una lista de ítems de un feed RSS/Atom."""
    feed = feedparser.parse(url)
    items = []
    for e in feed.entries[:max_entries]:
        items.append({
            "title": e.get("title", ""),
            "link": e.get("link", ""),
            "published": e.get("published", e.get("updated", "")),
            "summary": e.get("summary", e.get("description", "")),
        })
    return items

# 🔹 Lista de feeds RSS / Atom
urls = [
    "https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/section/mexico/portada",
    "https://www.reddit.com/r/python/.rss",
    "https://hnrss.org/frontpage"
]

#  Diccionario: una clave por feed
feeds_data = {}

for url in urls:
    try:
        feeds_data[url] = fetch_rss(url, max_entries=10)
    except Exception as err:
        feeds_data[url] = [{"error": str(err)}]

        
with open("data.json", "w", encoding="utf-8") as f:
    json.dump(feeds_data, f, indent=2, ensure_ascii=False)
        
        
#  Salida legible en Org / consola
print("Salida del JSON")
Salida del JSON

Limpieza de texto (normalización para el IRS)

Los feeds RSS suelen incluir en título y resumen HTML (etiquetas <p>, <a>, etc.), mayúsculas/minúsculas mezcladas y signos de puntuación. Si el IRS busca la palabra “python” en el texto crudo, no encontrará “Python” ni “<em>python</em>”. La normalización (limpieza) hace que todos los documentos y la consulta compartan el mismo “alfabeto”: minúsculas, sin HTML, sin caracteres que no aporten para la búsqueda. Así la comparación consulta–documento es consistente y el modelo booleano (palabras exactas) y el vectorial (conteo de términos) pueden aplicarse sobre una representación uniforme del texto.

  • IRS: la normalización es una etapa típica de indexación; el índice se construye sobre el texto limpio, no sobre el raw.
  • RSS: los campos title y summary~/~description suelen venir en HTML; text_clean es la versión “indexable” de ese contenido.
  • Código: clean_text transforma una cadena en texto normalizado; normalize_items añade a cada documento de la colección el campo text_clean (título + resumen concatenados y limpios).

cleantext(text)~: (1) quita etiquetas HTML con re.sub(r"<[^>]+>", " ", text); (2) deja solo letras, números y espacios con re.sub(r"[^\w\s]", " ", text, flags=re.UNICODE); (3) colapsa espacios y pasa a minúsculas con .strip().lower(). El resultado es una sola cadena en minúsculas, sin HTML ni puntuación, lista para comparar con la consulta. normalize_items(items) recorre cada ítem de la colección, concatena título y resumen limpios en text_clean y modifica el ítem in-place. A partir de aquí, la recuperación (booleana o rankeada) usará text_clean, no el título/resumen originales. Así se garantiza que la búsqueda “python” coincida con “Python” en el feed.

import re

def clean_text(text):
    """Limpieza básica: quitar tags HTML, solo letras y espacios, minúsculas."""
    if not text:
        return ""
    text = re.sub(r"<[^>]+>", " ", text)
    text = re.sub(r"[^\w\s]", " ", text, flags=re.UNICODE)
    text = re.sub(r"\s+", " ", text).strip().lower()
    return text

def normalize_items(items):
    """Añade campo 'text_clean' a cada ítem (título + resumen limpios)."""
    for it in items:
        if "error" in it:
            continue
        title = it.get("title", "")
        summary = it.get("summary", "")
        it["text_clean"] = clean_text(title) + " " + clean_text(summary)
    return items

items_norm = normalize_items(items)
for i, it in enumerate(items_norm[:3]):
    if "error" not in it:
        print("--- Ítem", i+1, "---")
        print("text_clean:", it.get("text_clean", "")[:200], "...")
        print()
--- Ítem 1 ---
text_clean: el viaje de ayuso a méxico las inconsistencias y exageraciones del relato de la presidenta madrileña ayuso a su regreso a españa no aclara cuáles fueron las amenazas que recibió ni dónde estuvo durant ...

--- Ítem 2 ---
text_clean: entre el intervencionismo y el narco partido morena y la oposición atizan el fuego a un año de las elecciones chihuahua y sinaloa se convierten en el eje de la contienda política con maru campos y rub ...

--- Ítem 3 ---
text_clean: mario zamora en sinaloa se cruzaron líneas rojas y el fuera de la ley se convirtió en la ley el excandidato del pri sostiene que la elección de 2021 fue un operativo criminal y que la crisis de sinalo ...

Modelo booleano: búsqueda por palabras clave

En el modelo booleano de recuperación, cada documento es relevante o no relevante según cumpla o no una expresión lógica sobre los términos (AND, OR, NOT). No hay “más o menos relevante”: es un filtro. Es el modelo más directo para consultas del tipo “quiero ítems que contengan todas estas palabras”. Sobre la colección de ítems RSS normalizados (text_clean), una búsqueda AND equivale a: “devolver solo los documentos cuyo text_clean contiene cada palabra de la consulta”. Así el IRS aplica el proceso de recuperación: comparar la consulta (query) con cada documento de la colección y decidir sí/no según el criterio booleano.

  • IRS — Proceso de recuperación: la función que compara consulta y documentos implementa el modelo booleano (AND).
  • Consulta: la cadena que escribe el usuario (p. ej. “python software”) se normaliza igual que los documentos y se divide en palabras; cada palabra debe aparecer en text_clean.
  • RSS: cada ítem del feed es un documento; el campo text_clean (título + resumen limpios) es el texto sobre el que se hace la comparación.

searchbooleanand(items, query)~ recibe la colección (lista de ítems con text_clean) y la consulta en lenguaje natural. Primero se normaliza la consulta con clean_text(query).split(): mismas reglas que los documentos, para que “Python Software” y “python software” den las mismas palabras. Luego se recorre cada ítem: si text_clean contiene todas las palabras de la consulta (all(w in text for w in words)), el documento se considera relevante y se añade a results. La lista devuelta es el conjunto recuperado: documentos que pasan el filtro booleano. No hay orden de relevancia; si se quisiera ordenar, se usaría un modelo con score (p. ej. el ranking siguiente).

def search_boolean_and(items, query):
    """Recupera ítems donde text_clean contiene TODAS las palabras de query."""
    words = clean_text(query).split()
    results = []
    for it in items:
        if "error" in it:
            continue
        text = it.get("text_clean", "")
        if all(w in text for w in words):
            results.append(it)
    return results

query = "python software"
found = search_boolean_and(items_norm, query)
print("Consulta:", repr(query))
print("Resultados:", len(found))
for it in found[:5]:
    print("-", it.get("title", "")[:60])
Consulta: 'python software'
Resultados: 0

Ranking simple (modelo tipo vectorial)

El modelo booleano devuelve un conjunto sin orden: todos los documentos recuperados son “igual de relevantes”. En la práctica el usuario espera ver primero los más relevantes. El modelo vectorial (y variantes) asigna a cada documento un score de relevancia y ordena por ese score. Una simplificación muy usada es contar cuántas veces aparecen los términos de la consulta en el documento (term frequency, TF): más apariciones suelen indicar mayor relevancia. Así el IRS no solo filtra (sí/no) sino que rankea los resultados; la interfaz puede mostrar los top_k primeros. En feeds RSS, los ítems con más menciones de las palabras buscadas en título y resumen aparecen arriba.

  • IRS — Modelo de recuperación: aquí se implementa un ranking inspirado en el modelo vectorial (score por conteo de términos en text_clean).
  • Consulta: se normaliza y se divide en palabras; cada palabra contribuye al score del documento.
  • RSS: cada ítem tiene text_clean; el score es la suma de las frecuencias de los términos de la consulta en ese texto. Los ítems se ordenan por score descendente.

searchranked(items, query, topk)~ recibe la colección normalizada, la consulta y cuántos resultados devolver. Para cada ítem se calcula score como sum(text.split().count(w) for w in words): número total de apariciones de cualquier término de la consulta en text_clean. Solo se consideran documentos con score > 0. Se ordena la lista (score, ítem) por score descendente (key=lambda x: -x[0]) y se devuelven los primeros top_k ítems. Así la salida es una lista ordenada por relevancia: el primer elemento es el que más coincide con la consulta. Es una versión simplificada de TF (sin IDF ni normalización por longitud); suficiente para ilustrar el concepto de ranking en un IRS sobre feeds RSS.

def search_ranked(items, query, top_k=10):
    """Recupera ítems rankeados por número de apariciones de términos de la consulta."""
    words = clean_text(query).split()
    if not words:
        return []
    scored = []
    for it in items:
        if "error" in it:
            continue
        text = it.get("text_clean", "")
        score = sum(text.split().count(w) for w in words)
        if score > 0:
            scored.append((score, it))
    scored.sort(key=lambda x: -x[0])
    return [it for _, it in scored[:top_k]]

query2 = "python release"
ranked = search_ranked(items_norm, query2, top_k=5)
print("Consulta:", repr(query2))
for i, it in enumerate(ranked, 1):
    title = it.get("title", "")[:55]
    print(i, "-", title)
Consulta: 'python release'

Interfaz de resultados

En un IRS la interfaz es el medio por el que el usuario formula la consulta y ve los resultados. Sin una presentación clara de los documentos recuperados, el sistema no es usable. En un buscador web típico la interfaz muestra título, enlace, fecha y a veces un snippet. En nuestro flujo con feeds RSS y Org-mode, la “pantalla” de resultados puede ser una tabla Org: cada fila es un ítem recuperado (por búsqueda booleana o rankeada), con columnas como número de orden, título, URL y fecha. Así se cierra el ciclo IRS: colección → consulta → recuperación → presentación al usuario.

  • IRS — Interfaz: la tabla generada es la vista de resultados del sistema; el usuario ve qué documentos se recuperaron y puede acceder al enlace (RSS link) de cada ítem.
  • RSS: los campos mostrados (título, enlace, fecha) vienen directamente del feed; el orden de las filas viene del modelo de recuperación (ranking o filtro booleano).
  • Código: results_to_org_table transforma la lista de ítems (salida de search_ranked o search_boolean_and) en líneas de tabla Org.

resultstoorgtable(itemslist)~ recibe la lista de ítems ya recuperados (ordenados por score si vienen de search_ranked). Construye la cabecera de la tabla Org (| # | Título | Enlace | Fecha |) y el separador (|---+...). Para cada ítem añade una fila: posición (i), título truncado a 50 caracteres, link (URL del ítem en el feed) y fecha truncada a 20 caracteres. El resultado es una cadena con las líneas de la tabla; en Org-mode se puede insertar en el buffer o exportar. Así el documento Org actúa como “pantalla” del IRS: el usuario ve el listado rankeado de ítems RSS con enlaces clicables.

def results_to_org_table(items_list):
    """Genera líneas de tabla Org (| col1 | col2 | col3 |)."""
    lines = ["| # | Título | Enlace | Fecha |", "|---+--------+--------+-------|"]
    for i, it in enumerate(items_list, 1):
        if "error" in it:
            continue
        title = (it.get("title", "") or "")[:50]
        link = it.get("link", "")
        pub = (it.get("published", "") or "")[:20]
        lines.append(f"| {i} | {title} | {link} | {pub} |")
    return "\n".join(lines)

org_table = results_to_org_table(ranked)
print(org_table)
None

Pipeline completo del IRS con RSS

Un IRS real encadena varias etapas: obtener la colección, indexar/normalizar, recibir la consulta, aplicar el modelo de recuperación y presentar los resultados. Integrar todo en un pipeline (una sola función o flujo) permite ver de un vistazo cómo se relacionan los componentes del IRS con los feeds RSS: URL del feed → colección; consulta del usuario → proceso de recuperación; lista rankeada → interfaz (tabla). Así se evidencia que el IRS no es solo “búsqueda”, sino colección + consulta + modelo + interfaz.

Relación IRS ↔ feeds RSS en el pipeline

Componente IRS En el pipeline con RSS
Colección fetch_rss(feed_url) → ítems del feed
Normalización normalize_items(raw)text_clean por ítem
Consulta Parámetro query (cadena del usuario)
Modelo recuperación search_ranked(normalized, query, top_k)
Interfaz results_to_org_table(resultados) → tabla Org

Explicación del código

irs_pipeline(feed_url, query, max_entries, top_k) encadena: (1) fetch_rss descarga y parsea el feed y devuelve la lista de ítems (colección en bruto); (2) si hay error, se devuelve el mensaje; (3) normalize_items añade text_clean a cada ítem; (4) search_ranked aplica el modelo de recuperación (ranking por conteo de términos) y devuelve los top_k ítems más relevantes; (5) opcionalmente se pasa esa lista a results_to_org_table para generar la vista. Una sola llamada con URL de feed y consulta produce la tabla de resultados que el usuario vería en la interfaz. Así queda explícito el flujo completo: feed RSS → colección → normalización → consulta → ranking → presentación.

def irs_pipeline(feed_url, query, max_entries=20, top_k=5):
    """Pipeline: fetch → normalizar → búsqueda rankeada → resultados."""
    raw = fetch_rss(feed_url, max_entries=max_entries)
    if raw and "error" in raw[0]:
        return raw
    normalized = normalize_items(raw)
    results = search_ranked(normalized, query, top_k=top_k)
    return results

url_ejemplo = "https://pyfound.blogspot.com/feeds/posts/default?alt=rss"
consulta = "python foundation"
resultados = irs_pipeline(url_ejemplo, consulta, max_entries=15, top_k=5)
print("Feed:", url_ejemplo)
print("Consulta:", consulta)
print(results_to_org_table(resultados))
Feed: https://pyfound.blogspot.com/feeds/posts/default?alt=rss
Consulta: python foundation
| # | Título | Enlace | Fecha |
|---+--------+--------+-------|
| 1 | Announcing PSF Community Service Award Recipients! | https://pyfound.blogspot.com/2026/05/announcing-psf-community-service-award.html | Tue, 12 May 2026 15: |
| 2 | Strategic Planning at the PSF | https://pyfound.blogspot.com/2026/05/strategic-planning-at-psf.html | Mon, 11 May 2026 10: |
| 3 | Python is for Everyone: Inside the PSF's D&I Work  | https://pyfound.blogspot.com/2026/02/python-is-for-everyone-inside-psfs-d.html | Thu, 12 Feb 2026 12: |
| 4 | Introducing the PSF Community Partner Program | https://pyfound.blogspot.com/2026/02/introducing-psf-community-partner.html | Tue, 10 Feb 2026 13: |
| 5 | Applications to Join the PSF Meetup Pro Network Ar | https://pyfound.blogspot.com/2026/03/applications-to-join-psf-meetup-pro.html | Thu, 12 Mar 2026 12: |

Introducción a XPath

XPath (XML Path Language) es un lenguaje para localizar nodos y extraer valores en documentos XML y HTML. Se usa en transformaciones XSLT, scraping web, APIs y consultas a documentos estructurados.

¿Para qué sirve?

  • Navegar por la estructura de un árbol XML/HTML.
  • Seleccionar elementos por etiqueta, atributo, posición o relación jerárquica.
  • Extraer texto, atributos o fragmentos concretos.
  • Usarse desde Python (lxml, BeautifulSoup), JavaScript (document.evaluate), etc.

Documento de ejemplo

En esta parte usaremos este XML ficticio como referencia:

<?xml version="1.0" encoding="UTF-8"?>
<catalogo>
  <libro id="1" categoria="ficcion">
    <titulo>Don Quijote</titulo>
    <autor>Cervantes</autor>
    <precio moneda="EUR">12.50</precio>
    <anio>1605</anio>
  </libro>
  <libro id="2" categoria="tecnica">
    <titulo>Introducción a XPath</titulo>
    <autor>Ana García</autor>
    <precio moneda="USD">25.00</precio>
    <anio>2023</anio>
  </libro>
  <libro id="3" categoria="ficcion">
    <titulo>Cien años de soledad</titulo>
    <autor>García Márquez</autor>
    <precio moneda="EUR">14.00</precio>
    <anio>1967</anio>
  </libro>
</catalogo>

Sintaxis básica

Rutas absolutas y relativas

  • Ruta absoluta

    Empieza en la raíz del documento con /:

    /catalogo/libro
    

    Selecciona todos los elementos <libro> que son hijos directos de <catalogo>.

  • Ruta relativa

    Se evalúa desde el nodo actual (contexto):

    libro/titulo
    

    Desde el contexto actual, selecciona <titulo> dentro de <libro>.

Seleccionar por etiqueta

XPath Descripción
/catalogo Elemento raíz catalogo
/catalogo/libro Todos los libro hijos de catalogo
//libro Todos los libro en cualquier nivel
//titulo Todos los titulo en el documento
  • Diferencia entre / y //
    /
    Solo hijos directos (un nivel).
    //
    Descendientes en cualquier nivel.
    /catalogo/libro/titulo   → titulos que son nietos de catalogo (1 nivel)
    //titulo                 → todos los titulos del documento
    

Predicados

Los predicados [...] filtran nodos según una condición.

Por posición

XPath Descripción
/catalogo/libro[1] Primer libro (índice desde 1)
/catalogo/libro[last()] Último libro
/catalogo/libro[position()<=2] Los dos primeros
  • Nota sobre índices

    En XPath, los índices empiezan en 1, no en 0.

Por atributo

XPath Descripción
//libro[@id] Libros que tienen atributo id
//libro[@id"2"]= Libro con id"2"=
//libro[@categoria"ficcion"]= Libros de categoría ficción
//precio[@moneda"EUR"]= Precios en euros
//libro[@id>1] Libros con id numérico mayor que 1

Por contenido de texto

XPath Descripción
//titulo[text()"Don Quijote"]= Título con ese texto exacto
//titulo[contains(., "años")] Títulos que contienen "años"
//autor[starts-with(., "Gar")] Autores que empiezan por "Gar"
//libro[precio>13] Libros cuyo precio es mayor que 13

El punto . representa el nodo actual (el texto del elemento en muchos contextos).

Ejes

Los ejes definen la relación entre nodos. Sintaxis: eje::paso.

Ejes más usados

Eje Abreviatura Descripción
child:: (por defecto) Hijos directos
descendant:: // Descendientes (cualquier nivel)
parent:: ../ Padre
ancestor::   Antepasados
following-sibling::   Hermanos siguientes
preceding-sibling::   Hermanos anteriores
attribute:: @ Atributos

Ejemplos con ejes

/catalogo/child::libro          → igual que /catalogo/libro
//titulo/parent::libro          → libros que contienen un titulo
//libro[1]/following-sibling::libro  → hermanos siguientes del primer libro
//libro/@id                     → atributo id de cada libro

Funciones útiles

Funciones de texto

Función Uso Ejemplo
text() Texto del nodo //titulo/text()
normalize-space(s) Quita espacios extra normalize-space(//titulo[1])
contains(s, sub) ¿s contiene sub? //titulo[contains(., "Quijote")]
starts-with(s, pre) ¿s empieza por pre? //autor[starts-with(., "C")]
substring(s, i, n) Subcadena desde i, n chars substring(//titulo[1], 1, 3)
concat(s1, s2) Concatenar strings concat(//titulo[1], " - ", //autor[1])

Funciones numéricas

Función Uso
count(nodeset) Número de nodos
sum(nodeset) Suma de valores
position() Posición en el contexto
last() Última posición
count(//libro)           → 3
sum(//precio)            → 51.50 (si los precios son números)
//libro[position()=last()]

Funciones de tipo

Función Uso
name() Nombre de la etiqueta
local-name() Nombre local (sin prefijo)
string-length(s) Longitud de la cadena

Operadores y combinaciones

Operadores lógicos

//libro[@categoria="ficcion" and precio>12]
//libro[@categoria="ficcion" or @categoria="tecnica"]
//titulo[not(contains(., "años"))]

Unión de conjuntos (|)

//titulo | //autor

Devuelve todos los elementos titulo y autor.

Operadores de comparación

= = , =! , =< =, =< , => =, => =

XPath en HTML (scraping)

HTML no siempre es XML bien formado. Herramientas como lxml en Python pueden parsear HTML y aplicar XPath. Ejemplo:

from lxml import etree

html = """
<html>
  <body>
    <div class="articulo">
      <h1>Título del artículo</h1>
      <p>Párrafo uno.</p>
      <p>Párrafo dos con <a href="/enlace">enlace</a>.</p>
    </div>
    <div class="articulo">
      <h1>Otro artículo</h1>
      <p>Contenido.</p>
    </div>
  </body>
</html>
"""

tree = etree.HTML(html)

# Todos los h1
titulos = tree.xpath("//h1/text()")
print(titulos)  # ['Título del artículo', 'Otro artículo']

# h1 dentro de div con clase "articulo"
titulos_articulo = tree.xpath("//div[@class='articulo']/h1/text()")

# Atributo href de enlaces
enlaces = tree.xpath("//a/@href")
print(enlaces)  # ['/enlace']

# Segundo párrafo del primer articulo
segundo_p = tree.xpath("//div[@class='articulo'][1]/p[2]/text()")

XPath útiles para HTML común

//a/@href                    → todos los enlaces (URL)
//img/@src                   → todas las imágenes
//div[@class='contenido']    → divs con esa clase
//table//tr                  → filas de tablas
//input[@name='email']       → input por nombre
//*[@id='principal']         → elemento con id

XPath en Python (lxml)

Cargar XML desde archivo

from lxml import etree

tree = etree.parse("catalogo.xml")
root = tree.getroot()

# O desde string
xml_string = "<catalogo><libro><titulo>X</titulo></libro></catalogo>"
root = etree.fromstring(xml_string.encode("utf-8"))

Ejecutar XPath

# .xpath() devuelve lista de nodos o valores según la expresión
libros = root.xpath("//libro")
titulos = root.xpath("//titulo/text()")
precio_eur = root.xpath("//precio[@moneda='EUR']/text()")
primer_autor = root.xpath("//autor[1]/text()")[0]

Namespaces (XML con prefijos)

Si el XML usa namespaces (p. ej. xmlns), hay que registrarlos:

ns = {"rss": "http://purl.org/rss/1.0/"}
items = root.xpath("//rss:item/title/text()", namespaces=ns)

Ejercicios prácticos

Con el XML de ejemplo del catálogo, escribe el XPath que:

  • Ejercicio 1

    Obtenga el título del segundo libro.

    # Respuesta: /catalogo/libro[2]/titulo/text()
    
  • Ejercicio 2

    Obtenga todos los precios en euros.

    # Respuesta: //precio[@moneda='EUR']/text()
    
  • Ejercicio 3

    Obtenga los títulos de los libros de ficción.

    # Respuesta: //libro[@categoria='ficcion']/titulo/text()
    
  • Ejercicio 4

    Obtenga el autor del libro cuyo título contiene "años".

    # Respuesta: //libro[titulo[contains(., 'años')]]/autor/text()
    
  • Ejercicio 5

    Cuente cuántos libros hay en el catálogo.

    # Respuesta: count(//libro)
    

Referencia rápida

Símbolo / sintaxis Significado
/ Hijo directo / raíz
// Descendiente (cualquier nivel)
. Nodo actual
.. Padre
@ Atributo
[] Predicado (filtro)
= = Unión de conjuntos
text() Contenido de texto
* Cualquier elemento
@* Cualquier atributo

Dependencias

pip install lxml

Para HTML "suelto": lxml con etree.HTML() o BeautifulSoup con soporte lxml.

Ruta Crítica del Web Scraping y Aspectos de Seguridad

Introducción

Este apartado describe la ruta crítica para realizar web scraping: los pasos imprescindibles, orden lógico y las consideraciones de seguridad (tuyas y del sitio objetivo) que conlleva.

La ruta crítica: fases del proyecto de scraping

La ruta crítica es la secuencia de actividades que deben completarse en orden y cuyas demoras afectan directamente el tiempo total del proyecto.

Diagrama de flujo general

┌─────────────────┐
│ 1. LEGALIDAD    │ ¿Puedo hacerlo?
│ y ÉTICA         │
└────────┬────────┘
         │ Sí
         ▼
┌─────────────────┐
│ 2. ALTERNATIVAS │ ¿Existe API o descarga oficial?
│ (evitar scrape) │
└────────┬────────┘
         │ No hay alternativa adecuada
         ▼
┌─────────────────┐
│ 3. PLANIFICACIÓN│ ¿Qué datos? ¿Qué páginas? ¿Frecuencia?
│                 │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ 4. ANÁLISIS     │ Inspeccionar HTML, estructura, paginación
│ TÉCNICO         │ ¿Contenido estático o dinámico?
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ 5. DESARROLLO   │ Código: requests/lxml/selenium, etc.
│                 │ Manejo de errores, reintentos
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ 6. SEGURIDAD    │ Headers, rate limiting, proxies (si aplica)
│ Y ROBUSTEZ      │ Validación de datos, sanitización
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ 7. EJECUCIÓN    │ Monitoreo, logs, alertas
│ Y MANTENIMIENTO │ Adaptación a cambios del sitio
└─────────────────┘

Fase 1: Legalidad y ética (puerta de entrada)

Antes de escribir código

Verificación Pregunta Acción si falla
Términos de uso ¿El sitio prohíbe explícitamente el scraping? No proceder
robots.txt ¿Hay directivas que bloqueen tu user-agent o rutas? Respetar o no proceder
Propiedad intelectual ¿El contenido está protegido por derechos de autor? Evaluar uso justo / licencia
Datos personales ¿Vas a extraer datos que identifiquen personas? Cumplir RGPD/LFPDPPP y normativa local
robots.txt location ¿Dónde está? Revisar https://dominio.com/robots.txt

Ejemplo de robots.txt

User-agent: *
Disallow: /admin/
Disallow: /api/  # Puede indicar que hay API disponible
Allow: /public/

User-agent: BadBot
Disallow: /

Consecuencias de omitir esta fase

  • Demandas por violación de términos de servicio.
  • Bloqueo de IP o cuenta.
  • Responsabilidad por tratamiento indebido de datos personales.
  • Reputación y costes legales.

Fase 2: Buscar alternativas al scraping

Jerarquía de preferencia (orden recomendado)

  1. API oficial (Twitter, INEGI, Amazon Product Advertising API, etc.)
  2. RSS / feed XML
  3. JSON oculto en Network (pestaña Network del navegador: peticiones XHR/Fetch que devuelven JSON)
  4. HTML estático (requests + lxml)
  5. Contenido dinámico (Selenium, Playwright)

Nunca empezar con Selenium si no es necesario: es más lento y pesado. Ejemplos: Twitter/X → API oficial (plan básico limitado); Amazon → Product Advertising API para afiliados.

Antes de scrapear, preguntar

  1. ¿Existe una API oficial? (Google, Twitter, INEGI, etc.)
  2. ¿Hay datos abiertos o descarga masiva? (datos.gob.mx, Kaggle)
  3. ¿Ofrecen exportación o feed RSS/XML?
  4. ¿El sitio carga datos por JSON interno? (revisar pestaña Network)
  5. ¿Se puede contactar al propietario para solicitar acceso formal?

Motivos para preferir alternativas

  • Legalidad más clara.
  • Menor riesgo de bloqueo.
  • Datos más estructurados y estables.
  • Menos mantenimiento ante cambios del sitio.

Fase 3: Planificación

Definir alcance

Aspecto Preguntas
Objetivo ¿Qué datos exactos necesito?
Volumen ¿Cuántas páginas/registros?
Frecuencia ¿Una vez, diario, en tiempo real?
Formato salida CSV, JSON, base de datos
Tiempo estimado ¿Cuánto tardará una ejecución completa?

Estimar carga al servidor

\[ \text{peticiones/hora} \approx \frac{\text{páginas totales}}{\text{tiempo deseado (h)}} \times (1 + \text{margen reintentos}) \]

Mantener un ritmo razonable evita saturar el servidor y ser detectado como bot.

Fase 4: Análisis técnico

Inspeccionar el sitio

  • Herramientas de desarrollador (F12): estructura HTML, clases, IDs.
  • Network tab: ver peticiones XHR/Fetch (contenido dinámico).
  • Probar si requests + lxml devuelven el contenido visible en el navegador.

Contenido estático vs. dinámico

Tipo Cómo identificarlo Herramienta típica
Estático HTML completo en la respuesta GET requests + lxml
Dinámico Contenido cargado por JavaScript Selenium, Playwright

Mapear la estructura

  • URLs base y patrones de paginación.
  • Selectores XPath o CSS estables.
  • Formularios, cookies o cabeceras requeridas.

Detectando JSON oculto (Network)

Muchos sitios (incl. Amazon, tiendas, portales) cargan datos por peticiones internas a APIs que devuelven JSON. En lugar de parsear HTML:

  1. Abrir herramientas de desarrollador (F12) → pestaña Network.
  2. Filtrar por XHR o Fetch.
  3. Recargar la página y observar qué peticiones devuelven datos útiles.
  4. Copiar la URL de la petición y sus parámetros; replicar con requests.

Ventaja: JSON estructurado, más fácil de parsear y más estable que el HTML.

Fase 5: Desarrollo

Estructura recomendada del código

scraper/
├── config.py        # URLs, timeouts, headers
├── fetch.py         # Lógica de peticiones HTTP
├── parse.py         # Extracción con XPath/CSS
├── storage.py       # Guardar datos (CSV, DB)
├── main.py          # Orquestación
└── logs/            # Registro de ejecuciones

Manejo de errores

import time
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def session_con_reintentos():
    session = requests.Session()
    retries = Retry(total=3, backoff_factor=1, status_forcelist=[500, 502, 503])
    session.mount("https://", HTTPAdapter(max_retries=retries))
    return session

def fetch_con_pausa(url, session, delay=1):
    try:
        r = session.get(url, timeout=30)
        r.raise_for_status()
        time.sleep(delay)  # Rate limiting
        return r.content
    except requests.RequestException as e:
        # Registrar en log, no solo print
        raise

Fase 6: Seguridad y robustez

Seguridad de las peticiones

  • User-Agent e identificación
    • Identificarse de forma clara (no hacerse pasar por Googlebot si no lo eres).
    • Incluir un correo de contacto si el sitio lo sugiere.
    • Evitar user-agents de navegadores muy antiguos (suelen ser bloqueados).
    HEADERS = {
        "User-Agent": "MiProyecto/1.0 (estudio académico; +mail@ejemplo.com)",
        "Accept": "text/html,application/xhtml+xml",
        "Accept-Language": "es-ES,es;q=0.9",
    }
    
  • No exponer credenciales
    • Nunca hardcodear API keys, contraseñas o tokens.
    • Usar variables de entorno o archivos de configuración excluidos de control de versiones.
    import os
    API_KEY = os.environ.get("SCRAPER_API_KEY")
    if not API_KEY:
        raise ValueError("Configurar SCRAPER_API_KEY")
    
  • Validar y sanitizar datos de entrada
    • URLs, parámetros y consultas pueden venir de fuentes externas.
    • Evitar inyección o URLs maliciosas (redirecciones a dominios desconocidos).
    from urllib.parse import urlparse
    
    def url_permitida(url, dominios_permitidos):
        parsed = urlparse(url)
        return parsed.netloc in dominios_permitidos
    

Seguridad de los datos extraídos

  • Sanitización de salida
    • Limpiar HTML antes de guardar (evitar XSS si luego se muestran en una web).
    • Validar tipos y formatos (fechas, números, textos).
    import re
    def limpiar_texto(texto):
        if not isinstance(texto, str):
            return ""
        return re.sub(r"<[^>]+>", "", texto).strip()
    
  • Almacenamiento
    • No guardar datos personales innecesarios.
    • Cifrar almacenamiento si hay datos sensibles.
    • Respaldar solo lo necesario y cumplir normativa de retención.

Protección del sitio objetivo (ética técnica)

Práctica Objetivo
Rate limiting No saturar el servidor; pausas entre peticiones
Horarios valle Ejecutar en horas de menor tráfico si es posible
Cache Evitar repetir peticiones innecesarias
Respetar 429/503 Detener o ralentizar si el servidor pide esperar
No evadir bloqueos Si te bloquean, revisar si tu uso es adecuado

Proxies y escalado (consideraciones)

  • Proxies rotativos: mayor complejidad y coste; puede violar términos de uso.
  • Antes de usarlos: confirmar legalidad y política del sitio.
  • Preferir optimizar el ritmo antes que multiplicar IPs.

Fase 7: Ejecución y mantenimiento

Logging

import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
logger.info("Iniciando scraping de %s", url)
logger.warning("Reintento %d para %s", intento, url)
logger.error("Error en %s: %s", url, str(e))

Monitoreo básico

  • Tasa de éxito vs. errores.
  • Tiempo de respuesta y códigos HTTP.
  • Cambios en la estructura (parsers que dejan de funcionar).

Adaptación a cambios

  • Los sitios modifican HTML, clases y rutas.
  • Diseñar selectores robustos (evitar dependencias frágiles).
  • Revisar periódicamente que el scraper sigue funcionando.

Checklist de seguridad resumido

# Verificación Estado
1 Términos de uso y robots.txt revisados
2 Sin API/alternativa viable
3 Credenciales en variables de entorno
4 User-Agent identificable
5 Rate limiting / pausas implementados
6 Manejo de errores y reintentos
7 Validación de URLs y dominios permitidos
8 Sanitización de datos extraídos
9 Sin almacenar datos personales innecesarios
10 Logging y monitoreo básico

Riesgos y mitigaciones

Riesgo Mitigación
Bloqueo de IP Rate limiting, horarios valle, User-Agent adecuado
Cambio de estructura HTML Selectores resilientes, pruebas periódicas
Exposición de credenciales Variables de entorno, .gitignore
Datos personales Minimización, anonimización, cumplimiento legal
Caída del servidor objetivo Reintentos con backoff, detección de 429/503
Malware en respuestas Validar tipos MIME, no ejecutar código descargado
Sanciones legales Cumplir ToS, robots.txt y normativa aplicable

Referencias

  • Tutorial_Webscraping_XPath_Blogs.org: Técnicas de scraping con XPath, ejemplo likcos.
  • Fuentes_Datos_Gobierno_Abierto.org: INEGI, datos.gob.mx, clima, alternativas legales.
  • Tutorial_XPath.org: Sintaxis XPath y comparación XPath vs CSS.
  • robots.txt: https://dominio.com/robots.txt
  • RGPD / LFPDPPP: normativa aplicable a datos personales según jurisdicción.

Web Scraping con XPath para Blogs Estáticos

Introducción

Este apartado explica cómo hacer web scraping con XPath sobre blogs estáticos (HTML pre-renderizado en el servidor), qué consideraciones técnicas y éticas tener, y por qué sitios como Amazon no son adecuados para este enfoque.

Prerrequisitos

  • Python 3 con requests, lxml (o BeautifulSoup con parser lxml).
  • Conocimientos básicos de XPath (ver Tutorial_XPath.org).
  • Comprensión de la estructura HTML (etiquetas, clases, ids).

Blogs estáticos vs. sitios dinámicos

¿Qué es un blog estático?

Un blog estático sirve HTML ya generado por el servidor. Al hacer una petición GET, recibes el contenido completo en la respuesta. No necesitas ejecutar JavaScript para ver los artículos.

  • Características típicas
    • HTML con estructura predecible (artículos en <article>, <div class"post">=, etc.).
    • Paginación mediante URLs distintas (/page/2/, ?p=2).
    • Contenido público y pensado para ser indexado por buscadores.
    • Sin capchas agresivos ni medidas anti-bot fuertes (en muchos casos).

¿Por qué son adecuados para scraping?

Aspecto Blogs estáticos
Contenido HTML completo en la respuesta
Herramientas requests + lxml suficientes
Estructura Generalmente clara y consistente
Legalidad Más predecible si es uso personal
robots.txt Suele permitir acceso a contenido

Técnicas de scraping para blogs con XPath

Paso 1: Inspeccionar la estructura HTML

Antes de escribir XPath, abre el blog en el navegador y usa las herramientas de desarrollador (F12) para ver:

  • Contenedor de artículos: <article>, <div class"post">=, <div class"entry">=.
  • Título: <h1>, <h2>, <a class"title">=.
  • Autor, fecha, categorías.
  • Enlace al artículo completo: <a href"…">=.
  • Snippet o extracto: <p class"excerpt">=, <div class"summary">=.

Paso 2: Obtener el HTML

import requests
from lxml import etree

url = "https://ejemplo-blog.com/"
headers = {
    "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
}
response = requests.get(url, headers=headers)
response.raise_for_status()
html = response.content

Paso 3: Parsear y aplicar XPath

tree = etree.HTML(html)

# Ejemplos típicos para blogs (ajustar selectores según el sitio):
# Todos los artículos
articulos = tree.xpath("//article")
# o
articulos = tree.xpath("//div[contains(@class, 'post')]")

# Título del primer artículo
titulo = tree.xpath("//article[1]//h2/a/text()")[0].strip()

# Enlace al artículo
enlace = tree.xpath("//article[1]//h2/a/@href")[0]

# Fecha (si está en un elemento con clase 'date')
fecha = tree.xpath("//article[1]//time/@datetime")
fecha = fecha[0] if fecha else tree.xpath("//article[1]//*[contains(@class,'date')]/text()")

# Extracto o resumen
extracto = tree.xpath("//article[1]//div[contains(@class,'excerpt')]//text()")
extracto = " ".join(t.strip() for t in extracto if t.strip())

Paso 4: Extraer todos los artículos de una página

def extraer_articulos_pagina(html):
    tree = etree.HTML(html)
    articulos = tree.xpath("//article")
    resultado = []
    for art in articulos:
        titulo = art.xpath(".//h2/a/text() | .//h1/a/text() | .//.//a[contains(@class,'title')]/text()")
        enlace = art.xpath(".//h2/a/@href | .//h1/a/@href | .//a[contains(@class,'title')]/@href")
        extracto = art.xpath(".//div[contains(@class,'excerpt')]//text() | .//p[contains(@class,'summary')]//text()")
        resultado.append({
            "titulo": titulo[0].strip() if titulo else "",
            "enlace": enlace[0] if enlace else "",
            "extracto": " ".join(t.strip() for t in extracto if t.strip())[:200]
        })
    return resultado

Paso 5: Paginación

Muchos blogs usan URLs como /page/2/, /?paged=2, /blog?page=2:

import time

base_url = "https://ejemplo-blog.com/page/{}/"
for pagina in range(1, 6):  # páginas 1 a 5
    url = base_url.format(pagina)
    response = requests.get(url, headers=headers)
    articulos = extraer_articulos_pagina(response.content)
    for a in articulos:
        print(a["titulo"], a["enlace"])
    time.sleep(1)  # respetar el servidor: pausa entre peticiones

XPath útiles para blogs (resumen)

Objetivo XPath típico
Contenedor posts //article, //div[contains(@class,'post')]
Título .//h1/a/text(), .//h2/a/text()
Enlace .//h1/a/@href, .//a[contains(@class,'entry-link')]/@href
Fecha .//time/@datetime, .//*[contains(@class,'date')]/text()
Autor .//*[contains(@class,'author')]/text()
Categorías .//a[contains(@class,'category')]/text()
Extracto .//div[contains(@class,'excerpt')]//text()

Consideraciones éticas y legales

Buenas prácticas

  1. robots.txt: Revisar si el sitio prohíbe o limita el scraping.
  2. User-Agent: Identificarse de forma razonable (no hacerse pasar por bot de Google).
  3. Rate limiting: Hacer pausas entre peticiones (time.sleep(1) o más).
  4. Uso razonable: Evitar sobrecargar el servidor; respetar términos de uso.
  5. Atribución: Si redistribuyes contenido, cita la fuente.

Cuándo es más aceptable

  • Blogs personales o pequeños con contenido público.
  • Uso educativo o de investigación.
  • Agregadores de noticias que respetan robots.txt y términos de uso.
  • Contenido bajo licencias abiertas (CC, etc.).

Por qué NO es adecuado hacer scraping en Amazon (y similares)

1. Términos de uso y condiciones

Amazon (y la mayoría de grandes plataformas) prohíben explícitamente el scraping en sus condiciones de servicio. Hacerlo puede suponer:

  • Suspensión o cierre de cuenta.
  • Acciones legales.
  • Bloqueo de IP.

No es una limitación técnica, sino contractual y legal.

2. Contenido dinámico (JavaScript)

Amazon carga gran parte del contenido mediante JavaScript:

  • Listados de productos se actualizan con peticiones AJAX.
  • Precios, disponibilidad y valoraciones se insertan después del HTML inicial.
  • Una petición con requests devuelve HTML casi vacío respecto a lo que ves en el navegador.

Para scraping efectivo haría falta un navegador headless (Selenium, Playwright), lo que:

  • Es más complejo y pesado.
  • Genera más carga para el servidor.
  • Suele detectarse más fácilmente como bot.

3. Medidas anti-bot

Medida Descripción
Rate limiting Bloqueo temporal tras muchas peticiones
CAPTCHAs Verificaciones que rompen automatización
Fingerprinting Detección de patrones de navegación
IP blocking Bloqueo de IPs sospechosas
Contenido ofuscado Clases/ids aleatorios que cambian

4. Estructura cambiante

  • El HTML de Amazon cambia con frecuencia (A/B tests, rediseños).
  • Selectores XPath que funcionan hoy pueden dejar de hacerlo en semanas.
  • Mantener un scraper estable es costoso y frágil.

5. Alternativas legales y recomendadas

  • Amazon Product Advertising API: Para afiliados y partners autorizados.
  • Otros APIs oficiales: Muchas tiendas ofrecen APIs para precios, inventario, etc.
  • Bases de datos de terceros: Datasets académicos o comerciales de productos.

Resumen: Amazon vs. blog estático

Aspecto Blog estático Amazon (y similares)
Términos de uso Suele ser tolerable Prohibido explícitamente
Contenido HTML estático completo Mucho contenido dinámico
Anti-bot Escaso o inexistente Muy presente
Estructura Estable y predecible Cambia con frecuencia
Alternativas Scraping razonable Usar API oficial

Ejemplo completo: scraper para un blog estático

import requests
from lxml import etree
import time
import csv

def scrape_blog(base_url, num_paginas=3):
    """Extrae artículos de un blog estático con paginación /page/N/."""
    headers = {"User-Agent": "Mozilla/5.0 (compatible; BlogStudyBot/1.0)"}
    articulos = []
    for p in range(1, num_paginas + 1):
        url = f"{base_url.rstrip('/')}/page/{p}/" if p > 1 else base_url
        try:
            r = requests.get(url, headers=headers, timeout=10)
            r.raise_for_status()
            tree = etree.HTML(r.content)
            for art in tree.xpath("//article"):
                tit = art.xpath(".//h2/a/text() | .//h1/a/text()")
                lnk = art.xpath(".//h2/a/@href | .//h1/a/@href")
                ext = art.xpath(".//div[contains(@class,'excerpt')]//text()")
                articulos.append({
                    "titulo": tit[0].strip() if tit else "",
                    "enlace": lnk[0] if lnk else "",
                    "extracto": " ".join(t.strip() for t in ext if t.strip())[:300]
                })
        except Exception as e:
            print(f"Error en {url}: {e}")
        time.sleep(1)
    return articulos

# Uso (sustituir por un blog real que permita scraping):
# datos = scrape_blog("https://ejemplo-blog.com/", num_paginas=3)
# for a in datos:
#     print(a["titulo"], "->", a["enlace"])

Dependencias

pip install requests lxml

Referencias

  • Tutorial_XPath.org: Sintaxis y ejemplos de XPath.
  • robots.txt: Revisar https://dominio.com/robots.txt antes de scrapear.
  • APIs: Buscar si el sitio ofrece una API oficial antes de automatizar.

Fuentes de Datos Gubernamentales y Abiertos: INEGI, datos.gob.mx y más

Introducción

Instituciones gubernamentales publican datos abiertos bajo marcos legales que permiten su reutilización. Es preferible usar APIs oficiales o descargas directas en lugar de scraping cuando están disponibles: son legales, estables y eficientes.

Este apartado recopila fuentes como INEGI, datos.gob.mx y otras donde sí podemos obtener información de forma adecuada.

INEGI (México)

Qué es INEGI

El Instituto Nacional de Estadística y Geografía (INEGI) produce estadísticas y geografía de México. Ofrece varias formas de acceso programático a sus datos.

API del Banco de Indicadores

  • Registro y token
    • URL: https://www.inegi.org.mx/servicios/api_indicadores.html
    • Requiere registro para obtener un token.
    • Registro: https://www.inegi.org.mx/app/desarrolladores/registro/default.html
  • Parámetros típicos
    Parámetro Descripción Ejemplo
    IdIndicador Clave del indicador 1002000001 (Población total)
    Idioma es / en es
    Área geográfica 00=nacional, 99=entidad, 999=municipio 00
    Dato más reciente true / false false (serie histórica)
    Fuente BISE, etc. BISE
    Token Token de registro [tu token]
    Formato json, jsonp, xml json
  • Ejemplo en Python (API Indicadores)
    import requests
    
    TOKEN = "TU_TOKEN_AQUI"  # Obtener en inegi.org.mx/app/desarrolladores/registro
    # Indicador 1002000001 = Población total
    url = (
        f"https://www.inegi.org.mx/app/api/indicadores/desarrolladores/jsonxml/"
        f"INDICATOR/1002000001/es/00/false/BISE/2.0/{TOKEN}?type=json"
    )
    response = requests.get(url)
    data = response.json()
    # Estructura: data["Series"][0]["OBSERVATIONS"]
    for obs in data.get("Series", [{}])[0].get("OBSERVATIONS", []):
        print(obs["TIME_PERIOD"], obs["OBS_VALUE"])
    

API DENUE (Directorio Estadístico Nacional de Unidades Económicas)

  • URL: https://www.inegi.org.mx/servicios/api_denue.html
  • Busca establecimientos por palabra, actividad económica, ubicación, etc.
  • Requiere token.
  • Devuelve JSON con unidades económicas.
  • Ejemplo de consulta DENUE
    TOKEN = "TU_TOKEN"
    busqueda = "restaurante"
    entidad = "19"  # Nuevo León
    url = f"https://www.inegi.org.mx/app/api/denue/v1/consulta/buscar/{busqueda}/{entidad}/{TOKEN}"
    response = requests.get(url)
    establecimientos = response.json()
    

Datos Abiertos INEGI

  • URL: https://www.inegi.org.mx/datosabiertos/
  • Archivos en formatos estándar (CSV, Excel, etc.).
  • Descarga directa sin API; suele usarse para conjuntos grandes.

Descarga Masiva INEGI

  • URL: https://www.inegi.org.mx/app/descarga/
  • Permite seleccionar DENUE, Banco de Indicadores, Inventario Nacional de Viviendas, microdatos y tabulados por área geográfica, tema, período y formato.

Librería inegipy (Python)

pip install inegipy

Facilita el acceso al Banco de Indicadores, DENUE y Marco Geoestadístico. Documentación: https://pypi.org/project/inegipy/

datos.gob.mx (Plataforma Nacional de Datos Abiertos)

Qué es datos.gob.mx

Plataforma centralizada de datos abiertos del Gobierno de México. Incluye miles de bases de datos de dependencias federales, estatales y organismos autónomos.

  • URL: https://datos.gob.mx/
  • Más de 5 000 bases de datos.
  • Categorías: Economía, Educación, Salud, Cultura, Ciencia y Tecnología, etc.

API CKAN

La plataforma usa CKAN. Su API permite listar conjuntos, buscar y obtener URLs de descarga de recursos (CSV, Excel, etc.).

  • Endpoints útiles
    Acción URL Descripción
    Listar datasets /api/3/action/package_list IDs de todos los conjuntos
    Ver dataset /api/3/action/package_show?id=ID Metadatos y recursos
    Buscar /api/3/action/package_search?q=TERMINO Búsqueda por término
    Ver recurso /api/3/action/resource_show?id=ID Detalles de un recurso
  • Ejemplo: listar y buscar conjuntos de datos
    import requests
    
    BASE = "https://datos.gob.mx/api/3/action"
    
    # Listar todos los IDs de conjuntos
    r = requests.get(f"{BASE}/package_list")
    datasets = r.json().get("result", [])
    
    # Buscar por término
    r = requests.get(f"{BASE}/package_search", params={"q": "educación"})
    resultados = r.json().get("result", {}).get("results", [])
    for d in resultados[:5]:
        print(d["title"], "-", d.get("organization", {}).get("title"))
    
  • Ejemplo: obtener URL de descarga de un recurso
    # Supongamos que tenemos el id del dataset (ej. "catalogo-escuelas")
    dataset_id = "catalogo-escuelas"
    r = requests.get(f"{BASE}/package_show", params={"id": dataset_id})
    pkg = r.json().get("result", {})
    for res in pkg.get("resources", []):
        if res.get("format", "").upper() == "CSV":
            url_descarga = res["url"]
            print("Descargar CSV:", url_descarga)
            # response = requests.get(url_descarga)
            # with open("datos.csv", "wb") as f:
            #     f.write(response.content)
            break
    

Descarga directa de archivos

En la web de datos.gob.mx cada dataset tiene recursos con enlaces de descarga directa. Se puede usar la API para obtener esas URLs y luego requests.get() para descargar.

Otras fuentes gubernamentales de datos abiertos

México

Fuente URL Descripción
INEGI inegi.org.mx Estadísticas, indicadores, DENUE, geografía
datos.gob.mx datos.gob.mx Catálogo nacional de datos abiertos
SAT datos abiertos gob.mx/sat Información fiscal y aduanal
SEPOMEX sepomex.gob.mx Códigos postales (consultar términos de uso)

España

Fuente URL Descripción
datos.gob.es datos.gob.es Portal de datos abiertos de España

Internacional

Fuente URL Descripción
World Bank Open Data data.worldbank.org Indicadores mundiales
UN Data data.un.org Estadísticas de Naciones Unidas
Eurostat ec.europa.eu/eurostat Estadísticas europeas

Cuándo usar cada método

Método Cuándo usarlo Ejemplo
API oficial Existe y cubre tus necesidades INEGI Indicadores, DENUE
Descarga directa Archivos CSV/Excel en el portal datos.gob.mx, INEGI Datos Abiertos
API CKAN Listar, buscar, obtener URLs de recursos datos.gob.mx
Scraping Solo si no hay API ni descarga y el sitio lo permite Evitar en gobierno; preferir alternativas

Buenas prácticas

  1. Priorizar API y descargas oficiales: Son legales, estables y están pensadas para uso programático.
  2. Revisar términos de uso y licencias: Aunque sean datos abiertos, pueden haber condiciones.
  3. Identificarse: Usar un User-Agent razonable si haces peticiones HTTP.
  4. No saturar servidores: Respetar rate limits; hacer pausas entre peticiones masivas.
  5. Atribuir la fuente: Citar INEGI, datos.gob.mx, etc., al publicar o reutilizar datos.
  6. Protección de datos: Evitar extraer o combinar datos que permitan identificar personas sin base legal.

Ejemplo completo: indicador INEGI + guardar CSV

import requests
import csv

TOKEN = "TU_TOKEN"
# INPC - Índice Nacional de Precios al Consumidor (ejemplo)
indicador = "628193"  # Ajustar al indicador deseado
url = (
    f"https://www.inegi.org.mx/app/api/indicadores/desarrolladores/jsonxml/"
    f"INDICATOR/{indicador}/es/00/false/BISE/2.0/{TOKEN}?type=json"
)
r = requests.get(url)
data = r.json()

observaciones = data.get("Series", [{}])[0].get("OBSERVATIONS", [])
with open("indicador_inegi.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.writer(f)
    w.writerow(["periodo", "valor"])
    for obs in observaciones:
        w.writerow([obs["TIME_PERIOD"], obs["OBS_VALUE"]])
print(f"Guardados {len(observaciones)} registros")

Resumen de enlaces

  • INEGI API Indicadores: https://www.inegi.org.mx/servicios/api_indicadores.html
  • INEGI API DENUE: https://www.inegi.org.mx/servicios/api_denue.html
  • INEGI Datos Abiertos: https://www.inegi.org.mx/datosabiertos/
  • INEGI Descarga Masiva: https://www.inegi.org.mx/app/descarga/
  • Registro token INEGI: https://www.inegi.org.mx/app/desarrolladores/registro/default.html
  • datos.gob.mx: https://datos.gob.mx/
  • Constructor de Consultas INEGI: https://www.inegi.org.mx/app/querybuilder/

Dependencias

pip install requests inegipy

Actividades Introductorias - Recuperación de la Información

Introducción

Este conjunto de actividades tiene como objetivo desarrollar en el estudiante las habilidades básicas de recuperación de información:

  • Formulación de consultas
  • Identificación de relevancia
  • Detección de ruido
  • Evaluación de fuentes
  • Análisis crítico de resultados

No se requiere programación. El enfoque es conceptual y práctico.


1. Inteligencia Artificial en la vida diaria

1.1 Uso de la IA en la educación y el aprendizaje

  • Objetivo

    Analizar cómo cambia la recuperación de información según la formulación de la consulta.

  • Actividad
    1. Formular tres consultas:
      • General
      • Específica
      • Académica (Google Scholar)
    2. Recuperar al menos 8 resultados por consulta.
    3. Clasificar cada resultado:
      • Evidencia científica
      • Opinión
      • Publicidad
      • Experiencia personal
  • Producto

    Tabla comparativa de calidad de resultados por tipo de consulta.

  • Reflexión
    • ¿Qué consulta tuvo mayor precisión?
    • ¿Cuál generó más ruido?

1.2 IA generativa y creación de contenido

  • Objetivo

    nComparar sistemas generativos y sistemas de recuperación tradicionales.

  • Actividad
    1. Buscar información sobre "IA generativa".
    2. Formular la misma pregunta a ChatGPT.
    3. Comparar:
      • Fuentes citadas
      • Profundidad
      • Posibles omisiones
  • Producto

    Reporte comparativo de 1 cuartilla.

  • Conceptos clave

    Relevancia, ranking, síntesis vs recuperación.

1.3 Impacto de la IA en el empleo joven

  • Actividad
    1. Recuperar:
      • 3 noticias alarmistas
      • 2 estudios académicos
      • 2 fuentes estadísticas
    2. Clasificar el tipo de evidencia.
  • Reflexión

    ¿Predomina el sensacionalismo o la evidencia?

1.4 Ética, privacidad y regulación

  • Actividad

    Realizar búsqueda usando: site:.gob site:.edu site:.org

    Comparar resultados con blogs o medios comerciales.

  • Producto

    Mapa conceptual de fuentes oficiales vs no oficiales.

1.5 Herramientas de IA cotidianas

  • Actividad
    1. Identificar 5 herramientas de IA.
    2. Recuperar información sobre:
      • Uso de datos personales
      • Políticas de privacidad
    3. Clasificar riesgos.

2. Salud mental y bienestar en jóvenes

2.1 Ansiedad y estrés académico

  • Actividad

    Comparar resultados de:

    • "estrés estudiantes"
    • "estrés académico universitario estudio"
    • "academic stress prevalence Mexico"
  • Reflexión

    ¿Cómo cambia la calidad de los resultados?

2.2 Influencia de redes sociales

  • Actividad

    Buscar:

    • Impacto negativo de redes sociales
    • Impacto positivo de redes sociales

    Comparar narrativas y diversidad de fuentes.

2.3 Estrategias de autocuidado

  • Actividad

    Clasificar resultados en:

    • Científico
    • Comercial
    • Influencer
    • Pseudociencia

2.4 Salud mental universitaria

  • Actividad

    Recuperar programas de apoyo psicológico en universidades mexicanas.

    Analizar:

    • Visibilidad
    • Claridad de información
    • Accesibilidad

2.5 Programas y políticas públicas

  • Actividad

    Buscar: site:.gob.mx "salud mental jovenes"

    Evaluar cobertura y actualidad.

3. Algoritmos, poder y sociedad digital

3.1 Algoritmos de recomendación

  • Actividad

    Diferentes perfiles de búsqueda:

    • Noticias políticas A
    • Noticias políticas B

    Comparar resultados obtenidos.

3.2 Economía de la atención

  • Actividad

    Seleccionar un tema polémico. Comparar:

    • Título
    • Contenido
    • Palabras clave predominantes

3.3 Vigilancia y privacidad

  • Actividad

    Comparar búsquedas:

    • "vigilancia digital"
    • "surveillance capitalism"

    Analizar profundidad de resultados.

3.4 Plataformas digitales y control de la información

  • Actividad

    Buscar el mismo tema en:

    • Google
    • Bing
    • DuckDuckGo

    Comparar ranking y diversidad de fuentes.

3.5 Desinformación y fake news

  • Actividad principal: Detectives de información
    1. Se proporciona una noticia potencialmente dudosa.
    2. El estudiante debe:
      • Formular consultas alternativas
      • Recuperar evidencia
      • Verificar fecha y fuente original
    3. Clasificar evidencia como:
      • Confirmada
      • Engañosa
      • Falsa
      • Desactualizada
  • Producto final

    Informe argumentado con evidencia recuperada.

La Ventana de Overton

La Ventana de Overton es una teoría política que describe el rango de ideas que el público está dispuesto a aceptar en un momento dado. Las ideas fuera de la ventana se consideran "radicales" o "impensables".

El objetivo de la desinformación suele ser "empujar" la ventana para que algo que hoy nos parece una locura, mañana nos parezca normal.

Caso de Estudio: "La legalización de la carne humana"

Este es uno de los ejemplos más perturbadores y efectivos para analizar cómo se manipula la opinión pública mediante noticias falsas y debates provocados.

  1. La Noticia (El anzuelo)

En varios portales de noticias "alternativas" y redes sociales, empezaron a circular artículos afirmando que "Científicos y éticos sugieren que comer carne humana producida en laboratorio es la solución al cambio climático".

  1. El proceso de búsqueda y recuperación para los alumnos:

Paso 1: De lo Impensable a lo Radical. Aparecen noticias sobre una empresa ficticia o un artista que "sirve carne humana" en una cena privada. (Es falso, pero introduce la idea en el buscador de Google).

Paso 2: De lo Radical a lo Aceptable. Aparecen artículos de opinión (muchas veces financiados por bots) que dicen: "No es que queramos comer humanos, pero ¿no es peor que se muera el planeta?". Aquí el alumno debe buscar quién firma esos artículos.

Paso 3: De lo Aceptable a lo Sensato. Se recuperan noticias sobre "carne cultivada en laboratorio" (que es real) y se mezclan malintencionadamente con el tema del canibalismo para confundir al usuario.

  1. La Realidad (El Fact-Check)

No hay ninguna legislación ni movimiento científico serio promoviendo el canibalismo. Sin embargo, la noticia falsa se utiliza para:

Desacreditar la lucha contra el cambio climático (haciendo que parezca que los ecologistas son caníbales).

Generar clics por puro impacto visual y emocional.

Ejercicios: La Ventana de Overton

Introducción al Análisis de Desplazamiento de Opinión

Este documento contiene 10 casos de estudio sobre temas que están moviendo la "Ventana de Overton" en la esfera pública global. El objetivo es recuperar las fuentes originales y determinar la veracidad o manipulación de los datos presentados.

Casos de Estudio para Investigación Forense Digital

1. Implementación de Ciudades de 15 Minutos y Restricción de Movimiento

  • Premisa: Gobiernos europeos han comenzado a instalar vallas biométricas para impedir que los ciudadanos salgan de su radio de 15 minutos sin permiso digital previo.
  • Punto de búsqueda: Investigar regulaciones urbanísticas en Oxford y París (2023-2024).

2. El Crédito Social Obligatorio para Acceso a Internet

  • Premisa: La nueva Ley de Servicios Digitales incluye un sistema de puntos donde comentarios "negativos" bloquean el acceso al home banking del usuario de forma automática.
  • Punto de búsqueda: Analizar el texto de la Digital Services Act (DSA) de la Unión Europea.

3. Sustitución de Proteína Animal por Invertebrados en Comedores Escolares

  • Premisa: La normativa de la FAO para 2025 exige que el 30% de la harina en escuelas sea de origen insectoide para combatir el metano bovino.
  • Punto de búsqueda: Rastrear comunicados oficiales de la FAO y el Codex Alimentarius.

4. Identidad Digital de Recién Nacidos mediante Escaneo de Iris

  • Premisa: Un programa piloto en hospitales de Indonesia vincula la partida de nacimiento a una billetera digital biométrica desde el primer minuto de vida.
  • Punto de búsqueda: Investigar proyectos de ID2020 y alianzas tecnológicas en el sudeste asiático.

5. Impuesto Global al Carbono Individual (Monedero de CO2)

  • Premisa: Bancos centrales están probando tarjetas de crédito que se bloquean si el usuario supera su cuota mensual de emisiones de carbono al comprar carne o combustible.
  • Punto de búsqueda: Revisar las propuestas del Foro Económico Mundial sobre "Personal Carbon Allowances".

6. Legalización de la "Eutanasia por Cansancio Vital" en Menores

  • Premisa: Países Bajos y Canadá han extendido sus protocolos médicos para permitir el procedimiento en adolescentes con cuadros de eco-ansiedad severa sin consentimiento parental.
  • Punto de búsqueda: Recuperar las leyes MAiD (Canadá) y el Protocolo de Groningen.

7. Sensores de "Ruido Social" en Hogares Inteligentes

  • Premisa: Las nuevas normativas de Smart Homes exigen que los asistentes de voz reporten palabras clave de "discurso de odio" directamente a las autoridades locales.
  • Punto de búsqueda: Analizar términos y condiciones de dispositivos IoT y patentes recientes de procesamiento de lenguaje natural.

8. La Prohibición de la Propiedad Privada de Vehículos para 2030

  • Premisa: Un acuerdo firmado en la COP28 establece que la fabricación de coches particulares cesará en 2028, permitiendo solo el modelo de "suscripción por uso" estatal.
  • Punto de búsqueda: Consultar las resoluciones finales de la COP28 y el plan "Fit for 55".

9. Uso de IA para la Reescritura de Textos Históricos en Tiempo Real

  • Premisa: Bibliotecas digitales están utilizando algoritmos para "adaptar" el lenguaje de clásicos literarios a estándares de sensibilidad modernos sin dejar rastro del original.
  • Punto de búsqueda: Investigar casos de edición en obras de Roald Dahl y Agatha Christie (2023).

10. Geoingeniería: El Bloqueo Solar mediante Aerosoles de Azufre

  • Premisa: Un proyecto financiado por magnates tecnológicos ha comenzado a liberar partículas en la estratósfera para oscurecer el sol y bajar 1.5°C la temperatura global este verano.
  • Punto de búsqueda: Rastrear el proyecto SCoPEx de Harvard y su estado de ejecución actual.

Guía de Recuperación para el Alumno

  1. Localizar la fuente primaria (documento legal, paper científico o comunicado oficial).
  2. Identificar si el contenido recuperado coincide con la premisa o si hay una distorsión semántica.
  3. Evaluar en qué fase de la Ventana de Overton se encuentra el tema:
    • [ ] Impensable
    • [ ] Radical
    • [ ] Aceptable
    • [ ] Sensato
    • [ ] Popular
    • [ ] Política Pública

Similitud de coseno y TF‑IDF en Python

1. Representar textos como vectores

  • Bolsa de palabras (Bag of Words): cada documento se representa por cuántas veces aparece cada palabra.
    • Ventaja: sencillo.
    • Desventaja: trata todas las palabras como igual de importantes (un "el" cuenta tanto como "inteligencia").
    • TF‑IDF mejora esta idea ponderando las palabras según:
    • TF (Term Frequency): qué tan frecuente es una palabra en un documento.
    • IDF (Inverse Document Frequency): qué tan rara es esa palabra en toda la colección de documentos.

2. TF (Term Frequency)

Para una palabra \(t\) en un documento \(d\):

\[ TF(t, d) = \frac{\text{número de veces que aparece } t \text{ en } d}{\text{número total de palabras en } d} \]

En la práctica, muchas librerías usan variantes (por ejemplo, solo el conteo bruto).

3. IDF (Inverse Document Frequency)

Para una palabra \(t\) en una colección de documentos \(D\) (con \(|D|\) documentos) y \(df(t)\) = número de documentos donde aparece \(t\):

\[ IDF(t, D) = \log \left( \frac{|D|}{1 + df(t)} \right) \]

  • Si una palabra aparece en muchos documentos, \(df(t)\) es grande y el IDF es pequeño → la palabra aporta poca información.
  • Si una palabra aparece en pocos documentos, \(df(t)\) es pequeño y el IDF es grande → la palabra aporta mucha información.

4. TF‑IDF

El peso TF‑IDF de una palabra \(t\) en un documento \(d\) se define como:

\[ TF\text{-}IDF(t, d, D) = TF(t, d) \cdot IDF(t, D) \]

Cada documento se convierte en un vector donde cada componente es el TF‑IDF de una palabra del vocabulario.

Imagina que tienes muchos documentos (noticias, artículos, tareas, etc.) y quieres saber de qué habla cada uno y cuáles se parecen entre sí.

4.1. TF: qué tanto se repite una palabra en un documento

Piensa en un solo documento.

TF (Term Frequency) responde a la pregunta:

  • "¿Cuántas veces aparece esta palabra aquí dentro?"

Ejemplo sencillo:

  • Si en un texto de 100 palabras, la palabra "gato" aparece 5 veces, su TF es 5 de 100 (o simplemente 5, según la variante de la fórmula).

Cuanto más aparece una palabra en ese documento, más importante parece para ese documento.

4.2. El problema de las palabras que no dicen nada

Palabras como "el", "la", "de", "y" aparecen en casi todos los textos y no nos dicen de qué trata el documento.

Si solo miráramos TF, "el" podría parecer súper importante, pero no queremos eso.

4.3. IDF: qué tan rara es una palabra en toda la colección

Ahora miramos todos los documentos juntos.

IDF (Inverse Document Frequency) responde:

  • "¿En cuántos documentos aparece esta palabra?"

Ideas clave:

  • Si una palabra aparece en muchísimos documentos, su IDF es bajo → es poco informativa.
  • Si aparece en pocos documentos, su IDF es alto → es muy informativa (característica de ciertos textos).

Es como decir:

  • Palabra muy común ⇒ "no cuenta tanto".
  • Palabra rara ⇒ "esto describe algo específico".

4.4. TF‑IDF: combinar las dos ideas

TF‑IDF = TF × IDF

  • TF dice: qué tanto se usa la palabra dentro de este documento.
  • IDF dice: qué tan rara o especial es esa palabra en toda la colección.

Al multiplicar:

  • Una palabra que se repite mucho en este documento y casi no aparece en otros ⇒ peso alto (muy representativa de este texto).
  • Una palabra que se repite mucho pero aparece en todos lados (como "el") ⇒ peso bajo.

Al final, cada documento se convierte en un vector (una lista de números), donde cada número es el TF‑IDF de una palabra:

  • cuanto más grande el número, más describe esa palabra al documento.

4n.5. ¿Para qué sirve en la práctica?

  • Para que un buscador sepa qué documentos son más relevantes a una consulta.
  • Para medir similitud entre textos (por ejemplo, usando después la similitud de coseno).
  • Para que una máquina vea el texto como números significativos y no solo como palabras sueltas.

5. Similitud de coseno

Dado dos vectores \(A\) y \(B\) (por ejemplo, los TF‑IDF de dos documentos), la similitud de coseno es:

\[ \text{coseno}(A, B) = \frac{A \cdot B}{\|A\| \cdot \|B\|} \]

  • \(A \cdot B\): producto punto (suma de multiplicaciones componente a componente).
  • \(\|A\|\): norma (longitud) del vector.
  • Resultado:
    • 1 → vectores muy parecidos (documentos muy similares).
    • 0 → vectores ortogonales (sin relación).
    • Valores negativos suelen no aparecer con TF‑IDF estándar (porque los pesos son no negativos).

6. Ejemplo en Python: cargar varios .txt, calcular TF‑IDF y similitud de coseno

Este ejemplo usa scikit‑learn para calcular TF‑IDF y similitudes entre varios documentos de texto.

6.1. Requisitos

Instala las dependencias:

pip install scikit-learn

Organiza tus textos, por ejemplo:

  • Carpeta textos/ con archivos:
    • doc1.txt
    • doc2.txt
    • doc3.txt

6.2. Código básico

from pathlib import Path

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity


def cargar_documentos(directorio: str):
    """Carga todos los .txt de un directorio y devuelve rutas y contenidos."""
    base_path = Path(directorio)
    filepaths = sorted(base_path.glob("*.txt"))

    documentos = []
    nombres = []

    for path in filepaths:
        texto = path.read_text(encoding="utf-8", errors="ignore")
        documentos.append(texto)
        nombres.append(path.name)

    return nombres, documentos


def calcular_tfidf(documentos):
    """Calcula la matriz TF-IDF a partir de una lista de textos."""
    vectorizador = TfidfVectorizer(
        lowercase=True,      # pasar todo a minúsculas
        stop_words="spanish" # eliminar stopwords en español (siempre opcional)
    )
    matriz_tfidf = vectorizador.fit_transform(documentos)
    return matriz_tfidf, vectorizador


def matriz_similitud_coseno(matriz_tfidf):
    """Devuelve la matriz de similitud de coseno entre todos los documentos."""
    return cosine_similarity(matriz_tfidf)


if __name__ == "__main__":
    # 1. Cargar documentos
    nombres, documentos = cargar_documentos("/home/likcos/txt")

    # 2. Calcular TF-IDF
    matriz_tfidf, vectorizador = calcular_tfidf(documentos)

    # 3. Calcular similitud de coseno entre todos los pares de documentos
    sim_matrix = matriz_similitud_coseno(matriz_tfidf)

    # 4. Mostrar resultados
    print("Documentos cargados:")
    for i, nombre in enumerate(nombres):
        print(f"{i}: {nombre}")

    print("\nMatriz de similitud de coseno:")
    print(sim_matrix)

    # Ejemplo: similitud entre el documento 0 y el 1
    if len(nombres) >= 2:
        print(
            f"\nSimilitud de coseno entre '{nombres[0]}' y '{nombres[1]}': "
            f"{sim_matrix[0, 1]:.4f}"
        )

Código con stop word añadidas

from pathlib import Path

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

STOP_WORDS_ES = [
    "a", "acá", "ahí", "al", "algo", "algún", "alguna", "algunas", "alguno",
    "algunos", "allá", "allí", "ante", "antes", "aquel", "aquella", "aquellas",
    "aquello", "aquellos", "aquí", "así", "aún", "bajo", "bastante", "bien",
    "cabe", "cada", "casi", "como", "con", "contra", "cual", "cuales",
    "cualquier", "cualquiera", "cualquieras", "cuán", "cuándo", "cuánto",
    "de", "dejar", "del", "demás", "demasiado", "donde", "dos", "el", "él",
    "ella", "ellas", "ellos", "en", "entre", "era", "eran", "eres", "es",
    "esa", "esas", "ese", "eso", "esos", "esta", "está", "están", "estas",
    "este", "esto", "estos", "estoy", "fue", "fueron", "fui", "ha", "han",
    "hasta", "hay", "la", "las", "le", "les", "lo", "los", "más", "me",
    "mi", "mis", "mucha", "muchas", "mucho", "muchos", "muy", "nada",
    "ni", "no", "nos", "nosotros", "nuestra", "nuestras", "nuestro",
    "nuestros", "o", "os", "otra", "otras", "otro", "otros", "para",
    "pero", "poco", "por", "porque", "que", "qué", "quien", "quién",
    "quienes", "se", "sea", "según", "ser", "si", "sí", "sin", "sobre",
    "so", "somos", "son", "su", "sus", "también", "tan", "tanto", "te",
    "tenemos", "tener", "tiene", "tienen", "toda", "todas", "todavía",
    "todo", "todos", "tu", "tus", "un", "una", "uno", "unos", "usted",
    "ustedes", "ya", "yo"
]

def cargar_documentos(directorio: str):
    base_path = Path(directorio)
    filepaths = sorted(base_path.glob("*.txt"))

    documentos = []
    nombres = []

    for path in filepaths:
        texto = path.read_text(encoding="utf-8", errors="ignore")
        documentos.append(texto)
        nombres.append(path.name)

    return nombres, documentos


def calcular_tfidf(documentos):
    vectorizador = TfidfVectorizer(
        lowercase=True,
        stop_words=STOP_WORDS_ES  # aquí pasamos la lista
    )
    matriz_tfidf = vectorizador.fit_transform(documentos)
    return matriz_tfidf, vectorizador


def matriz_similitud_coseno(matriz_tfidf):
    return cosine_similarity(matriz_tfidf)


if __name__ == "__main__":
    nombres, documentos = cargar_documentos("/home/likcos/txt")
    matriz_tfidf, vectorizador = calcular_tfidf(documentos)
    sim_matrix = matriz_similitud_coseno(matriz_tfidf)

    print("Documentos cargados:")
    for i, nombre in enumerate(nombres):
        print(f"{i}: {nombre}")

    print("\nMatriz de similitud de coseno:")
    print(sim_matrix)

    if len(nombres) >= 2:
        print(
            f"\nSimilitud de coseno entre '{nombres[0]}' y '{nombres[1]}': "
            f"{sim_matrix[0, 1]:.4f}"
        )

Ejemplo con nltk

from pathlib import Path

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

from nltk.corpus import stopwords


def cargar_documentos(directorio: str):
    base_path = Path(directorio)
    filepaths = sorted(base_path.glob("*.txt"))

    documentos = []
    nombres = []

    for path in filepaths:
        texto = path.read_text(encoding="utf-8", errors="ignore")
        documentos.append(texto)
        nombres.append(path.name)

    return nombres, documentos


def calcular_tfidf(documentos):
    stopwords_es = stopwords.words("spanish")

    vectorizador = TfidfVectorizer(
        lowercase=True,
        stop_words=stopwords_es
    )
    matriz_tfidf = vectorizador.fit_transform(documentos)
    return matriz_tfidf, vectorizador


def matriz_similitud_coseno(matriz_tfidf):
    return cosine_similarity(matriz_tfidf)


if __name__ == "__main__":
    nombres, documentos = cargar_documentos("textos")
    matriz_tfidf, vectorizador = calcular_tfidf(documentos)
    sim_matrix = matriz_similitud_coseno(matriz_tfidf)

    print("Documentos cargados:")
    for i, nombre in enumerate(nombres):
        print(f"{i}: {nombre}")

    print("\nMatriz de similitud de coseno:")
    print(sim_matrix)

    if len(nombres) >= 2:
        print(
            f"\nSimilitud de coseno entre '{nombres[0]}' y '{nombres[1]}': "
            f"{sim_matrix[0, 1]:.4f}"
        )

7. Similitud de coseno “a mano” (opcional)

Para entender mejor el algoritmo, se puede calcular la similitud de coseno sin usar cosine_similarity:

import numpy as np


def similitud_coseno_manual(vec_a, vec_b):
    """Calcula la similitud de coseno entre dos vectores de NumPy."""
    numerador = np.dot(vec_a, vec_b)
    norma_a = np.linalg.norm(vec_a)
    norma_b = np.linalg.norm(vec_b)

    if norma_a == 0 or norma_b == 0:
        return 0.0  # si algún vector es cero, definimos similitud 0

    return numerador / (norma_a * norma_b)

Con la matriz TF‑IDF de scikit‑learn (que es una matriz dispersa), podrías convertir una fila a vector denso para probar:

vec0 = matriz_tfidf[0].toarray()[0]
vec1 = matriz_tfidf[1].toarray()[0]

print(similitud_coseno_manual(vec0, vec1))

8. Caso de uso típico: búsqueda de documentos similares

Podemos usar TF‑IDF y similitud de coseno para, dado un documento (o una consulta de texto), encontrar los documentos más parecidos.

import numpy as np


def documentos_mas_similares(idx_objetivo, sim_matrix, nombres, top_k=3):
    """
    Devuelve los índices y nombres de los documentos más similares
    a un documento objetivo.
    """
    similitudes = sim_matrix[idx_objetivo]

    # Ordenar por similitud descendente (ignorando el propio documento)
    indices_ordenados = np.argsort(-similitudes)

    resultados = []
    for idx in indices_ordenados:
        if idx == idx_objetivo:
            continue
        resultados.append((idx, nombres[idx], similitudes[idx]))
        if len(resultados) >= top_k:
            break

    return resultados


if __name__ == "__main__":
    # ... reutilizar código anterior para cargar nombres, documentos,
    # matriz_tfidf y sim_matrix ...

    if len(nombres) > 0:
        idx_objetivo = 0  # por ejemplo, el primer documento
        similares = documentos_mas_similares(idx_objetivo, sim_matrix, nombres, top_k=3)

        print(f"\nDocumentos más similares a '{nombres[idx_objetivo]}':")
        for idx, nombre, score in similares:
            print(f"- {nombre}: similitud = {score:.4f}")
  • TF‑IDF transforma textos en vectores numéricos que destacan las palabras más informativas.
  • La similitud de coseno mide cuánto se parecen dos documentos según el ángulo entre sus vectores.
  • Con scikit‑learn, podemos:
    • Cargar múltiples archivos .txt.
    • Calcular su representación TF‑IDF.
    • Obtener la similitud de coseno entre todos los documentos y buscar los más parecidos.

10. ¿Dónde se usa TF‑IDF y la similitud de coseno?

TF‑IDF y la similitud de coseno se utilizan ampliamente en tareas de recuperación de información y minería de texto:

  • Motores de búsqueda: las consultas y documentos se representan como vectores TF‑IDF y se ordenan por similitud de coseno para mostrar primero los resultados más relevantes.
  • Búsqueda de documentos similares: dado un documento, se buscan otros con alta similitud de coseno (por ejemplo, artículos científicos o noticias relacionadas).
  • Sistemas de recomendación basados en contenido: se comparan descripciones de ítems (productos, películas, cursos) con lo que el usuario ha visto, usando similitud de coseno sobre vectores TF‑IDF.
  • Clustering y clasificación de textos: los vectores TF‑IDF sirven como entrada a algoritmos de agrupamiento o clasificación para organizar grandes colecciones de documentos.
  • Detección de plagio: se comparan trabajos de estudiantes o artículos para medir si comparten demasiado contenido (similitud de coseno alta).
  • Filtrado de spam y categorización de correos: los correos se representan con TF‑IDF y se clasifican; también puede medirse la similitud con ejemplos de spam conocidos.
  • Sistemas de FAQ simples: se representa cada pregunta frecuente con TF‑IDF y, ante una nueva consulta, se devuelve la respuesta asociada a la pregunta más similar según la similitud de coseno.

Modelo HSV (Hue, Saturation, Value)

El modelo HSV es una representación más intuitiva del color basada en la percepción humana, donde el matiz, la saturación y el valor describen un color.

  • Características:
    • Matiz (Hue, H): Representa el ángulo en el círculo cromático, en grados [0°, 360°).
    • Saturación (S): Indica la pureza del color, rango de [0,1].
    • Valor (V): Define el brillo del color, rango de [0,1].
  • Conversión de RGB a HSV:
    • Primero, normalizar los valores RGB entre 0 y 1: \[ R' = \frac{R}{L}, \quad G' = \frac{G}{L}, \quad B' = \frac{B}{L} \]

      donde \( L \) es el valor máximo de intensidad (por ejemplo, 255).

    • Calcular el valor máximo y mínimo: \[ C_{\max} = \max(R', G', B'), \quad C_{\min} = \min(R', G', B') \]
    • Diferencia: \[ \Delta = C_{\max} - C_{\min} \]
    • Cálculo del Matiz (H): \[ \text{Si } \Delta = 0 \Rightarrow H = 0 \\ \text{Si } C_{\max} = R' \Rightarrow H = 60^\circ \times \left( \frac{G' - B'}{\Delta} \mod 6 \right) \\ \text{Si } C_{\max} = G' \Rightarrow H = 60^\circ \times \left( \frac{B' - R'}{\Delta} + 2 \right) \\ \text{Si } C_{\max} = B' \Rightarrow H = 60^\circ \times \left( \frac{R' - G'}{\Delta} + 4 \right) \]
    • Cálculo de la Saturación (S): \[ \text{Si } C_{\max} = 0 \Rightarrow S = 0 \\ \text{Si no} \Rightarrow S = \frac{\Delta}{C_{\max}} \]
    • Cálculo del Valor (V): \[ V = C_{\max} \]
  • Aplicaciones:
    • Edición de imágenes, interfaces de selección de color y en procesamiento de video.
  • Ejemplo:
    • Convertir RGB (255, 255, 0) a HSV:
      • Normalizar: \[ R' = 1, \quad G' = 1, \quad B' = 0 \]
      • \( C_{\max} = 1 \), \( C_{\min} = 0 \), \( \Delta = 1 \)
      • Calcular H: \[ H = 60^\circ \times \left( \frac{G' - B'}{\Delta} \mod 6 \right) = 60^\circ \times (1 \mod 6) = 60^\circ \]
      • Calcular S: \[ S = \frac{\Delta}{C_{\max}} = \frac{1}{1} = 1 \]
      • Calcular V: \[ V = C_{\max} = 1 \]
      • Resultado: H = 60°, S = 1, V = 1 (Color amarillo)

HSV opencv

Tutorial: Umbrales en el Modelo de Color HSV

El espacio de color HSV (Hue, Saturation, Value) se utiliza para la segmentación de colores en OpenCV. A continuación, explicaremos los tres parámetros principales y cómo definir umbrales para detectar colores.

Definición de los Parámetros del Modelo HSV

  • Hue (H): Tono o color básico (rojo, verde, azul, etc.). En OpenCV va de 0 a 179.
  • Saturation (S): Intensidad del color. Va de 0 a 255.
  • Value (V): Brillo del color. Va de 0 a 255.

Detectar un color en el espacio HSV

Puedes definir un rango de color utilizando los valores de Hue, Saturation y Value para segmentar colores específicos en una imagen.

Código en Python para detectar un color (Verde)

import cv2
import numpy as np

# Leer la imagen
img = cv2.imread('man.jpg')

# Convertir la imagen al espacio de color HSV
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)

# Definir el rango inferior y superior para detectar verde
lower_green = np.array([35, 60, 60])  # Hue, Saturación, Brillo mínimos
upper_green = np.array([85, 255, 255])  # Hue, Saturación, Brillo máximos


# 4. Contar píxeles blancos (non-zero)


# Crear una máscara que solo incluya los píxeles dentro del rango
mask = cv2.inRange(hsv, lower_green, upper_green)

pixel_blanco = cv2.countNonZero(mask)
print('pixel blanco =',  pixel_blanco)
# 5. Calcular total de píxeles y porcentaje
total_pixel = mask.shape[0] * mask.shape[1]
print('total pixeles = ',  total_pixel)
porcentaje = (pixel_blanco / total_pixel) * 100

print('porcenaje', porcentaje)

# Aplicar la máscara a la imagen original
result = cv2.bitwise_and(img, img, mask=mask)

# Mostrar la imagen original y la imagen con el color detectado
cv2.imshow("Imagen Original", img)
cv2.imshow("Color Detectado", result)
cv2.imshow("mask", mask)

cv2.waitKey(0)
cv2.destroyAllWindows()

Explicación de los Parámetros:

  • lowergreen = np.array([35, 100, 100]):
    • El valor mínimo del tono es 35, correspondiente a un verde.
    • La saturación mínima es 100 para evitar colores desaturados.
    • El valor mínimo de brillo es 100 para evitar colores muy oscuros.
  • uppergreen = np.array([85, 255, 255]):
    • El valor máximo del tono es 85, cubriendo tonos verdes claros y oscuros.
    • La saturación máxima es 255 para incluir verdes vibrantes.
    • El valor máximo de brillo es 255 para incluir colores brillantes.

Ajustes de los Umbrales

Dependiendo de las condiciones de luz y el color exacto que deseas detectar, puedes ajustar los valores de Hue, Saturación y Brillo:

  • Hue (H): Ajusta el rango para detectar tonos específicos del color.
  • Saturation (S): Ajusta para incluir colores más o menos saturados.
  • Value (V): Ajusta para incluir colores más claros o más oscuros.

Ejemplo visual: Rango de tonos para detectar verde

Color Hue (H) Saturación (S) Brillo (V)
Verde claro 35 100 100
Verde oscuro 85 255 255

Uso de cv2.inRange

La función cv2.inRange() crea una máscara binaria donde los píxeles dentro del rango son blancos (255) y los fuera del rango son negros (0).oO

cargar varias imágenes opencv

import cv2
import glob

# 1. Obtener todas las rutas de imágenes en una carpeta
ruta_imagenes = glob.glob('carpeta_imagenes/*.jpg')

# 2. Leer imágenes y guardarlas en una lista
imagenes = []
for ruta in ruta_imagenes:
    img = cv2.imread(ruta)
    if img is not None:
        imagenes.append(img)
    
# 3. Acceder a las imágenes cargadas
for i, img in enumerate(imagenes):
    cv2.imshow(f'Imagen {i}', img)
    
cv2.waitKey(0)
cv2.destroyAllWindows()

Cargar vídeo Opencv

#OpenCV
import cv2 as cv
import numpy as np

cap = cv.VideoCapture(0)
i=0
while(True):
    ret, img = cap.read()
    if ret:
        cv.imshow('video', img)
        #img2 = cv.cvtColor(img, cv.COLOR_BGR2HSV)
        #cv.imshow('video1', img2)
        cv.imwrite('/home/likcos/caras/cara'+str(i)+'.jpg', img)
        i+=1
        k =cv.waitKey(1) & 0xFF
        if k == 27 :
            break
    else:
        break
   
cap.release()
cv.destroyAllWindows()

Expediente X: Recuperación de la Información y Esteganografía

Introducción para los Agentes

La Recuperación de la Información (IR) no siempre consiste en buscar texto en una base de datos. A veces, la información está oculta a plena vista dentro de archivos multimedia. Su misión de hoy consta de tres fases para recuperar datos clasificados.

Misión 1: El Píxel Chismoso (Investigación y LSB Básico)

La Historia

Hemos interceptado una imagen en escala de grises (evidencia_1.png). Nuestros analistas aseguran que contiene un mensaje de texto oculto mediante una técnica llamada Esteganografía LSB (Least Significant Bit).

Tu Tarea

  1. Fase de Investigación: Antes de programar, investiga en internet: ¿Qué es la Esteganografía? ¿Cómo funciona específicamente la técnica LSB en los píxeles de una imagen?
  2. Fase de Extracción: Sabiendo que el mensaje está en el último bit de cada píxel, aplana la imagen, extrae el bit menos significativo de cada valor (usando la operación bit a bit & con 1), agrúpalos de 8 en 8 (un byte), conviértelos a caracteres ASCII y detente cuando encuentres la palabra clave ###FIN###.
import cv2
import numpy as np

# 1. Cargar la imagen interceptada (en escala de grises)
# img = cv2.imread('evidencia_1.png', cv2.IMREAD_GRAYSCALE)

# ESCRIBE TU CÓDIGO AQUÍ:
# Aplanar la imagen, extraer el último bit, agrupar en bytes y convertir a texto.

Misión 2: Operación Camaleón (Recuperación por Color HSV)

La Historia

El enemigo se dio cuenta de que conocemos el método LSB y cambió de táctica. Interceptamos evidencia_2.png. Parece un simple fondo verde aburrido, pero contiene un texto escrito con "Tinta Camaleón". El texto tiene exactamente el mismo brillo y saturación que el fondo, pero alteraron ligeramente el Matiz (Hue). El ojo humano no lo ve, pero OpenCV sí.

Las Pistas

  • La imagen parece completamente verde (En HSV, el verde ronda el valor H=60).
  • La tinta enemiga tiene una frecuencia de Matiz ligeramente superior, alrededor de H=64.

Tu Tarea

Convierte la imagen de BGR a HSV y utiliza cv2.inRange para crear una "máscara" que filtre y recupere solamente los píxeles que estén en el rango de la tinta enemiga (por ejemplo, entre H=63 y H=66).

# 1. Cargar evidencia_2.png
# 2. Convertir a HSV
# 3. Aplicar cv2.inRange para revelar el mensaje

Misión 3: El Cifrado Cromático (El Reto Híbrido HSV + LSB)

La Historia

¡El reto final! El enemigo ha combinado ambas técnicas en evidencia_3.png. Han ocultado un mensaje usando la técnica LSB, pero la información no está en toda la imagen. Los datos secretos están escondidos exclusivamente en los bits menos significativos del canal V (Value/Brillo) de los píxeles que pertenecen al color Amarillo Pardo.

Las Pistas

  • Llave de Color: Amarillo Pardo (Rango HSV sugerido -> Bajo: [15, 100, 100], Alto: [20, 255, 255]).
  • Debes crear una máscara para aislar ese color. Luego, extraer el LSB solo de los píxeles amarillos del canal V, agruparlos en ASCII y buscar el delimitador ###FIN###.
# 1. Cargar evidencia_3.png y convertir a HSV
# 2. Crear máscara para el Amarillo Pardo
# 3. Extraer canal V y obtener solo los píxeles donde la máscara es válida
# 4. Aplicar decodificación LSB a ese subconjunto de píxeles

Entregable: Reporte de Misión (Formato Markdown)

Deben entregar un archivo reporte_mision.md con la siguiente estructura. Deberán incluir sus códigos, capturas de pantalla de los mensajes revelados, y responder a las preguntas del Análisis del Analista.

#  Reporte de Misión: Recuperación de la Información
**Agente Especial:** [Tu Nombre/Matrícula]

---
## Misión 1, 2 y 3
[Incluir aquí los bloques de código Python y las imágenes o textos recuperados de cada misión]

---
##  Análisis del Analista (Reflexiones Finales)

1. **Sobre la Investigación (Misión 1):** Explica con tus propias palabras qué es la Esteganografía LSB. ¿Por qué cambiar el último bit de un píxel no altera la imagen de forma visible para el ojo humano?
> *[Tu respuesta]*

2. **Sobre los Espacios de Color (Misión 2):** Intenta aislar el texto de la Misión 2 usando directamente los canales BGR. ¿Por qué crees que es casi imposible recuperar esa información en BGR, pero resultó tan fácil usando el canal 'H' (Hue) del modelo HSV?
> *[Tu respuesta]*

3. **Sobre la Lógica de Recuperación (Misión 3):** Si en la Misión 3 intentaras extraer el mensaje LSB de toda la imagen completa (sin usar la máscara amarilla primero), ¿qué obtendrías como texto? ¿Cómo demuestra esto que el color actuó como una "llave de acceso"?
> *[Tu respuesta]*

Procesamiento de lenguaje natural

2.1 Representación de los documentos en lenguaje natural

Los documentos en lenguaje natural (textos, correos, artículos, publicaciones en redes, etc.) deben transformarse a una forma numérica para que puedan ser procesados por algoritmos de aprendizaje automático o recuperación de información. Esta transformación se denomina representación de documentos.

La idea general es pasar de texto crudo (cadenas de caracteres) a estructuras que capturen, en lo posible, el contenido semántico y estadístico:

  • Listas de tokens (palabras, términos, n‑gramas).
  • Vectores numéricos (por ejemplo, frecuencia de términos, TF‑IDF, embeddings).
  • Estructuras más complejas (árboles sintácticos, grafos de dependencias, etc.).

En este tema nos centraremos principalmente en las representaciones basadas en bolsa de palabras y espacios vectoriales.

2.1.1 Pre-procesamiento

El pre‑procesamiento es el conjunto de pasos que prepara el texto para ser representado numéricamente. Suele incluir:

  • Normalización:
    • Conversión a minúsculas.
    • Eliminación de caracteres especiales irrelevantes (signos extraños, espacios múltiples).
    • Opcionalmente, normalización de tildes y caracteres Unicode.
  • Tokenización:
    • División del texto en unidades básicas (tokens): palabras, signos, o n‑gramas.
    • Ejemplo: "Los perros ladran fuerte" → ["los", "perros", "ladran", "fuerte"].
  • Eliminación de stopwords:
    • Palabras muy frecuentes que aportan poca información semántica en muchos contextos (artículos, preposiciones, pronombres).
    • Ejemplos en español: "el", "la", "de", "y", "a", "que".
  • Lematización y stemming:
    • Stemming: recortar palabras a una raíz aproximada (ej. "hablando", "hablaría", "hablaron" → "habl").
    • Lematización: transformar la palabra a su forma canónica o lema (ej. "hablando", "hablaría", "hablaron" → "hablar").
    • En español, la lematización suele ser preferible al stemming porque mantiene mejor la forma correcta de la palabra.

El objetivo principal del pre‑procesamiento es reducir la variabilidad superficial del texto y centrarse en la información relevante para la tarea (clasificación, búsqueda, resumen, etc.).

2.1.2 Indexado

El indexado es el proceso de construir estructuras de datos que permitan recuperar documentos de forma eficiente en función de los términos que contienen.

En sistemas de recuperación de información suelen construirse:

  • Índice directo:
    • Para cada documento, la lista de términos que contiene y sus frecuencias.
    • Útil para calcular representaciones internas y para análisis dentro del documento.
  • Índice invertido:
    • Para cada término, la lista de documentos en los que aparece (y, opcionalmente, posiciones, frecuencias, etc.).
    • Es la estructura clave en motores de búsqueda.
    • Ejemplo esquemático:
      • "perro" → {doc1: f=3, doc4: f=1}
      • "gato" → {doc2: f=2, doc5: f=1}

El índice invertido hace posible responder consultas de palabras clave de manera muy rápida, sin examinar todos los documentos de la colección.

2.1.3 Reducción de dimensionalidad

Cuando representamos documentos como vectores de términos, el número de dimensiones suele ser enorme (tantos términos distintos como vocabulario). Esto provoca problemas:

  • Alto costo computacional.
  • Espacio de almacenamiento grande.
  • Ruidoso: muchos términos poco frecuentes o irrelevantes.

La reducción de dimensionalidad busca obtener representaciones más compactas preservando la mayor parte de la información relevante.

En PLN se usan varias estrategias:

  • Selección de características:
    • Eliminar términos irrelevantes según criterios estadísticos (frecuencia mínima, ganancia de información, chi‑cuadrado, etc.).
  • Proyección a espacios de menor dimensión:
    • Métodos lineales: PCA (Análisis de Componentes Principales), LSA (Latent Semantic Analysis).
    • Métodos no lineales o basados en aprendizaje de representaciones: autoencoders, embeddings (Word2Vec, GloVe, FastText, etc., aunque estos se aplican a nivel de palabras, pueden agregarse para obtener vectores de documentos).

La reducción de dimensionalidad puede:

  • Mejorar rendimiento de algoritmos.
  • Reducir el sobreajuste.
  • Facilitar la visualización de datos de texto en 2D o 3D.

2.1.4 Umbral de frecuencia

Un criterio sencillo y efectivo de selección de características es el umbral de frecuencia.

La idea básica:

  • Eliminar términos que aparecen muy pocas veces en la colección.
  • En algunos casos, también se filtran términos que aparecen en casi todos los documentos.

Parámetros típicos:

  • Frecuencia mínima absoluta: por ejemplo, descartar términos que aparecen en menos de 5 documentos.
  • Frecuencia relativa / documento: descartar términos presentes en un porcentaje excesivo de documentos (ej. más del 80 %) porque suelen ser poco discriminativos.

Ventajas:

  • Implementación muy simple.
  • Reduce significativamente el tamaño del vocabulario.
  • Suele mejorar la calidad de los modelos, especialmente para tareas de clasificación o clustering de documentos.

Limitaciones:

  • Puede eliminar términos raros pero muy informativos (por ejemplo, nombres propios, tecnicismos).
  • No considera el valor discriminativo de los términos de forma más sofisticada (para eso se usan medidas como la ganancia de información).

2.1.5 Ganancia de información

La ganancia de información (Information Gain, IG) es una medida de teoría de la información que indica cuánto reduce la incertidumbre sobre una variable objetivo (por ejemplo, la clase de un documento) al conocer el valor de una característica (por ejemplo, la presencia o ausencia de un término).

En clasificación de textos:

  • Variable objetivo: clase del documento (spam/no spam, categoría temática, etc.).
  • Característica: término \(t\) (presente/ausente, o rangos de frecuencia).

La ganancia de información se define como:

\[ IG(\text{clase}, t) = H(\text{clase}) - H(\text{clase} \mid t), \]

donde:

  • \(H(\text{clase})\) es la entropía de la distribución de clases sin considerar el término.
  • \(H(\text{clase} \mid t)\) es la entropía de las clases condicionada por el conocimiento de si el término está o no presente.

Interpretación:

  • Si un término aparece principalmente en documentos de una clase específica, reduce mucho la incertidumbre sobre la clase cuando lo observamos → alta ganancia de información.
  • Si un término se distribuye de forma parecida en todas las clases, apenas aporta información → baja ganancia de información.

Uso principal:

  • Selección de características supervisada para clasificación de textos.
  • Se calculan las IG de todos los términos y se conservan los de mayor valor.

2.2 Modelado espacio vectorial

El modelo de espacio vectorial (Vector Space Model, VSM) representa documentos y, a menudo, consultas como vectores en un espacio de alta dimensión.

Elementos básicos:

  • Cada dimensión corresponde a un término del vocabulario (o a una característica derivada).
  • Cada documento se representa como un vector \(\vec{d} = (w_1, w_2, \dots, w_n)\), donde \(w_i\) es el peso del término \(i\) en ese documento.

Formas típicas de pesar los términos:

  • Frecuencia absoluta (TF, term frequency): cuenta de cuántas veces aparece el término en el documento.
  • Frecuencia normalizada: TF dividido entre el número total de términos del documento.
  • TF‑IDF (Term Frequency – Inverse Document Frequency):
    • TF mide la importancia local del término en el documento.
    • IDF mide la rareza global del término en la colección.
    • IDF suele definirse como \(IDF(t) = \log \frac{N}{df_t}\), donde:
      • \(N\) es el número total de documentos.
      • \(df_t\) es el número de documentos que contienen el término \(t\).
    • El peso TF‑IDF se calcula comúnmente como:

\[ w_{t,d} = TF_{t,d} \times IDF(t) \]

Ventajas del VSM:

  • Relativamente sencillo de implementar.
  • Escala bien a colecciones grandes con técnicas adecuadas de indexado.
  • Permite medir similitud entre documentos y entre documento‑consulta.

Limitaciones:

  • Ignora el orden de las palabras (modelo de bolsa de palabras).
  • Captura poco la semántica profunda (sinónimos, polisemia).
  • El espacio puede ser extremadamente disperso y de alta dimensión.

2.3 Cálculo de similitudes

Una vez que documentos y consultas se representan como vectores, se necesitan medidas de similitud (o distancia) para compararlos.

Medidas comunes:

  • Similitud del coseno:
    • Define la similitud entre dos vectores \(\vec{d_1}\) y \(\vec{d_2}\) como:

\[ \text{sim}_\cos(\vec{d_1}, \vec{d_2}) = \frac{\vec{d_1} \cdot \vec{d_2}} {\|\vec{d_1}\| \, \|\vec{d_2}\|} \]

  • Varía entre 0 y 1 cuando todos los pesos son no negativos.
  • Es muy utilizada en recuperación de información y clustering de textos.
  • Distancia euclídea:
    • Mide la distancia geométrica estándar entre vectores.
    • Menos utilizada directamente en texto por la alta dimensionalidad y dispersión.
  • Distancia Manhattan (L1):
    • Suma de valores absolutos de las diferencias de cada dimensión.
    • A veces útil cuando interesa la suma de discrepancias absolutas.

En motores de búsqueda basados en VSM:

  • La consulta se representa como un vector (de términos de la consulta).
  • Cada documento tiene su vector (por ejemplo en TF‑IDF).
  • Se calcula la similitud (normalmente coseno) entre la consulta y cada documento.
  • Se ordenan los documentos de mayor a menor similitud.

2.4 NLTK

NLTK (Natural Language Toolkit) es una biblioteca de Python muy utilizada para aprendizaje y experimentación en PLN, especialmente en entornos académicos.

Características principales:

  • Soporta múltiples lenguajes (aunque está más centrada en inglés; para español tiene recursos limitados pero útiles).
  • Incluye:
    • Herramientas de pre‑procesamiento: tokenizadores, stemmers, lematizadores, stopwords.
    • Modelos de lenguaje simples (n‑gramas).
    • Etiquetado morfosintáctico (POS tagging) y parsers sintácticos.
    • Corpora de ejemplo para experimentación.

Ejemplo sencillo en Python (tokenización y stopwords en español):

import nltk
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize

# Asegurarse de haber descargado los recursos necesarios:
# nltk.download('punkt')
# nltk.download('stopwords')

texto = "Los modelos de lenguaje natural permiten procesar grandes volúmenes de texto."

tokens = word_tokenize(texto.lower(), language='spanish')
stopwords_es = set(stopwords.words('spanish'))

tokens_filtrados = [t for t in tokens if t.isalpha() and t not in stopwords_es]

print(tokens_filtrados)

Aunque existen bibliotecas más modernas y eficientes para producción (como spaCy, Hugging Face Transformers, etc.), NLTK sigue siendo muy valiosa para comprender los fundamentos del PLN.

2.5 Aplicaciones

El procesamiento de lenguaje natural tiene numerosas aplicaciones prácticas. A continuación, se destacan algunas relacionadas con la representación de documentos, modelo vectorial y cálculo de similitudes.

2.5.1 Búsqueda natural en bases de datos

La búsqueda natural en bases de datos consiste en permitir que los usuarios formulen consultas en lenguaje natural (frases completas) en lugar de usar únicamente filtros estructurados (campos, operadores).

Estrategia típica:

  • Pre‑procesar los textos de los registros (descripciones, títulos, comentarios) y construir un índice invertido.
  • Representar tanto los registros como las consultas como vectores (TF‑IDF u otros esquemas).
  • Calcular similitud entre la consulta del usuario y cada registro (o documento) y devolver los más similares.

Ventajas:

  • Experiencia de usuario más cercana a un buscador web.
  • Permite descubrir información relevante incluso si el usuario desconoce los campos exactos de la base de datos.

Aspectos avanzados:

  • Expansión de consulta (añadir sinónimos o términos relacionados).
  • Relevancia basada en aprendizaje (learning to rank).
  • Uso de embeddings semánticos para capturar similitud conceptual más allá de palabras exactas.

2.5.2 Estadística de discursos en texto

El análisis estadístico de discursos y textos permite extraer patrones y tendencias de grandes colecciones:

  • Frecuencias de términos:
    • Palabras más usadas por un hablante, partido político, medio de comunicación, etc.
  • Análisis de temas:
    • Métodos como LDA (Latent Dirichlet Allocation) detectan temas latentes a partir de distribuciones de términos.
  • Evolución temporal:
    • Cómo cambian los temas y vocabulario a lo largo del tiempo.
  • Análisis de sentimiento:
    • Clasificar textos según polaridad (positivo, negativo, neutro) o emociones.

La representación vectorial de documentos es clave aquí, ya que:

  • Permite agrupar discursos similares (clustering).
  • Facilita medir proximidad entre discursos de distintos autores o períodos.
  • Hace posible visualizaciones en 2D/3D tras reducción de dimensionalidad.

2.5.3 Chatbots y frameworks para su construcción

Los chatbots son sistemas capaces de interactuar en lenguaje natural con usuarios a través de texto o voz.

En chatbots clásicos (no basados en modelos de lenguaje gigantes), el PLN se utiliza para:

  • Comprensión de la intención (intent detection):
    • Clasificar el mensaje del usuario en una de varias intenciones (consultar saldo, hacer una reserva, pedir ayuda, etc.).
    • Se suele entrenar un clasificador donde los mensajes están representados como vectores de características (TF‑IDF, embeddings, etc.).
  • Reconocimiento de entidades:
    • Detectar y extraer entidades relevantes (fechas, lugares, nombres propios, productos).
  • Gestión del diálogo:
    • Módulo que decide la siguiente acción del sistema según el estado del diálogo y la intención detectada.

Existen diversos frameworks para construir chatbots:

  • Rasa:
    • Framework open source en Python orientado a chatbots con intención + entidades + diálogo.
    • Utiliza representaciones vectoriales y modelos de clasificación para la comprensión del lenguaje.
  • Botpress, Microsoft Bot Framework, Dialogflow, etc.:
    • Proporcionan herramientas visuales, conectores a múltiples canales (web, Telegram, WhatsApp, etc.) y modelos de PLN integrados.

Aunque los modelos actuales tipo transformer (como BERT, GPT y variantes) han revolucionado la construcción de chatbots, muchos conceptos fundamentales siguen siendo los mismos:

  • Representar texto de forma numérica.
  • Calcular similitudes y probabilidades.
  • Tomar decisiones basadas en la información contenida en los vectores.

2.6 Procesamiento y recuperación de información en imágenes

Aunque el foco de esta sección son los textos, muchas de las ideas se trasladan casi de forma directa al dominio de las imágenes si pensamos siempre en tres pasos: pre‑procesar → representar → comparar.

2.6.1 Pre-procesamiento de imágenes

Paralelamente al pre‑procesamiento de texto, sobre una imagen se suelen aplicar transformaciones para hacerla más manejable y homogénea:

  • Normalización geométrica:
    • Redimensionar todas las imágenes a un tamaño fijo (por ejemplo, 224×224 píxeles).
    • Recortes (cropping) para centrar el contenido relevante.
  • Normalización de intensidad y color:
    • Escalar los valores de píxeles a un rango estándar (por ejemplo, \([0, 1]\) o \([-1, 1]\)).
    • Estandarizar canales de color (RGB, escala de grises, etc.).
  • Reducción de ruido:
    • Filtros de suavizado para eliminar artefactos.

En tareas de aprendizaje profundo, estas operaciones suelen estar integradas en los pipelines de entrenamiento y predicción.

2.6.2 Representación vectorial de imágenes

El análogo a la bolsa de palabras o TF‑IDF en texto es la construcción de un vector de características que resuma el contenido visual de la imagen.

Dos enfoques generales:

  • Descriptores manuales (clásicos):
    • Histogramas de color: resumen cuántos píxeles de cada rango de color hay.
    • Descriptores de textura (LBP, Gabor, etc.): describen patrones de textura.
    • Puntos de interés y descriptores locales (SIFT, SURF, ORB):
      • Se detectan puntos “salientes” en la imagen.
      • Para cada punto se calcula un vector que describe su vecindad.
      • Estos vectores locales pueden agruparse (clustering) para formar una bolsa de palabras visuales: cada imagen se representa como un histograma de “palabras visuales”.
  • Embeddings aprendidos (profundos):
    • Redes neuronales convolucionales (CNN) o Vision Transformers se entrenan sobre grandes bases de datos de imágenes.
    • Se toma la salida de una capa intermedia (o penúltima capa) como vector de características compacto.
    • Estos vectores suelen capturar información semántica de alto nivel (objetos, escenas, estilos).

En ambos casos, el resultado final es un vector \(\vec{i} = (f_1, f_2, \dots, f_m)\) asociado a cada imagen, análogo al vector de términos de un documento.

2.6.3 Reducción de dimensionalidad y estructuras para búsqueda

Los vectores de características de imágenes pueden ser de muy alta dimensión (cientos o miles de componentes), por lo que también se aplican técnicas de reducción de dimensionalidad y estructuras de indexado especializadas:

  • Reducción de dimensionalidad:
    • PCA, autoencoders, etc., para comprimir los vectores manteniendo la información relevante.
    • Facilita la visualización de colecciones de imágenes en 2D/3D.
  • Índices para búsqueda por similitud:
    • A diferencia del índice invertido basado en términos, aquí se usan estructuras diseñadas para búsqueda de vecinos más cercanos en espacios vectoriales:
      • Árboles k‑d, estructuras HNSW, y librerías como FAISS, Annoy o similares.
    • Estas estructuras permiten encontrar, de forma eficiente, las imágenes cuyos vectores están más cerca del vector de consulta.

2.6.4 Cálculo de similitud entre imágenes

Una vez representadas como vectores, las imágenes se comparan con medidas de similitud o distancia, igual que en texto:

  • Similitud del coseno:
    • Muy usada cuando los vectores provienen de redes neuronales o han sido normalizados.
  • Distancia euclídea (L2):
    • Común en embeddings de visión, especialmente si el modelo fue entrenado con una pérdida que asume L2.
  • Otras distancias:
    • L1, distancias sobre histogramas (por ejemplo, distancia de Bhattacharyya), etc.

La idea clave es la misma: imágenes “parecidas” tienen vectores cercanos según la métrica elegida.

2.6.5 Recuperación de información basada en contenido de imágenes (CBIR)

El problema de Content‑Based Image Retrieval (CBIR) es el análogo directo a la recuperación de información en texto:

  • Se tiene una base de datos de imágenes, cada una con su vector de características.
  • Dada una consulta:
    • Una imagen ejemplo (búsqueda por similitud visual).
    • O incluso una descripción en texto, si ambos tipos se proyectan a un espacio común.
  • Se calcula el vector de la consulta y se buscan los vectores de la base que sean más cercanos.
  • Se devuelven las imágenes ordenadas por similitud.

Con modelos recientes (por ejemplo, CLIP y otros modelos multimodales), se puede:

  • Mapear texto e imágenes a un mismo espacio vectorial.
  • Hacer búsqueda de imágenes por texto:
    • El texto se convierte a un vector.
    • Se recuperan las imágenes con vectores más similares.

2.6.6 Analogía resumen texto ↔ imagen

Resumiendo la analogía:

  • Texto:
    • Pre‑procesamiento: limpieza, tokenización, stopwords, lemas.
    • Representación: bolsa de palabras, TF‑IDF, embeddings.
    • Índices: índices invertidos y estructuras para búsqueda eficiente.
    • Similitud: coseno, distancias.
    • Aplicación: búsqueda de documentos, clasificación, clustering, etc.
  • Imagen:
    • Pre‑procesamiento: normalización de tamaño, color, ruido.
    • Representación: descriptores clásicos o embeddings de redes profundas.
    • Índices: estructuras de vecinos más cercanos en espacios vectoriales.
    • Similitud: coseno, L2, otras distancias sobre histogramas.
    • Aplicación: búsqueda de imágenes similares, reconocimiento de objetos, sistemas de recomendación visual, etc.

En ambos casos, el núcleo conceptual es el mismo: representar objetos complejos como vectores en un espacio, y recuperar información relevante midiendo cercanía en ese espacio.

2.7 Procesamiento y recuperación de información en audio

El mismo esquema pre‑procesar → representar → comparar se aplica al dominio del audio (voz, música, sonidos ambientales, podcasts, etc.).

2.7.1 Pre-procesamiento de audio

  • Normalización de señal:
    • Re-muestreo a una frecuencia fija (ej. 16 kHz para voz, 44.1 kHz para música).
    • Normalización de amplitud (volumen) para que las señales sean comparables.
  • Segmentación:
    • División en ventanas o tramas (frames) de duración fija (ej. 20–40 ms), con solapamiento (overlap) para no perder información en los bordes.
    • Detección de actividad de voz (VAD) para ignorar silencios o ruido de fondo.
  • Filtrado y reducción de ruido:
    • Filtros paso bajo / paso banda según la aplicación.
    • Técnicas de supresión de ruido (espectrales o con redes neuronales).

2.7.2 Representación vectorial de audio

El audio se convierte en vectores de características que resumen el contenido acústico:

  • Descriptores clásicos (dominio tiempo-frecuencia):
    • MFCC (Mel-frequency cepstral coefficients): muy usados en reconocimiento de voz y música; resumen el espectro en bandas tipo Mel.
    • Espectrogramas: representación 2D (tiempo × frecuencia); pueden tratarse como “imágenes” y aplicar CNN.
    • Energía, zero-crossing rate, características espectrales (centroide, ancho de banda, etc.).
  • Embeddings aprendidos (profundos):
    • Redes entrenadas para tareas de audio (reconocimiento de voz, identificación de canciones, detección de eventos sonoros).
    • Se extrae el vector de una capa intermedia como “fingerprint” del audio.
    • Modelos como Wav2Vec, YAMNet, o encoders de música (MusicNN, etc.) producen vectores compactos por segmento o por archivo.

Cada clip o ventana de audio queda representado como \(\vec{a} = (f_1, f_2, \dots, f_m)\), análogo al vector de un documento o una imagen.

2.7.3 Reducción de dimensionalidad e indexado

  • Reducción de dimensionalidad: PCA, autoencoders, etc., igual que en texto e imágenes.
  • Índices para búsqueda: estructuras de vecinos más cercanos (FAISS, HNSW, Annoy) sobre los vectores de audio para recuperar clips similares de forma eficiente.

2.7.4 Similitud y aplicaciones en audio

  • Similitud: coseno, distancia euclídea (L2), etc., sobre los vectores de características o embeddings.
  • Aplicaciones:
    • Búsqueda por contenido: “encuentra canciones o fragmentos que suenen como este”.
    • Identificación de música (Shazam-like): matching de huellas espectrales o de embeddings.
    • Recuperación de voz: buscar fragmentos de habla similares (mismo hablante, mismo tema).
    • Clasificación: género musical, idioma, detección de eventos sonoros (risa, aplausos, etc.).

2.8 Procesamiento y recuperación de información en vídeo

El vídeo puede entenderse como secuencias de imágenes (frames) más una pista de audio opcional. La idea de representar y comparar se mantiene; lo que cambia es que hay que decidir qué representar (frames clave, promedios temporales, secuencias de vectores).

2.8.1 Pre-procesamiento de vídeo

  • Extracción de frames:
    • Muestreo a tasa fija (ej. 1 fps) o detección de cambios de escena (shot detection) para quedarse con frames representativos.
  • Normalización por frame:
    • Mismo tipo de pre-procesamiento que en imágenes: redimensionado, normalización de color, etc.
  • Audio (opcional):
    • Si se usa la pista de audio, se aplican las técnicas de pre-procesamiento de la sección 2.7.

2.8.2 Representación vectorial de vídeo

  • Por frames:
    • Cada frame se pasa por una CNN o Vision Transformer y se obtiene un vector.
    • El vídeo puede representarse como:
      • Un único vector: promedio o concatenación de los vectores de unos pocos frames clave; o el vector del frame central.
      • Secuencia de vectores: para modelar la evolución temporal con RNN, LSTM o Transformers.
  • Descriptores clásicos:
    • Histogramas de movimiento (optical flow), histogramas de color promediados en el tiempo, etc.
  • Modelos multimodales:
    • Modelos que fusionan visión + audio (ej. redes que procesan frames y waveform) para un único embedding por clip de vídeo.

2.8.3 Indexado, similitud y aplicaciones en vídeo

  • Indexado: los vectores (por vídeo completo o por segmento) se almacenan en índices de vecinos más cercanos para búsqueda rápida.
  • Similitud: coseno, L2, o métricas sobre secuencias (DTW, etc.) si se comparan trayectorias temporales.
  • Aplicaciones:
    • Búsqueda por similitud visual: “vídeos que se parecen a este” (misma escena, mismo estilo).
    • Búsqueda por contenido: encontrar fragmentos donde aparece un objeto, una acción o un tema.
    • Detección de duplicados o near-duplicates: gestión de derechos, moderación de contenido.
    • Resumen y recomendación: agrupar vídeos por contenido para recomendadores o catálogos.

2.8.4 Resumen multimodal: texto, imagen, audio y vídeo

En todos los casos el flujo es el mismo:

Modalidad Pre-procesamiento Representación típica Búsqueda / recuperación
Texto Tokenización, lemas TF-IDF, embeddings Índice invertido, ANN
Imagen Resize, color Descriptores, CNN/ViT embeddings ANN (FAISS, HNSW, etc.)
Audio Ventanas, MFCC MFCC, espectrogramas, Wav2Vec, etc. ANN sobre vectores de audio
Vídeo Frames, shots Vectores por frame o por clip ANN (sobre resumen del vídeo)

Representar como vectores y medir cercanía en ese espacio permite recuperar información de forma unificada; los modelos multimodales (CLIP, modelos audio-visuales) extienden esto a consultas cruzadas (texto → imagen, texto → vídeo, etc.).

PCA y reducción de dimensionalidad

1. Punto de partida: muchos atributos, un diagnóstico

En el archivo enfermedad_1000.csv tienes, de forma simplificada:

  • Un identificador: id
  • Muchas variables numéricas:
    • Clínicas: edad, imc, glucosa, insulina, colesterol_total, hdl, ldl, trigliceridos, presion_sistolica, presion_diastolica, frecuenciacardiaca
    • Estilo de vida: actividad_fisica, consumo_calorias, horas_sueno, =estres=…
    • Genéticas: gen_1gen_5
    • Ruido: ruido_1ruido_5
  • Una variable objetivo binaria: diagnostico (por ejemplo 0 = sano, 1 = enfermo)

En términos de dimensionalidad:

  • Cada columna numérica (excepto id y diagnostico) es una dimensión del espacio de datos.
  • Cada fila (paciente) es un punto en ese espacio de alta dimensión.

Problema típico:

  • Hay muchas variables, algunas muy correlacionadas entre sí.
  • El modelo puede ser complejo, difícil de visualizar y más propenso al sobreajuste.

La pregunta central es:

¿Podemos describir “casi toda” la información útil de estos datos con menos dimensiones que las columnas originales?

El Análisis de Componentes Principales (PCA) responde precisamente a esa pregunta.

2. Idea intuitiva del PCA

Imagina:

  • Cada paciente es un punto en un espacio de, por ejemplo, 20 o 25 dimensiones (tantas como variables uses).
  • Esta nube de puntos tiene una cierta forma: más dispersión en unas direcciones, menos en otras.

El PCA busca:

  • Nuevas direcciones en ese espacio (llamadas componentes principales) tales que:
    • La primera dirección (PC1) es aquella en la que la nube de datos tiene la mayor variabilidad posible.
    • La segunda dirección (PC2) es la segunda con mayor variabilidad, pero ortogonal (independiente) de la primera.
    • La tercera (PC3) también es ortogonal a las anteriores, y así sucesivamente.

¿Qué significa exactamente “ortogonal”?

  • En un dibujo 2D:
    • El eje horizontal (X) y el eje vertical (Y) forman un ángulo recto de 90°.
    • Esos dos ejes se llaman ortogonales.
  • En 3D:
    • Los ejes X, Y y Z también son ortogonales entre sí (cada pareja se cruza en 90°).
  • En muchas dimensiones (más de 3):
    • No lo podemos dibujar, pero la idea es la misma:
    • Cada componente principal es un “eje nuevo” y todos esos ejes nuevos se cruzan entre sí en “ángulo recto” en ese espacio de muchas dimensiones.

Traducido a lenguaje de datos:

  • Que PC1 y PC2 sean ortogonales significa:
    • No apuntan en la misma dirección ni son combinaciones similares de las variables originales.
    • Lo que cuenta PC1 sobre un paciente es información distinta de lo que cuenta PC2.
  • Estadísticamente:
    • PC1 y PC2 son no correlacionadas:
      • Saber que un paciente tiene un valor alto en PC1 no te dice nada sobre si tendrá un valor alto o bajo en PC2.
      • No hay relación lineal simple entre ellas.

Una forma de imaginarlo:

  • Piensa que cada componente principal es una pregunta diferente que le haces al paciente:
    • PC1: “¿En qué medida tienes un perfil metabólico alto: glucosa, triglicéridos, colesterol…?”
    • PC2: “¿En qué medida tienes un perfil cardiovascular alto: presión, frecuencia cardíaca…?”
  • Que sean ortogonales significa:
    • La respuesta a la primera pregunta (PC1) no está “mezclada” con la segunda (PC2).
    • Cada pregunta mira la nube de puntos desde un ángulo completamente distinto.

¿Por qué es bueno que sean ortogonales?

  • Porque evita la redundancia:
    • Si tuvieras dos componentes que casi miran lo mismo, estarías repitiendo información.
    • Al ser ortogonales, cada componente aporta un “trozo nuevo” de información sobre cómo se dispersan los datos.
  • Porque te da dimensiones limpias y separadas:
    • Puedes decir: “PC1 recoge este tipo de variación, PC2 otro tipo, PC3 otro…”
    • Y sabes que esas variaciones no están solapadas entre sí.

Cada componente principal es:

  • Una combinación lineal de todas las variables originales.
  • Una nueva “variable” que resume un patrón de variación en los datos.

Ejemplo conceptual:

  • PC1 podría representar una combinación fuerte de:
    • glucosa, insulina, trigliceridos, colesterol_total
    • Es decir, un “eje metabólico”.
  • PC2 podría combinar:
    • presion_sistolica, presion_diastolica, frecuencia_cardiaca
    • Un “eje cardiovascular”.
  • PC3 podría estar más vinculado a gen_1…gen_5 y algunas variables de estilo de vida.

Estas nuevas variables (PC1, PC2, PC3…) son las que usarías en lugar del conjunto original completo.

3. Relación entre PCA y reducción de dimensionalidad

La clave es cómo se reparte la varianza total de los datos entre los componentes:

  • PC1 explica un cierto porcentaje de la varianza (por ejemplo, 30%).
  • PC2 otro porcentaje (por ejemplo, 20% adicional).
  • PC3 otro tanto, etc.

Al sumar esa varianza explicada, obtienes una varianza acumulada:

  • PC1: 30% acumulado
  • PC1 + PC2: 50%
  • PC1 + PC2 + PC3: 65%

La observación empírica en muchos problemas reales es:

  • Con relativamente pocos componentes (2, 3, 5, 10) puedes llegar a explicar una gran parte de la varianza total (por ejemplo, 80–95%).

Eso significa:

  • Aunque tengas 20–30 variables originales, tal vez 5 componentes principales capturan casi toda la información relevante.
  • Entonces puedes “reducir” tu problema a trabajar en un espacio de dimensión 5, en lugar de 20–30.

En tu dataset:

  • Variables como colesterol_total, hdl, ldl, trigliceridos van muy ligadas.
  • presion_sistolica y presion_diastolica también se complementan.
  • Parte de las columnas de ruido_1..ruido_5 aportarán varianza pero desestructurada (sin correlaciones claras).

El PCA tiende a:

  • Concentrar la variación “estructurada” (patrones reales) en los primeros componentes.
  • Dejar la variación más caótica o ruido en los componentes posteriores, que explican poca varianza.

4. Geometría del PCA

Visualización mental:

  1. Tienes una nube de puntos de alta dimensión (los pacientes).
  2. Centras los datos:
    • A cada variable le restas su media para que todas tengan media cero.
    • (Normalmente también se escala a varianza 1; esto equilibra variables en distinta escala.)
  3. Buscas la recta (dirección) en el espacio donde la proyección de los datos:
    • Tiene la mayor dispersión posible.
    • Es decir, donde la nube se “alarga” más.
    • Esa recta define la primera componente principal.
  4. Luego buscas otra recta:
    • Perpendicular a la primera.
    • Que maximice la varianza restante.
    • Esa es la segunda componente principal.
  5. Y sigues repitiendo el proceso para más componentes.

Cada paciente puede ahora describirse por sus coordenadas en estas nuevas direcciones:

  • En vez de dar sus valores de edad, imc, glucosa, …, gen_5, ruido_5
  • Le asignas sus valores en PC1, PC2, PC3, …

Si te quedas solo con PC1 y PC2:

  • Cada paciente pasa a ser un punto en un plano bidimensional.
  • Puedes dibujar un gráfico de dispersión y colorear por diagnostico para ver si la nube con 0 y 1 tiende a separarse.

5. Papel de las variables de ruido

En tu conjunto de datos aparecen explícitamente ruido_1ruido_5.

Intuitivamente:

  • Estas columnas parecen generadas como variables aleatorias sin estructura clara.
  • No deberían estar muy correlacionadas con el resto de variables ni entre sí (más allá del azar).

¿Cómo las trata el PCA?

  • Como cualquier otra variable: solo “ve” que tienen cierta varianza.
  • Si el ruido tiene una varianza comparable a las demás, también contribuye a la suma total de varianza.
  • Pero, al no estar organizado en patrones claros, suele:
    • No concentrarse mucho en los primeros componentes (que buscan patrones sistemáticos).
    • Repartirse en muchos componentes o aparecer más en los componentes que explican poca varianza.

Cuando tú decides quedarte solo con los primeros k componentes:

  • Estás, en la práctica, filtrando parte de esa variación aleatoria.
  • Es una forma de limpiar ruido a la vez que reduces dimensionalidad.

6. Interpretación de los componentes principales

Cada componente principal se define por un conjunto de cargas (pesos) sobre las variables originales.

Aunque aquí no entremos en fórmulas:

  • Imagina una tabla donde cada fila es una variable (por ejemplo glucosa, imc, =estres=…)
  • Y cada columna es un componente (PC1, PC2, PC3…)
  • El valor en la tabla es el “peso” (o importancia) de esa variable en ese componente.

Interpretación cualitativa:

  • Para PC1:
    • Si ves que tienen pesos grandes (en valor absoluto) variables como glucosa, insulina, trigliceridos, colesterol_total
    • Dirías que PC1 representa sobre todo un eje metabólico.
  • Para PC2:
    • Si dominan presion_sistolica, presion_diastolica, frecuencia_cardiaca
    • Podrías hablar de un eje cardiovascular.
  • Para otros componentes:
    • Pueden aparecer combinaciones entre factores genéticos, estrés, horas de sueño, actividad física, etc.

Así, los componentes no solo reducen dimensiones, sino que:

  • Te dan una visión de qué grupos de variables tienden a variar juntas.
  • Permiten crear “índices” o “ejes” latentes (metabólico, cardiovascular, de estilo de vida, genético, etc.).

7. Reducir dimensionalidad paso a paso (conceptualmente)

Supongamos que quieres pasar de, por ejemplo, 24 variables originales a algo más manejable.

El proceso conceptual sería:

  1. Crear la matriz de datos X:
    • Filas = pacientes.
    • Columnas = todas las variables numéricas salvo:
      • Quitas id (identificador).
      • Quitas diagnostico (objetivo, la dejas aparte).
  2. Estandarizar las variables:
    • Restas la media y divides entre la desviación estándar de cada columna.
    • Así ninguna domina solo por tener escala más grande.
  3. Calcular el PCA:
    • Obtienes tantos componentes como columnas originales.
    • Cada componente tiene:
      • Una varianza asociada.
      • Un porcentaje de varianza explicada.
  4. Ordenar por importancia:
    • PC1 es el más importante, luego PC2, etc.
    • Haces la suma acumulada de varianzas explicadas.
  5. Elegir cuántos componentes conservar:
    • Decides un umbral, por ejemplo:
      • “Quiero explicar al menos el 90% de la varianza total.”
    • Cuentas cuántos componentes necesitas para alcanzar ese porcentaje acumulado.
    • Imagina que necesitas 6 componentes para llegar al 90%.
  6. Proyectar los datos:
    • Cada paciente se representa ahora por solo 6 números: sus coordenadas en PC1…PC6.
    • Has pasado de 24 dimensiones a 6, conservando la mayor parte de la información.
  7. Usar los componentes en lugar de las variables originales:
    • Para entrenamiento de modelos de clasificación (por ejemplo, para predecir diagnostico).
    • Para visualización (por ejemplo, PC1 vs PC2 coloreando por diagnóstico).

Esta es la reducción de dimensionalidad:

  • Sigues describiendo razonablemente bien cada paciente.
  • Pero lo haces con menos dimensiones, más compactas y menos redundantes.

8. Ventajas y limitaciones del PCA en este contexto

Ventajas:

  • Reduce el número de variables, simplificando modelos posteriores.
  • Disminuye la colinealidad (las PCs son ortogonales entre sí).
  • Puede mejorar la estabilidad de los modelos y reducir el sobreajuste.
  • Facilita la visualización:
    • Con 2 o 3 PCs puedes hacer gráficos 2D/3D y ver patrones o grupos.
  • Ayuda a entender:
    • Qué conjuntos de variables forman “bloques” que se mueven juntos.

Limitaciones:

  • Los componentes son combinaciones lineales:
    • A veces su interpretación clínica no es tan directa como la de las variables originales.
  • El PCA no usa la información de diagnostico para construir los componentes:
    • Es una técnica no supervisada.
    • Encuentra direcciones de máxima varianza, no necesariamente las mejores para separar sanos de enfermos.
  • Si el ruido tiene varianza alta, puede influir:
    • Por eso es importante interpretar y, si hace falta, depurar variables antes o después del PCA.

9. PCA como preprocesamiento antes de modelar el diagnóstico

Aplicado a tu conjunto enfermedad_1000.csv, el PCA se puede entender como un bloque intermedio:

  1. Datos originales:
    • Muchas columnas (edad, imc, glucosa, genes, ruido, etc.).
  2. PCA y reducción:
    • Transformas esas columnas en un número pequeño de componentes principales.
  3. Modelo supervisado:
    • Usas los componentes (PC1..PCk) para entrenar un modelo que prediga diagnostico.

Conceptualmente:

  • En vez de decir:
    • “Mi modelo mira 24 variables originales.”
  • Dices:
    • “Mi modelo mira 6 índices latentes que resumen patrones clínicos, metabólicos, cardiovasculares, genéticos y de estilo de vida.”

Esta forma de pensar:

  • Te obliga a separar mentalmente:
    • Estructura interna de los datos (capturada por PCA).
    • Relación con el diagnóstico (capturada por el modelo supervisado posterior).

Actividad: Reducción de Dimensionalidad en Recuperación de Información

Objetivo

Comparar la reducción de dimensionalidad realizada manualmente (“a ojo”) con métodos automáticos como TF-IDF + SVD, para entender sus ventajas y limitaciones.

Dataset (Documentos)

Documento 1 – Tecnología

El aprendizaje automático es una rama de la inteligencia artificial que permite a las computadoras aprender a partir de datos sin ser programadas explícitamente. Se utiliza en aplicaciones como reconocimiento de voz, visión por computadora y sistemas de recomendación.

Documento 2 – Salud

La salud pública se enfoca en la prevención de enfermedades y la promoción del bienestar en la población. Incluye campañas de vacunación, educación sanitaria y el control de epidemias.

Documento 3 – Deportes

El fútbol es uno de los deportes más populares en el mundo. Requiere habilidades físicas, trabajo en equipo y estrategias para anotar goles y ganar partidos.

Documento 4 – Tecnología

Los sistemas de inteligencia artificial pueden analizar grandes volúmenes de datos para identificar patrones y tomar decisiones automatizadas en diversos contextos.

Documento 5 – Educación

La educación moderna incorpora tecnologías digitales para mejorar el aprendizaje. Plataformas en línea, simuladores y recursos interactivos facilitan la enseñanza.

Documento 6 – Medio Ambiente

El cambio climático afecta a los ecosistemas y provoca fenómenos extremos como sequías, inundaciones y aumento de temperatura global.

Documento 7 – Economía

La inflación es el aumento generalizado de los precios de bienes y servicios en una economía durante un periodo de tiempo.

Documento 8 – Tecnología

Las redes neuronales profundas permiten el desarrollo de sistemas avanzados de reconocimiento de imágenes y procesamiento de lenguaje natural.

Documento 9 – Salud

Una alimentación balanceada y el ejercicio regular son fundamentales para prevenir enfermedades y mantener una buena calidad de vida.

Documento 10 – Educación

El aprendizaje en línea ha crecido significativamente gracias al acceso a internet y a plataformas digitales educativas.

Parte 1: Reducción “a ojo”

Instrucciones

Para cada documento:

  • Selecciona las palabras más importantes
  • Reduce cada texto a máximo 5 palabras clave

Entregable

Documento Palabras clave
Doc 1  
Doc 2  
 

Parte 2: Discusión

Responde:

  1. ¿Qué criterio utilizaste para seleccionar palabras?
  2. ¿Coincidiste con otros compañeros?
  3. ¿Qué dificultades encontraste?

Parte 3: Representación con TF-IDF

from sklearn.feature_extraction.text import TfidfVectorizer

documentos = [
"El aprendizaje automático es una rama de la inteligencia artificial...",
"La salud pública se enfoca en la prevención de enfermedades...",
"El fútbol es uno de los deportes más populares...",
"Los sistemas de inteligencia artificial pueden analizar grandes datos...",
"La educación moderna incorpora tecnologías digitales...",
"El cambio climático afecta a los ecosistemas...",
"La inflación es el aumento de precios...",
"Las redes neuronales profundas permiten...",
"Una alimentación balanceada y el ejercicio...",
"El aprendizaje en línea ha crecido..."
]

vectorizer = TfidfVectorizer(stop_words='spanish')
X = vectorizer.fit_transform(documentos)

print(X.shape)

Parte 4: Reducción con SVD (LSA)

from sklearn.decomposition import TruncatedSVD

svd = TruncatedSVD(n_components=2)
X_reducido = svd.fit_transform(X)

print(X_reducido)

Parte 5: Análisis

Responde:

  1. ¿Qué representan las nuevas dimensiones?
  2. ¿Se agrupan documentos similares?
  3. ¿Qué diferencias hay respecto a la reducción manual?

Parte 6: Comparación

Aspecto Manual TF-IDF + SVD
Facilidad    
Precisión    
Escalabilidad    
Objetividad    

Reflexión Final

  1. ¿Qué método es más confiable?
  2. ¿Cuál usarías en un sistema real?
  3. ¿Se pierde información en ambos casos?

Actividad Extra

  • Probar con diferentes valores:
    • ncomponents = 2, 3, 5
  • Analizar cambios en resultados
  • Agrupar documentos similares

Conclusión

La reducción manual es útil para comprender el proceso, pero los métodos matemáticos permiten trabajar con grandes volúmenes de datos de forma objetiva, eficiente y reproducible.

Aplicaciones

2.5.1 Búsqueda natural en bases de datos

La búsqueda natural en bases de datos (también llamada consulta en lenguaje natural o Natural Language Querying) es el conjunto de técnicas que permiten que un usuario haga preguntas como las diría normalmente —por ejemplo: “Muéstrame los alumnos con promedio mayor a 85 del último periodo” o “¿Cuántos registros hay por carrera en 2025?”— y que el sistema las convierta en una consulta formal ejecutable (SQL, una llamada a una API, SPARQL, etc.). El objetivo principal es reducir la barrera técnica para acceder a los datos: en lugar de conocer el esquema, los nombres exactos de tablas y columnas, y la sintaxis de SQL, el usuario expresa una intención y el sistema se encarga de interpretarla.

En la práctica, una solución de este tipo suele tener varias etapas. Primero, el sistema debe entender la intención del usuario: qué quiere (contar, listar, comparar, filtrar, ordenar), sobre qué entidades (alumnos, materias, pagos, productos) y con qué restricciones (rango de fechas, umbral de promedio, carrera específica). Después aparece un paso crítico llamado schema linking: conectar las palabras del usuario con los elementos reales del esquema. Por ejemplo, si el usuario dice “promedio”, el sistema debe decidir si se refiere a `promedioaritmeticoacumulado`, `promedioperiodoanterior` u otro campo; si dice “último periodo”, quizá se refiere a `ultimoperiodoinscrito`. Este mapeo se complica porque los usuarios usan sinónimos, abreviaciones o términos que no coinciden 1 a 1 con la base.

Una vez que la intención y el vínculo al esquema están claros, el sistema genera la consulta. Hay enfoques basados en reglas y plantillas (muy efectivos cuando el dominio es reducido y controlado), y enfoques basados en aprendizaje automático, donde se intenta resolver el problema como Text-to-SQL (convertir texto a SQL). Con modelos modernos, especialmente LLMs, se puede generar SQL con bastante flexibilidad, pero aparecen retos: el SQL generado puede ser sintácticamente válido y aun así ser conceptualmente incorrecto (una unión mal hecha, una condición interpretada al revés, un campo equivocado). Por eso en sistemas reales se agregan restricciones y validaciones: limitar tablas y columnas permitidas, aplicar políticas de acceso por rol, revisar que el SQL no tenga operaciones peligrosas, y a veces ejecutar primero un “plan” o consulta de prueba.

Además de generar SQL, un buen sistema de búsqueda natural también debe presentar resultados de forma comprensible. Es común que muestre la consulta interpretada (o un resumen de ella) para que el usuario confirme que el sistema entendió bien: “Voy a filtrar por carrera=ISC y promedio>85 en el periodo 20252”. Cuando hay ambigüedad, lo ideal es preguntar: “¿Te refieres al promedio acumulado o al del periodo anterior?” Este comportamiento conversacional reduce errores silenciosos y aumenta confianza.

Finalmente, la evaluación de estos sistemas no se limita a “si el SQL coincide con uno de referencia”. Muchas veces hay múltiples SQL equivalentes (distinto orden de joins o condiciones) que producen el mismo resultado. Por eso se evalúa con métricas basadas en ejecución (si el resultado es correcto) y con análisis por componentes (qué tan bien detecta filtros, agregaciones, joins y límites). En producción, también importan métricas operativas: tiempo de respuesta, tasa de consultas que requieren aclaración, y, muy importante, seguridad y privacidad.

2.5.2 Estadística de discursos en texto

La estadística de discursos en texto es el análisis cuantitativo de características lingüísticas y estructurales que aparecen en discursos escritos u orales (transcritos). Aquí “discurso” no es solo el contenido literal, sino la forma en la que se organiza la información: cómo se conectan ideas, cómo se argumenta, qué estilo se usa, qué recursos lingüísticos aparecen y cómo cambian a lo largo del texto o entre distintos autores/grupos.

En términos prácticos, el análisis puede hacerse a varios niveles. En el nivel léxico, se estudian frecuencias de palabras, expresiones, n-gramas (secuencias de palabras), diversidad léxica y repetición. Estas medidas ayudan a describir el estilo: un texto con alta diversidad léxica y oraciones largas suele diferir de uno con vocabulario repetitivo y frases cortas, aunque hablen del mismo tema. También se analizan palabras funcionales (artículos, preposiciones) porque son sorprendentemente útiles para distinguir estilos y patrones de autoría.

En un nivel sintáctico, se examina la estructura de las oraciones: longitud promedio, complejidad, uso de subordinación, patrones de etiquetas gramaticales (POS tags), y construcciones repetidas. Estos indicadores pueden correlacionar con formalidad, claridad, densidad informativa o incluso fatiga/estrés en ciertos dominios (aunque esto requiere cuidado metodológico).

En el nivel semántico y discursivo, se incorporan técnicas como extracción de tópicos (por ejemplo, modelos tipo LDA), embeddings para medir similitud entre segmentos, detección de cambios temáticos, y análisis de cohesión textual (cómo se conectan frases mediante referencias y conectores). Un rasgo típico del discurso es el uso de marcadores discursivos (“sin embargo”, “por tanto”, “en conclusión”), que sirven para cuantificar el tipo de relación lógica que predomina: contraste, causa, enumeración, explicación, etc. En debates o textos argumentativos, también se intenta identificar premisas, conclusiones y evidencias, lo cual permite estudiar la estructura del argumento de manera cuantitativa.

Cuando el discurso es conversacional (chats, entrevistas, soporte), aparecen estadísticas específicas: duración de turnos, proporción de preguntas vs afirmaciones, interrupciones, actos de habla (pedir, prometer, quejarse), y medidas de alineación entre interlocutores. En atención al cliente, por ejemplo, se analiza si el agente usa más lenguaje empático, si el usuario incrementa el tono negativo, o si hay patrones que anticipen escalamiento a un humano.

Estas estadísticas se usan en aplicaciones como monitoreo de redes sociales, análisis de campañas políticas, evaluación de participación en educación, auditoría de lenguaje institucional, y detección de cambios de estilo (por ejemplo, en control de calidad o en seguridad). Sin embargo, hay desafíos importantes: los modelos pueden reflejar sesgos (de género, región o grupo social), el dominio puede tener jerga que altera frecuencias, y los datos reales suelen contener información sensible, por lo que hay que cuidar anonimización y privacidad.

2.5.3 Chatbots y frameworks para su construcción

Un chatbot es un sistema que interactúa con usuarios mediante texto o voz para responder preguntas, guiar procesos o automatizar tareas. Aunque hoy se asocian mucho a modelos generativos, los chatbots existen desde hace décadas, con enfoques basados en reglas, menús y árboles de decisión. La diferencia actual es la capacidad de manejar lenguaje natural más flexible y mantener conversaciones más “humanas” con modelos de IA.

Desde una perspectiva de ingeniería, construir un chatbot no es solo “poner un modelo a responder”. Un sistema robusto suele incluir un canal (web, WhatsApp, Telegram, app), una capa de interpretación (NLU), un gestor de diálogo (manejo de estado y contexto), una capa de negocio (conexión a bases de datos y APIs), y una capa de generación de respuesta (plantillas o modelos). La complejidad real surge cuando el chatbot debe ejecutar acciones: consultar registros, crear tickets, actualizar datos o aplicar reglas de negocio. En esos casos, además de entender texto, debe operar con seguridad y trazabilidad.

Hay distintos estilos de construcción. En chatbots orientados a tareas, se usan flujos: se recopila información paso a paso, se validan campos, se confirman acciones (“¿Seguro que deseas cancelar tu cita?”) y se maneja el estado. Frameworks como Rasa, Microsoft Bot Framework o herramientas de flujo tipo Botpress han sido populares porque permiten diseñar conversaciones controladas y auditables. En chatbots orientados a conocimiento (FAQ, manuales, normativas), se usa recuperación de información: el bot busca en documentos y responde con base en fuentes. Con modelos modernos, se usa mucho el enfoque RAG (Retrieval-Augmented Generation): el sistema recupera fragmentos relevantes de un repositorio (por ejemplo, PDFs o páginas internas) y el modelo genera la respuesta usando esos fragmentos como contexto para reducir alucinaciones.

Cuando se usan LLMs, es común integrar “herramientas” (tools): funciones que consultan bases de datos, calculan algo, o realizan una acción. Esto habilita chatbots que no solo responden, sino que “hacen”. Pero también introduce riesgos: un modelo podría ejecutar acciones indebidas si no se restringe. Por eso se aplican controles: listas de herramientas permitidas, validación de parámetros, control de acceso por rol, redacción de datos sensibles, límites de tasa, y logs para auditoría.

La evaluación de chatbots va más allá de exactitud. Importa la tasa de resolución (cuántos casos se resuelven sin humano), satisfacción del usuario, tasa de escalamiento, latencia, cobertura de intents, y especialmente la precisión factual cuando responde con información institucional. En chatbots con acciones, también se mide integridad: que no creen duplicados, que respeten reglas y que manejen errores con claridad.

En resumen, los frameworks para chatbots ayudan a organizar estas piezas: diseño conversacional, NLU, estado, integración con sistemas, y despliegue multicanal. La elección depende del tipo de bot: si es transaccional con flujos estrictos, convienen herramientas de diálogo estructurado; si es de conocimiento, conviene RAG; si es híbrido, una arquitectura por componentes y buenas prácticas de seguridad y observabilidad son esenciales.

Categorización automática de documentos

3.1.1 Definición formal del problema

La categorización automática de documentos (o clasificación de texto) es el problema de asignar a cada documento una o varias etiquetas predefinidas, como “deportes”, “política”, “finanzas”, “reclamo”, “solicitud”, “spam”, etc. Formalmente, se parte de un conjunto de documentos \(D = \{d_1,\dots,d_n\}\) y un conjunto de categorías \(C = \{c_1,\dots,c_k\}\). El objetivo es aprender una función \(f(d)\) que, dado un documento, devuelva la categoría correcta. En clasificación multiclase el documento pertenece a una sola clase; en multietiqueta puede pertenecer a varias simultáneamente (por ejemplo, un correo puede ser “facturación” y “urgente”).

Para poder aplicar aprendizaje automático, un documento debe representarse como una estructura numérica: un vector de características. Tradicionalmente se usaron representaciones de bolsa de palabras (conteos, TF-IDF) donde cada dimensión corresponde a un término. Con modelos modernos se usan embeddings que capturan significado contextual. Una vez representado el documento como vector \(x\), la tarea se reduce a aprender una función \(f(x)\) que aproxime la relación entre texto y etiqueta.

En la definición formal aparecen varios retos: el lenguaje es ambiguo, las clases pueden traslaparse, y los datos suelen ser desbalanceados (pocas instancias en clases raras). Además, a veces las categorías están definidas jerárquicamente (tema \(\to\) subtema) o cambian con el tiempo (concept drift). Por eso, además de entrenar un modelo, la formulación del problema incluye definir claramente qué significa cada etiqueta, cómo se etiqueta el corpus y qué se considera “correcto” en evaluación.

3.1.2 Paradigmas de categorización

El paradigma más común es el supervisado, donde se cuenta con ejemplos etiquetados. Aquí el desempeño depende directamente de la calidad y consistencia de las etiquetas: si dos personas etiquetan de forma distinta, el modelo aprenderá señales confusas. Por eso en proyectos reales se construyen guías de etiquetado y se mide acuerdo entre anotadores.

El paradigma no supervisado (clustering) se usa cuando no hay etiquetas o cuando se quiere explorar el espacio de documentos para descubrir grupos naturales. Aunque no reemplaza a la clasificación supervisada (porque no se ajusta necesariamente a categorías de negocio), ayuda a detectar temas emergentes, redundancia o categorías mal definidas.

El semi-supervisado intenta aprovechar grandes volúmenes de texto sin etiqueta junto con una pequeña cantidad de ejemplos etiquetados. Técnicas como self-training o pseudo-etiquetado permiten que un modelo entrenado con pocos datos genere etiquetas para datos no etiquetados, y luego se refine. Esto es útil cuando etiquetar es caro.

La transferencia es hoy un enfoque central: usar modelos preentrenados (transformers) y adaptarlos al dominio. Esto puede hacerse con fine-tuning (entrenar las últimas capas o todo el modelo con tu dataset) o con prompting (dar ejemplos en el prompt) cuando el modelo es suficientemente capaz. En escenarios con datos sensibles o dominio especializado, a veces se combina con RAG para aportar conocimiento y contexto al clasificador.

Finalmente, los enfoques basados en reglas siguen siendo relevantes en contextos donde se requiere alta interpretabilidad o cumplimiento estricto. Por ejemplo, clasificar tickets como “urgente” si contiene ciertas expresiones o códigos. Sin embargo, las reglas suelen ser frágiles frente a variaciones de lenguaje y requieren mantenimiento constante.

3.1.3 Algoritmos de categorización

Los algoritmos dependen mucho de la representación. Con representaciones TF-IDF, modelos lineales como SVM o regresión logística suelen ofrecer resultados muy fuertes, especialmente en textos con vocabulario distintivo. Naive Bayes también es un baseline clásico: asume independencia condicional entre palabras, lo cual no es realista, pero funciona sorprendentemente bien en muchos problemas, sobre todo con datasets medianos y vocabulario amplio.

Para tareas con múltiples etiquetas, se pueden entrenar clasificadores independientes (binary relevance) o usar cadenas de clasificadores que modelan dependencia entre etiquetas (classifier chains). En términos simples: si “facturación” y “pago” suelen co-ocurrir, un método que capture esa relación puede mejorar predicciones.

Los modelos neuronales clásicos (RNN, LSTM, CNN) fueron usados para capturar secuencias y patrones locales. Actualmente, los transformers dominan por su capacidad de representar contexto: entienden que el significado de una palabra depende de su entorno. Para clasificación, se toma una representación global del texto y se entrena una capa de salida. Estos modelos suelen mejorar desempeño en casos con ambigüedad semántica, ironía o variación de estilo, pero requieren más recursos y cuidado (sobreajuste, latencia, costos).

En producción, además del modelo, se emplean técnicas de calibración y umbrales. En multi-label, se decide un umbral por clase para convertir probabilidades en etiquetas. También se usan “top-k” (tomar las k etiquetas más probables) o estrategias mixtas que priorizan recall en clases críticas.

3.1.4 Medidas de evaluación

Evaluar clasificación de texto requiere más que accuracy, especialmente cuando hay desbalance. La precisión (precision) mide de lo que el modelo predice como positivo, cuánto es correcto; el recall mide de lo que realmente era positivo, cuánto detectó. El F1 combina ambos y es una métrica estándar. Para múltiples clases, se usan promedios macro y micro: macro trata todas las clases por igual (útil si importan clases raras), micro pondera por frecuencia (útil si importa el rendimiento global).

En multi-label, aparecen métricas específicas: Hamming loss mide el error promedio por etiqueta, subset accuracy exige que todas las etiquetas coincidan (muy estricta), y también se computan precision/recall/F1 por etiqueta y agregados. Si el modelo produce puntuaciones, se evalúa como ranking: MAP o NDCG, donde importa que las etiquetas correctas estén arriba.

Más allá de métricas numéricas, una evaluación práctica incluye inspección de falsos positivos y falsos negativos. Esto ayuda a detectar: etiquetas mal definidas, datos ruidosos, confusiones sistemáticas y sesgos. En dominios sensibles, también se evalúa robustez: desempeño ante cambios de vocabulario, ataques de ruido y drift temporal.

3.1.5 Estrategias de evaluación

Las estrategias de evaluación se centran en estimar qué tan bien generaliza el modelo. La división clásica es train/valid/test, donde test se reserva estrictamente para el final. En datasets pequeños, k-fold cross-validation reduce varianza y da una estimación más confiable. En datasets desbalanceados, los splits estratificados preservan proporciones de clase.

Un aspecto crítico es evitar fuga de información (data leakage). Por ejemplo, si hay documentos duplicados o muy similares y se reparten entre train y test, el modelo parecerá excelente sin realmente generalizar. Lo mismo ocurre si se construye el vocabulario TF-IDF usando todo el dataset antes de dividir: se filtra información del test hacia el entrenamiento. La regla práctica es: toda transformación que “aprenda” parámetros debe ajustarse con train y aplicarse a valid/test.

Para selección de hiperparámetros, se usa grid search o random search, y en redes neuronales se aplica early stopping para evitar sobreajuste. En modelos modernos, se recomienda también medir calibración (que la probabilidad refleje confianza real) y rendimiento operacional: latencia, memoria, costo por predicción. Esto es importante porque un modelo ligeramente mejor en F1 puede ser inviable si su costo es demasiado alto para producción.

Finalmente, una estrategia completa incluye evaluación cualitativa y monitoreo en producción. En producción, el lenguaje cambia, aparecen nuevos temas, y las etiquetas pueden evolucionar. Por eso se establecen pipelines de reentrenamiento, revisión de muestras difíciles, retroalimentación humana y medición de drift. En problemas de negocio, el “éxito” no siempre coincide con F1: a veces importa reducir tiempos, aumentar resolución o evitar errores críticos, por lo que se definen métricas alineadas al impacto real.

Tutorial NLTK (Español): Análisis de sentimientos

Objetivo

Hacer análisis de sentimientos (positivo/negativo/neutro) sobre una lista de textos tipo “twits”, usando NLTK en español.

Este tutorial incluye 2 enfoques:

  • Clasificador supervisado (Naive Bayes): el enfoque principal (el más útil con NLTK en español).
  • Baseline léxico (reglas simples): para comparar y entender limitaciones.

Requisitos

Python + NLTK

En una terminal, desde esta carpeta:

python3 -m pip install --upgrade pip
python3 -m pip install nltk

Datos (tus textos)

comentarios = [
    "El autocompletado de este IDE es bastante lento cuando el proyecto supera los mil archivos.",
    "La nueva actualización de la extensión de Python arruinó la indentación automática.",
    "Es increíble lo fácil que es configurar un entorno virtual con esta herramienta.",
    "El compilador tardó 45 segundos en generar el binario final.",
    "No entiendo por qué quitaron el botón de acceso rápido al depurador en esta versión.",
    "Me encanta que ahora se integre nativamente con Git sin necesidad de plugins extra.",
    "El modo oscuro de la interfaz tiene un contraste excelente para trabajar de noche.",
    "Siempre que intento abrir un archivo CSV muy grande, el editor se queda congelado.",
    "La refactorización de código se hace casi de manera mágica, me ahorra horas.",
    "El log de errores muestra un mensaje hexadecimal que no ayuda en nada a resolver el problema.",
    "Para esta práctica utilizaremos el framework de React en el frontend.",
    "La documentación oficial está desactualizada respecto a la última versión estable.",
    "Pude resolver el bug de concurrencia gracias a la herramienta de profiling.",
    "El atajo de teclado para comentar bloques de código dejó de funcionar tras actualizar.",
    "El consumo de RAM del entorno de desarrollo no baja de los 4GB, es excesivo.",
    "La sintaxis del lenguaje es muy limpia, pero la curva de aprendizaje del framework es empinada.",
    "Logré implementar el algoritmo de búsqueda en grafos sin utilizar recursividad.",
    "El soporte técnico del IDE tardó tres semanas en responder mi ticket.",
    "Configurar el linter por primera vez es tedioso, aunque después vale la pena.",
    "Se requiere una versión de Node igual o superior a la 18.x para ejecutar el script.",
    "El descriptor SIFT detectó los puntos clave perfectamente incluso con cambios de iluminación.",
    "El modelo de Deep Learning requiere demasiada capacidad de cómputo para lo que hace.",
    "Entrenar esta red neuronal tomó 14 horas continuas en la GPU.",
    "Prefiero usar técnicas clásicas de visión por computadora porque son más predecibles matemáticamente.",
    "La librería de OpenCV falló al intentar abrir el flujo de video de la cámara web.",
    "El dataset original contiene 5,000 imágenes etiquetadas manualmente.",
    "Increíble la precisión que logramos en la detección de bordes usando el filtro de Canny.",
    "El ruido gaussiano en las imágenes médicas está afectando la etapa de segmentación.",
    "El script de preprocesamiento normaliza los píxeles a valores entre 0 y 1.",
    "Los resultados del artículo no son reproducibles porque no publicaron los hiperparámetros.",
    "La API de reconocimiento facial es rapidísima, aunque falla con personas usando mascarilla.",
    "Extraer las características usando SURF fue mucho más eficiente que intentar una red convolucional.",
    "El accuracy del modelo se estancó en 82% sin importar cuántas épocas más lo entrene.",
    "Me fascina la elegancia matemática detrás de las transformadas de Hough.",
    "La función de binarización de Otsu calculó el umbral incorrecto para este lote de fotos.",
    "Reducir la dimensionalidad con PCA mejoró notablemente el tiempo de inferencia.",
    "El clasificador SVM separó las clases con un margen muy claro.",
    "La matriz de confusión muestra demasiados falsos positivos en la clase minoritaria.",
    "El tutorial sobre detección de blobs está muy bien explicado paso a paso.",
    "Al exportar el modelo a ONNX, el tamaño del archivo se redujo a la mitad.",
    "La base de datos relacional completó la migración sin pérdida de registros.",
    "El query de SQL está haciendo un escaneo completo de la tabla y colapsando el servidor.",
    "Excelente latencia en los servidores de la región este, apenas 12ms.",
    "Intentar configurar los contenedores de Docker en Windows sigue siendo un dolor de cabeza.",
    "El balanceador de carga distribuyó el tráfico equitativamente durante el pico de usuarios.",
    "La API REST responde con un error 500 intermitente que nadie sabe explicar.",
    "Me gusta la interfaz de administración del clúster, pero le faltan opciones de monitoreo en tiempo real.",
    "El backup automático se ejecuta todos los días a las 3:00 AM.",
    "Perdimos dos horas de producción porque el certificado SSL expiró y no hubo alerta.",
    "La arquitectura de microservicios hizo que el despliegue fuera extremadamente ágil.",
    "No puedo creer que sigan guardando las contraseñas en texto plano en la base de datos.",
    "El servicio de mensajería asíncrona procesó un millón de eventos por minuto sin problemas.",
    "La conexión al servidor SSH se cierra por timeout cada cinco minutos, es frustrante.",
    "El caché de Redis redujo el tiempo de carga de la página de inicio en un 80%.",
    "El script de automatización borró accidentalmente el directorio de configuraciones.",
    "Desplegar la aplicación en la nube fue mucho más caro de lo que habíamos presupuestado.",
    "La migración a GraphQL simplificó muchísimo las consultas desde las aplicaciones móviles.",
    "El uso de CPU del servidor de base de datos se mantiene estable en 45%.",
    "Restaurar el sistema desde el último snapshot fue rápido e intuitivo.",
    "Las políticas de CORS en este servidor están configuradas de manera demasiado restrictiva.",
    "La rúbrica del proyecto final no especifica si debemos incluir pruebas unitarias.",
    "El profesor explicó maravillosamente el concepto de complejidad algorítmica.",
    "La impresora 3D se atascó a mitad de la noche y arruinó una pieza de 10 horas.",
    "El laminador Cura generó unos soportes imposibles de remover sin romper el modelo.",
    "Las instrucciones de la práctica de laboratorio están llenas de ambigüedades.",
    "Me sorprende la calidad del acabado superficial al imprimir con PETG a esta temperatura.",
    "Reparar el archivo STL con MeshLab fue súper sencillo y rápido.",
    "El examen parcial abarcará desde el capítulo 1 hasta el 4 del libro de texto.",
    "El sistema de gestión escolar se cae siempre en temporada de inscripciones.",
    "Me encantó cómo el asesor de tesis estructuró el cronograma de investigación.",
    "La presentación sobre descriptores visuales me ayudó a entender por qué no todo es Deep Learning.",
    "El aula virtual no permite adjuntar archivos mayores a 5MB, lo cual es inútil para código compilado.",
    "La cama caliente de la impresora no alcanza los 80 grados necesarios para ABS.",
    "El código que compartieron en el repositorio de la clase compila a la primera.",
    "Las calificaciones del primer corte ya fueron publicadas en el portal institucional.",
    "Aunque la teoría fue densa, los ejercicios prácticos aterrizaron muy bien los conceptos.",
    "El filamento se rompió por exceso de humedad dentro del extrusor.",
    "Exportar el documento de Org-mode a PDF con LaTeX dio un formato impecable.",
    "La retroalimentación del código fue dura pero extremadamente constructiva.",
    "No tuvimos suficiente tiempo en el laboratorio para terminar de armar el circuito.",
    "La aplicación móvil se cierra sola al intentar usar la cámara en Android 13.",
    "El nuevo diseño del dashboard es moderno y muy intuitivo para el usuario final.",
    "La resolución del monitor secundario no se ajusta correctamente en Linux.",
    "Escribir la documentación técnica en Markdown me resulta muy productivo.",
    "El parche de seguridad solucionó la vulnerabilidad, pero rompió la compatibilidad hacia atrás.",
    "El paquete de instalación incluye un script de desinstalación limpia.",
    "Es la tercera vez esta semana que la VPN corporativa me desconecta sin motivo.",
    "El gestor de dependencias resolvió todos los conflictos de versiones automáticamente.",
    "La animación de carga es fluida y hace que la espera se sienta mucho menor.",
    "Los atajos de teclado me permiten editar texto sin quitar las manos de la posición base.",
    "El sistema reporta una fuga de memoria cada vez que se instancia esa clase.",
    "La actualización del firmware del router tomó menos de cinco minutos.",
    "Trabajar con expresiones regulares largas a menudo resulta en código difícil de mantener.",
    "El modo de depuración paso a paso es la mejor característica de este software.",
    "La batería del teclado inalámbrico dura casi seis meses con uso diario.",
    "Los colores de la terminal por defecto son difíciles de leer contra un fondo oscuro.",
    "Implementar el patrón Singleton aquí resolvió el problema de instancias múltiples.",
    "El foro de la comunidad resolvió mi duda técnica mucho más rápido que el manual oficial.",
    "El framework de pruebas genera reportes de cobertura en formato HTML detallado.",
    "El diseño responsivo de la web falla completamente al visualizarse en tablets en modo horizontal."
]
print("Cantidad de textos:", len(comentarios))

Paso 1: Descarga de recursos NLTK

NLTK requiere descargar algunos recursos (tokenizadores y stopwords).

import nltk

def ensure_nltk_resources():
    for resource in ("punkt", "stopwords"):
        try:
            nltk.data.find(f"tokenizers/{resource}" if resource == "punkt" else f"corpora/{resource}")
        except LookupError:
            nltk.download(resource, quiet=False)

ensure_nltk_resources()
print("NLTK OK.")

Paso 2: Preprocesamiento (normalización, tokenización, stopwords, stemming)

Normalización + features

Vamos a crear un “featurizer” (convierte texto → diccionario de features) que se usa para entrenar y predecir.

import re
from nltk.corpus import stopwords
from nltk.stem.snowball import SnowballStemmer
from nltk.tokenize import wordpunct_tokenize

_URL_RE = re.compile(r"https?://\\S+|www\\.\\S+")
_MENTION_RE = re.compile(r"@\\w+")
_HASHTAG_RE = re.compile(r"#\\w+")
_NON_LETTER_RE = re.compile(r"[^a-záéíóúñü0-9\\s]+", re.IGNORECASE)
_MULTISPACE_RE = re.compile(r"\\s+")

def normalize_text(text: str) -> str:
    t = text.strip().lower()
    t = _URL_RE.sub(" ", t)
    t = _MENTION_RE.sub(" ", t)
    t = _HASHTAG_RE.sub(" ", t)
    t = _NON_LETTER_RE.sub(" ", t)
    t = _MULTISPACE_RE.sub(" ", t).strip()
    return t

sw = set(stopwords.words("spanish"))
stemmer = SnowballStemmer("spanish")

def featurize(text: str) -> dict:
    t = normalize_text(text)
    tokens = wordpunct_tokenize(t)
    feats = {}
    for tok in tokens:
        if tok.isdigit():
            feats["HAS_NUMBER"] = True
            continue
        if len(tok) < 2:
            continue
        if tok in sw:
            continue
        stem = stemmer.stem(tok)
        feats[f"w={stem}"] = True
    feats["LEN_GT_120"] = len(t) > 120
    feats["HAS_EXCLAMATION"] = "!" in text
    feats["HAS_NEGATION"] = any(w in t.split() for w in ("no", "nunca", "jamás", "ni"))
    return feats

print("Ejemplo normalizado:")
print(normalize_text(comentarios[0]))
print("Features (muestra):", list(featurize(comentarios[0]).keys())[:12])

Paso 3 (baseline): Sentimiento por léxico simple (reglas)

Este enfoque NO es “inteligente”; sirve para tener un punto de comparación rápido.

POS_WORDS = {
    "increíble","fácil","me encanta","excelente","mágica","ahorra","precisión","maravillosamente",
    "rápido","intuitivo","productivo","constructiva","fluida","mejor","elegancia","perfectamente",
}
NEG_WORDS = {
    "lento","arruinó","congelado","no entiendo","no ayuda","desactualizada","dejó de funcionar",
    "excesivo","tardó","dolor","error","colapsando","inútil","expiró","texto plano",
    "frustrante","borró","caro","restrictiva","ambigüedades","se cae","no permite",
}

def lexicon_sentiment(text: str) -> str:
    t = normalize_text(text)
    score = 0
    for w in POS_WORDS:
        if w in t:
            score += 1
    for w in NEG_WORDS:
        if w in t:
            score -= 1
    if score > 0:
        return "pos"
    if score < 0:
        return "neg"
    return "neu"

for i in range(5):
    print(i, lexicon_sentiment(comentarios[i]), "-", comentarios[i])

Paso 4 (principal): Clasificador supervisado Naive Bayes (NLTK)

NLTK no trae un “modelo listo” de sentimiento en español, por eso usamos un clasificador supervisado.

4.1 Dataset mínimo de entrenamiento (amplíalo)

Puedes editar estos ejemplos cuando quieras. Mientras más ejemplos (50–200), mejor.

import nltk

train_data = [
    ("Me encanta que ahora se integre nativamente con Git", "pos"),
    ("El modo oscuro de la interfaz tiene un contraste excelente", "pos"),
    ("La refactorización de código se hace casi de manera mágica", "pos"),
    ("Es increíble lo fácil que es configurar un entorno virtual", "pos"),
    ("Excelente latencia en los servidores", "pos"),
    ("El autocompletado de este IDE es bastante lento", "neg"),
    ("La nueva actualización arruinó la indentación automática", "neg"),
    ("El editor se queda congelado", "neg"),
    ("No entiendo por qué quitaron el botón", "neg"),
    ("No ayuda en nada a resolver el problema", "neg"),
    ("El consumo de RAM es excesivo", "neg"),
    ("La API REST responde con un error 500", "neg"),
    ("Perdimos dos horas de producción porque el certificado SSL expiró", "neg"),
    ("El script borró accidentalmente el directorio de configuraciones", "neg"),
    ("Para esta práctica utilizaremos React en el frontend", "neu"),
    ("Se requiere una versión de Node igual o superior a la 18", "neu"),
    ("El dataset original contiene 5,000 imágenes etiquetadas", "neu"),
    ("El backup automático se ejecuta todos los días a las 3:00 AM", "neu"),
    ("El uso de CPU se mantiene estable en 45%", "neu"),
    ("El examen parcial abarcará desde el capítulo 1 hasta el 4", "neu"),
]

train_set = [(featurize(text), label) for text, label in train_data]
classifier = nltk.NaiveBayesClassifier.train(train_set)
print("Entrenamiento OK. Ejemplos:", len(train_set))
  • 4.2 Predicción sobre tus textos

    Generamos un CSV con: texto, etiqueta, probabilidad.

    import csv
    
    out_rows = []
    for text in comentarios:
        feats = featurize(text)
        dist = classifier.prob_classify(feats)
        label = dist.max()
        prob = float(dist.prob(label))
        out_rows.append((text, label, prob))
    
    out_path = "sentimientos_resultados.csv"
    with open(out_path, "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow(["texto", "sentimiento", "probabilidad"])
        for row in out_rows:
            w.writerow(row)
    
    print("Clasificación terminada.")
    print("Archivo generado:", out_path)
    print()
    print("Muestra (primeros 10):")
    for i, (t, lab, p) in enumerate(out_rows[:10]):
        print(f"{i:02d}  {lab}  p={p:.3f}  {t}")
    

4.3 Features más informativas

Esto te ayuda a entender qué palabras (stems) está usando el clasificador.

classifier.show_most_informative_features(15)

Siguientes mejoras (recomendadas)

  • Agrega más ejemplos a `traindata` (idealmente 50–200).
  • Incluye más frases “neutras” (descriptivas) para que no todo caiga en pos/neg.

(NLP): 3.2 Sentimientos, 3.3 Agrupamiento, 3.4 Embeddings y GPT-X

3.2 Análisis de sentimientos

¿Qué es?

El análisis de sentimientos busca inferir la polaridad o actitud expresada en un texto. Dependiendo del problema, puede formularse como:

  • Clasificación (p. ej. positivo/negativo/neutro).
  • Regresión (p. ej. puntuación de -1 a 1, o 1 a 5 estrellas).
  • Detección de emoción (alegría, ira, tristeza, miedo, sorpresa, etc.).
  • Aspect-based sentiment analysis (ABSA): sentimiento por aspecto (p. ej. “batería: negativa”, “pantalla: positiva”).

¿Para qué se usa?

  • Atención al cliente: priorizar tickets según urgencia/enojo y detectar temas recurrentes.
  • Monitoreo de marca: medir percepción en redes.
  • Producto/UX: entender qué duele y qué gusta en reseñas.
  • Riesgo/seguridad: detectar hostilidad, abuso o señales de crisis (con cuidado ético).

Flujo típico (pipeline)

  1. Definir etiquetas y criterio: ¿neutro existe?, ¿qué es “positivo”?, ¿hay ironía?, ¿qué hacer con ambivalencias?
  2. Recolección y etiquetado: muestreo representativo por dominio (redes, reseñas, encuestas).
  3. Preprocesamiento (según modelo):
    • Normalizar (minúsculas, URLs, menciones, emojis), tokenizar.
    • Mantener señales útiles: “!!”, MAYÚSCULAS, negación (“no”), repetición (“bueeeno”), emojis.
  4. Representación:
    • Clásico: bag of words, n-gramas, TF-IDF.
    • Moderno: embeddings (word/sentence) o transformers.
  5. Modelo:
    • Léxico/reglas, ML clásico (SVM/logreg), deep learning (LSTM/CNN), transformers (BERT/RoBERTa).
  6. Evaluación:
    • Conjunto de prueba separado, baseline claro, análisis de errores.
  7. Despliegue y monitoreo:
    • Deriva de datos (cambios de jerga), drift por eventos, sesgos, re-entrenamiento.

Enfoques principales

  • 1) Léxico + reglas (baseline)

    Se usa una lista de palabras con peso (positivo/negativo) y reglas simples (negación, intensificadores).

    • Ventajas: rápido, interpretable, útil como baseline.
    • Desventajas: se rompe por contexto, sarcasmo, dominio, polisemia.
  • 2) Aprendizaje supervisado (ML clásico)

    Se entrena un clasificador con ejemplos etiquetados.

    • Representación: TF-IDF con unigramas/bigramas suele funcionar sorprendentemente bien.
    • Modelos: Regresión logística, SVM lineal, Naive Bayes.
    • Ventajas: fuerte baseline, eficiente.
    • Desventajas: requiere datos etiquetados; generaliza peor a cambios de dominio.
  • 3) Deep learning / Transformers

    Los transformers (BERT y derivados) dominan por entender contexto.

    • Ventajas: mejor desempeño, maneja contexto/negación mejor.
    • Desventajas: más costo computacional; riesgo de sobreajuste con pocos datos; requiere cuidado en despliegue.

Métricas y evaluación

  • Accuracy: útil si clases balanceadas.
  • Precision/Recall/F1 (macro y micro): recomendado en clases desbalanceadas.
  • Matriz de confusión: para ver confusiones típicas (p. ej. neutro vs negativo).
  • AUC (si binario y se usan probabilidades).

Buenas prácticas:

  • Separar por tiempo si habrá despliegue real (evita fuga de información).
  • Reportar macro-F1 si hay desbalance.
  • Hacer análisis de errores: negación, ironía, ambigüedad, jerga, spanglish.

Retos comunes (muy importantes)

  • Sarcasmo/ironía: “¡Qué buen servicio!” (cuando es queja).
  • Negación y alcance: “no está mal” no es igual a “mal”.
  • Dominio: “está pesado” puede ser malo (app lenta) o bueno (música).
  • Lenguaje informal: emojis, abreviaturas, faltas, alargamientos (“bueeeno”).
  • Sesgo: datos no representativos (muestra de redes vs población).

Ejemplo mínimo (conceptual) en Python con TF-IDF + logística

Este bloque es opcional (si tienes `scikit-learn` instalado).

texts = [
    "Me encanta la interfaz, es muy rápida",
    "La app se congela y es desesperante",
    "Cumple lo que promete, nada especial",
]
labels = ["pos", "neg", "neu"]
print("Ejemplos:", len(texts))
from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression

clf = Pipeline([
    ("tfidf", TfidfVectorizer(ngram_range=(1,2), min_df=1)),
    ("lr", LogisticRegression(max_iter=1000)),
])
clf.fit(texts, labels)

pred = clf.predict(["No está mal, pero podría ser más rápido"])
print("Predicción:", pred[0])

3.3 Agrupamiento de texto (clustering)

¿Qué es y por qué importa?

El agrupamiento de texto busca organizar documentos en grupos (clusters) donde los elementos del mismo grupo sean más similares entre sí que con los de otros grupos, sin etiquetas previas.

Usos típicos:

  • Descubrir temas en grandes colecciones (tickets, reseñas, correos).
  • Deduplicación (detectar textos casi iguales).
  • Exploración y búsqueda (navegación por categorías emergentes).
  • Segmentación de usuarios por opiniones o necesidades.

Paso cero: representación (lo más crítico)

Clustering depende casi por completo de la representación vectorial.

Opciones comunes:

  • BoW / TF-IDF:
    • Bueno para textos cortos-medianos y vocabularios estables.
    • Alta dimensionalidad; suele requerir reducción (SVD/LSA).
  • Embeddings:
    • Sentence embeddings (p. ej. SBERT) funcionan muy bien para similitud semántica.
    • Menos dimensionalidad, mejor para sinonimia/paráfrasis.

Algoritmos más usados

  • K-means (y mini-batch K-means)
    • Requiere elegir K (número de clusters).
    • Funciona bien con TF-IDF y distancia coseno (normalizando) o euclídea con cuidado.
    • Escalable y rápido.
  • Clustering jerárquico (agglomerative)
    • No requiere fijar K exacto de inicio (se puede cortar dendrograma).
    • Interpretabilidad por jerarquía.
    • Más costoso en grandes volúmenes.
  • DBSCAN / HDBSCAN (densidad)
    • Detecta outliers/ruido y clusters de forma irregular.
    • Muy útil con embeddings semánticos.
    • Sensible a hiperparámetros (eps/minsamples).
  • Modelos de temas (relacionados, no idénticos)
    • LDA/NMF: “temas” como distribuciones de palabras; útil para interpretación.
    • A menudo se usa como exploración temática más que clustering estricto.

¿Cómo elegir K? (si aplica)

  • Método del codo (inercia) y silhouette (ojo: silhouette se puede engañar en alta dimensión).
  • Considerar la utilidad: “¿K=20 clusters me ayuda a tomar decisiones?”.
  • Validación humana: revisar muestras representativas por cluster y ajustar.

Evaluación (sin etiquetas)

No hay una sola métrica perfecta; combina:

  • Intrínseca: silhouette, Davies–Bouldin, Calinski–Harabasz (orientativas).
  • Estabilidad: ¿los clusters cambian mucho si re-muestreas?
  • Coherencia semántica: revisión humana, top términos por cluster.
  • Extrínseca: ¿mejora un objetivo? (tiempo de triage, tasa de resolución, etc.).

Buenas prácticas

  • Normalizar y limpiar sin borrar señales semánticas.
  • Para TF-IDF: usar n-gramas y filtrar términos muy raros o demasiado frecuentes.
  • Reducir dimensionalidad (TruncatedSVD/UMAP) antes de ciertos algoritmos o para visualizar.
  • Nombrar clusters con:
    • top términos (TF-IDF promedio) o
    • ejemplos prototípicos (documentos cercanos al centroide).

Ejemplo mínimo (conceptual) con TF-IDF + K-means

docs = [
    "No puedo iniciar sesión, me marca error 500",
    "La contraseña no funciona y no llega el correo de recuperación",
    "La app va lenta y se traba cuando abro reportes",
    "Quiero factura con RFC y datos fiscales",
    "Necesito descargar mi factura del mes pasado",
]
print("Docs:", len(docs))
docs = [
    "No puedo iniciar sesión, me marca error 500",
    "La contraseña no funciona y no llega el correo de recuperación",
    "La app va lenta y se traba cuando abro reportes",
    "Quiero factura con RFC y datos fiscales",
    "Necesito descargar mi factura del mes pasado",
]
print("Docs:", len(docs))


from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import KMeans

X = TfidfVectorizer(ngram_range=(2,2), min_df=1).fit_transform(docs)
kmeans = KMeans(n_clusters=3, n_init="auto", random_state=42)
labels = kmeans.fit_predict(X)

for d, c in zip(docs, labels):
    print(c, "-", d)

3.4 Tecnologías emergentes: word embeddings (ELMo, BERT) y GPT-X

3.4.1 Representaciones distribucionales (embeddings) en NLP

Un embedding representa unidades lingüísticas como vectores densos donde la geometría captura relaciones semánticas/sintácticas.

Idea base (distribucional): “*Una palabra se conoce por la compañía que mantiene*”.

3.4.2 Embeddings estáticos vs contextuales

  • Embeddings estáticos (una palabra = un vector)

    Ejemplos: Word2Vec, GloVe, fastText.

    • Ventajas: ligeros, rápidos, útiles como features.
    • Desventajas: no distinguen sentidos (p. ej. “banco” = riverbank vs institución).

    fastText ayuda con sub-palabras:

    • mejor para morfología y palabras raras/errores (“cansadísimo”, “cansadisimo”).
  • Embeddings contextuales (depende de la oración)

    Aquí el vector de una palabra cambia según el contexto:

    • “banco” en “me senté en el banco” vs “fui al banco a depositar”.

3.4.3 ELMo (Embeddings from Language Models)

ELMo genera embeddings contextuales usando un modelo de lenguaje bi-direccional basado en LSTM.

  • Produce representaciones por capa que capturan distinta información (sintaxis vs semántica).
  • Fue un salto grande antes de los transformers, pero hoy suele reemplazarse por BERT y variantes.

3.4.4 BERT (Bidirectional Encoder Representations from Transformers)

BERT es un encoder transformer entrenado con objetivos auto-supervisados (clásicamente: enmascarar tokens y predecirlos). Ideas clave:

  • Auto-atención: cada token “mira” a los demás para construir significado.
  • Bidireccionalidad: usa contexto izquierdo y derecho simultáneamente (ideal para clasificación/extracción).

Casos de uso típicos:

  • Clasificación (sentimientos, intención).
  • NER (entidades) y extracción.
  • QA extractivo (responder señalando un span en un texto).

Prácticas modernas con BERT:

  • Fine-tuning supervisado con tu dataset.
  • Alternativa: embeddings de oraciones (SBERT) para similitud y clustering.

3.4.5 GPT-X (familia GPT y modelos generativos tipo decoder)

Los modelos tipo GPT son decoders transformer entrenados para predecir el siguiente token. Esto los hace muy fuertes en:

  • Generación de texto.
  • Resumen y reescritura.
  • Traducción (vía prompting).
  • Código y razonamiento asistido (con límites).

Conceptos clave:

  • Pre-entrenamiento: aprende regularidades del lenguaje a gran escala.
  • Instrucción (instruction tuning): aprende a seguir órdenes.
  • Alineamiento: reducir conductas no deseadas (p. ej. RLHF/otros).

3.4.6 ¿Cómo se usan en la práctica? (patrones actuales)

  • Prompting (sin entrenamiento)

    Cuando tienes pocos datos o quieres prototipar:

    • Definir rol/objetivo.
    • Dar ejemplos (few-shot).
    • Exigir formato de salida (JSON, tabla, etc.).
  • Fine-tuning (con datos)

    Útil si necesitas:

    • Estilo/terminología muy específica.
    • Mejor desempeño en una tarea repetitiva y bien definida.
  • RAG (Retrieval-Augmented Generation)

    Combina búsqueda + generación:

    • Recuperas documentos relevantes (vector search) y el modelo responde apoyándose en ese contexto.
    • Reduce alucinaciones y adapta a conocimiento privado/actual.

3.4.7 Riesgos y consideraciones

  • Alucinación: el modelo puede “inventar” datos.
  • Privacidad: cuidado con texto sensible en prompts/logs.
  • Sesgos: reflejan sesgos del entrenamiento.
  • Evaluación: no basta con “se ve bien”; se requieren métricas y casos de prueba.

3.4.8 Mapa rápido: ¿qué usar y cuándo?

  • Si quieres baseline fuerte y barato en sentimientos: TF-IDF + logística/SVM.
  • Si quieres alto desempeño en sentimientos: fine-tuning de un transformer (BERT/RoBERTa).
  • Si quieres clustering semántico: sentence embeddings (tipo SBERT) + HDBSCAN/K-means.
  • Si quieres resumir, clasificar con reglas complejas o extraer info sin mucho entrenamiento: GPT-X con prompting (y si hay docs, RAG).

Clustering de texto con K-Means (tweets/comentarios y documentos grandes)

¿Qué es “clasificar” con clusters?

En textos tipo X/Facebook/Instagram, a veces se dice “clasificar” cuando en realidad se busca:

  • Clustering (no supervisado): agrupar textos por similitud sin etiquetas.
  • Sentimiento (supervisado): etiquetar como positivo/negativo (requiere labels).

Esta guía se centra en clustering con K-Means y, al final, muestra una forma práctica de “traducir” clusters a (positivo/negativo) con una pequeña capa humana (si lo necesitas).

Teoría de K-Means

K-Means es un algoritmo de clustering particional que busca dividir \(n\) puntos en \(K\) grupos. Cada grupo se representa por un centroide (un vector “promedio”).

¿Qué optimiza?

Dados puntos \(x_1,\dots,x_n \in \mathbb{R}^d\), K-Means busca minimizar la suma de distancias cuadráticas al centroide del cluster asignado:

\[ \min_{\{C_k\}_{k=1}^K}\ \sum_{k=1}^K \sum_{x_i \in C_k} \lVert x_i - \mu_k \rVert_2^2 \quad\text{donde}\quad \mu_k = \frac{1}{|C_k|}\sum_{x_i \in C_k} x_i \]

Esta cantidad se conoce como inercia (en scikit-learn: `inertia_`).

Qué significa: K-Means quiere clusters donde los puntos estén “compactos” alrededor de su centroide (en distancia euclídea).

Lectura intuitiva del objetivo

  • Si un documento cae “lejos” de su centroide, contribuye mucho a la inercia (por el cuadrado).
  • Por eso, K-Means suele “preferir” crear un cluster separado para grupos de puntos que estén bien apartados.
  • Si fuerzas un \(K\) muy pequeño, K-Means meterá temas distintos en el mismo cluster y la inercia subirá.

¿Por qué distancia al cuadrado? La distancia al cuadrado tiene dos efectos prácticos:

  • penaliza fuerte errores grandes (outliers),
  • permite que el mejor representante del cluster (en este criterio) sea el promedio (centroide).

¿Cómo funciona? (algoritmo de Lloyd, iterativo)

Se alternan dos pasos hasta converger:

  • Asignación: asignar cada punto al centroide más cercano.
  • Actualización: recomputar cada centroide como el promedio de los puntos asignados.

Cada iteración no puede empeorar la función objetivo (la inercia baja o se queda igual), por eso siempre converge a un mínimo local.

Paso a paso (qué pasa “en la práctica”) Imagina que ya tienes \(K\) centroides “propuestos”:

  1. Asignación: para cada documento, se calcula su distancia a cada centroide y se asigna al más cercano.
  2. Re-cálculo del centroide: para cada cluster, se promedian los vectores de sus documentos. Ese promedio “se mueve” hacia donde hay más masa de puntos.
  3. Se repite: al moverse los centroides, cambian distancias, por lo tanto pueden cambiar asignaciones.

Con el tiempo, las asignaciones dejan de cambiar (o cambian muy poco) y el algoritmo se detiene.

Por qué siempre baja (o no sube) la inercia

  • Si fijas los centroides, la mejor asignación es “al más cercano” (eso minimiza el término de distancia por punto).
  • Si fijas las asignaciones, el mejor centroide (que minimiza SSE) es el promedio del cluster.

Al alternar ambos pasos, no empeoras el valor objetivo.

Mínimo local (por qué no es “el óptimo global”) La función objetivo no es convexa respecto a asignaciones + centroides. Eso implica:

  • distintos arranques pueden terminar en particiones distintas,
  • algunas particiones son claramente mejores (menor inercia) que otras,
  • por eso conviene `ninit` (varios arranques) y k-means++.

Inicialización (por qué importa)

Como converge a mínimos locales, el resultado depende del arranque:

  • Random: centroides aleatorios (más riesgo de soluciones malas).
  • k-means++: elige centroides iniciales separados; suele mejorar estabilidad y calidad.

En scikit-learn, `KMeans` usa k-means++ por defecto. `ninit` controla cuántos arranques se prueban y se toma el mejor (menor inercia).

Criterios de parada (convergencia)

Se detiene cuando:

  • las asignaciones ya no cambian, o
  • el movimiento de centroides es muy pequeño (`tol`), o
  • se alcanza `maxiter`.

Qué significa converger en términos de clusters

  • Si las asignaciones no cambian, ya no hay un “intercambio” de puntos entre clusters que reduzca el objetivo.
  • Aun así, puede haber varias soluciones “parecidas” con inercia similar (por eso la estabilidad es útil).

Complejidad (intuición práctica)**

Si \(n\)=documentos, \(d\)=dimensión, \(K\)=clusters, \(I\)=iteraciones: \[ O(n \cdot K \cdot d \cdot I) \] En texto, \(d\) puede ser enorme (vocabulario), por eso `MiniBatchKMeans` suele ser mejor cuando hay muchos textos.

MiniBatchKMeans: por qué acelera* En vez de usar todos los puntos para actualizar centroides en cada iteración, usa minibatches (muestras pequeñas):

  • reduce costo computacional,
  • converge a una solución similar,
  • suele ser suficiente para datasets grandes de texto.

Supuestos implícitos y limitaciones

K-Means tiende a funcionar mejor cuando:

  • los clusters son aproximadamente “esféricos” (en euclídea),
  • tienen tamaños parecidos,
  • y la métrica euclídea tiene sentido para tus vectores.

Limitaciones típicas:

  • sensible a outliers (puntos raros pueden arrastrar centroides),
  • sufre con clusters no convexos (formas raras),
  • elegir \(K\) no es automático,
  • si hay features con escalas distintas, domina la escala más grande (en texto se mitiga con TF-IDF + normalización).

Problemas típicos que verás en texto

  • Un cluster “basura” con stopwords o muletillas (“hola”, “buenas”, “gracias”) si no filtras bien.
  • Clusters por estilo (uso de emojis, mayúsculas, alargamientos “buenoooo”) en vez de tema.
  • Con documentos largos, clusters por longitud si no normalizas o si TF domina.

K-Means aplicado a texto

En texto no trabajas con coordenadas “físicas”, sino con representaciones:

  • TF-IDF (sparse, alta dimensión)
  • embeddings (densos)

TF-IDF (qué representa)**

TF-IDF pondera términos por:

  • TF: frecuencia en el documento
  • IDF: rareza global (si aparece en muchos docs, pesa menos)

Resultado: cada documento es un vector donde “suben” palabras distintivas.

Por qué TF-IDF suele ser mejor que conteos crudos para clustering Con conteos crudos, palabras comunes dominan (“de”, “la”, “que” o términos de dominio muy frecuentes). TF-IDF baja automáticamente el peso de términos que aparecen en casi todos los documentos, y por eso:

  • mejora la separación por “temas”,
  • reduce clusters dominados por vocabulario genérico.

Coseno vs euclídea (por qué aparece coseno en NLP)

En texto, muchas veces importa más el ángulo entre vectores (dirección) que su magnitud:

  • Similaridad coseno: \(\cos(\theta) = \frac{x\cdot y}{\lVert x\rVert\lVert y\rVert}\)

Si tus vectores están normalizados a norma 1 (L2), entonces: \[ \lVert x-y\rVert_2^2 = 2 - 2\cos(\theta) \] Es decir: minimizar euclídea en vectores L2-normalizados equivale a maximizar coseno (ordenan igual).

Por eso K-Means con TF-IDF (que normalmente ya viene normalizado) suele funcionar razonablemente.

Detalle importante: normalización Si NO normalizas (o cambias `norm=None`):

  • documentos más largos tienden a tener vectores de mayor magnitud,
  • eso puede sesgar distancias y agrupar por “tamaño” más que por contenido.

Por eso es común mantener `norm='l2'` (default en `TfidfVectorizer`).

Textos cortos vs documentos largos

  • Cortos (tweets/comentarios): pocas palabras ⇒ señales débiles; ayuda:
    • bigramas (capturan frases)
    • char n-grams (robustos a faltas/variantes)
  • Largos: mezclan subtemas ⇒ conviene:
    • reducir dimensión (LSA/SVD)
    • o segmentar por párrafos/oraciones antes de clusterizar si buscas temas finos

Interpretación teórica de “qué representa un centroide” en texto

En K-Means el centroide \(\mu_k\) es el promedio de los vectores del cluster. En texto (TF-IDF):

  • cada dimensión \(\mu_{k,j}\) corresponde a un término (o n-grama),
  • un valor alto \(\mu_{k,j}\) sugiere que ese término aparece con TF-IDF alto en muchos documentos del cluster.

Cómo leerlo en palabras

  • “Términos top del centroide” ≈ vocabulario más característico del cluster.
  • No significa que todos los documentos tengan todas esas palabras, sino que en promedio empujan al centroide en esa dirección.

Por qué a veces confunde

  • Si hay términos muy correlacionados o muy frecuentes, pueden aparecer como top en varios clusters.
  • En documentos largos, un término puede ser “tema transversal” y no discriminar.
  • Por eso se recomienda SIEMPRE acompañar términos top con documentos representativos.

Pipeline recomendado (funciona muy bien como baseline)

  1. Recolectar textos (columna `text` en CSV, por ejemplo).
  2. Limpiar ligero (URLs, @menciones, #hashtags opcional).
  3. Vectorizar:
    • Para textos cortos: TF-IDF con word n-grams (1–2) y/o char n-grams (3–5).
    • Para documentos largos: TF-IDF + TruncatedSVD (LSA) para reducir dimensión.
  4. Clusterizar:
    • `MiniBatchKMeans` si tienes muchos textos (rápido y estable).
    • `KMeans` si son pocos (simple).
  5. Interpretar:
    • términos top por cluster
    • ejemplos representativos por cluster (los más cercanos al centroide)
  6. Elegir K con codo/silhouette + interpretabilidad.

Cómo interpretar un cluster

Interpretar no es solo “ver palabras top”; lo recomendable es combinar:

Tamaño del cluster

  • Un cluster gigante puede ser “tema general” o ruido/stopwords insuficientes.
  • Clusters minúsculos pueden ser outliers, spam, o un subtema real (depende del caso).

Términos representativos

Con TF-IDF + K-Means:

  • el centroide es un vector con pesos por término.
  • los términos con mayor peso en el centroide suelen describir “de qué va” el cluster.

Ojo: si un término muy frecuente domina todos los clusters, probablemente:

  • te faltan stopwords (de dominio)
  • `maxdf` está muy alto

Documentos representativos*

Es la forma más confiable de entender el cluster:

  • lee 5–20 documentos más cercanos al centroide
  • anota “qué tienen en común”
  • ponle un nombre breve al cluster (tema/intención)

Separación y ambigüedad

Si dos clusters comparten muchos términos top y ejemplos similares:

  • quizá \(K\) es muy alto, o
  • el preprocesamiento/vectorización no está capturando bien diferencias.

Si un cluster mezcla dos temas muy distintos:

  • quizá \(K\) es muy bajo, o
  • documentos largos están mezclando subtemas (considera trocear).

Métricas: qué sí miden y qué NO

Inercia (SSE / within-cluster)

  • Siempre baja cuando subes \(K\).
  • Sirve para el “codo”: el punto donde agregar clusters mejora poco.
  • No te garantiza interpretabilidad.

Silhouette

Mide si un punto está más cerca de su cluster que de otros.

  • Alto (cerca de 1): bien separado.
  • Cerca de 0: solapado.
  • Negativo: asignaciones dudosas.

En texto, no siempre es alto aunque el resultado sea útil; úsalo como guía, no como juez único.

Estabilidad (muy recomendada)

Una validación muy práctica:

  • corre K-Means con varios `randomstate`/`ninit`
  • mira si términos top y ejemplos por cluster se parecen

Si cambia muchísimo:

  • el problema quizá no tiene clusters fuertes
  • o necesitas mejor vectorización (char n-grams, SVD, embeddings, etc.)

“Proceso mental” recomendado

Si quieres una base sólida al aplicarlo, sigue esta rutina:

  1. Define la noción de similitud: ¿quieres clusters por tema (política/deportes), por intención (queja/elogio), por spam, por marca?
  2. Muestra de lectura: lee 100–200 textos al azar. Anota patrones (emojis, URLs, bots, repetidos).
  3. Baseline simple: TF-IDF word (1,2) + MiniBatchKMeans con un K moderado (8–15).
  4. Interpretación: para cada cluster, revisa términos top + 10 textos representativos.
  5. Ajuste del vectorizador:
    • si ves clusters por ortografía: agrega char n-grams
    • si ves términos genéricos: sube `mindf`, baja `maxdf`, agrega stopwords de dominio
  6. Ajuste de K:
    • si clusters mezclan temas: sube K
    • si clusters se duplican: baja K
  7. Valida estabilidad: cambia `randomstate` y revisa si se sostiene la estructura.
  8. Exporta y usa: con `cluster` puedes hacer analítica, dashboards, muestreo por cluster, etc.

Instalación (dependencias mínimas)

python -m pip install -U scikit-learn pandas numpy

Script 1 (tweets/comentarios): TF-IDF + MiniBatchKMeans + explicación de clusters

Este script:

  • Lee `input.csv` con columna `text`
  • Limpia básico
  • Vectoriza con TF-IDF (palabras + bigramas)
  • Agrupa con `MiniBatchKMeans`
  • Imprime términos top y ejemplos por cluster
  • Exporta `outputclusters.csv` con la etiqueta de cluster por fila
import re
import numpy as np
import pandas as pd

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import MiniBatchKMeans
from sklearn.metrics.pairwise import cosine_distances

INPUT_CSV = "input.csv"          # debe tener columna: text
OUTPUT_CSV = "output_clusters.csv"

K = 8                           # prueba 5, 8, 12... según tu caso
TOP_TERMS = 12                  # palabras/frases a mostrar por cluster
EXAMPLES_PER_CLUSTER = 5        # textos ejemplo por cluster

def normalize_social_text(s: str) -> str:
    s = s.lower().strip()
    s = re.sub(r"https?://\S+|www\.\S+", " ", s)     # urls
    s = re.sub(r"@\w+", " ", s)                      # menciones
    s = re.sub(r"#(\w+)", r" \1 ", s)                # #hashtag -> palabra
    s = re.sub(r"\s+", " ", s).strip()
    return s

df = pd.read_csv(INPUT_CSV)
if "text" not in df.columns:
    raise ValueError("El CSV debe incluir una columna llamada 'text'.")

texts_raw = df["text"].astype(str).fillna("")
texts = texts_raw.map(normalize_social_text).tolist()

# Vectorización: fuerte baseline para textos cortos.
vectorizer = TfidfVectorizer(
    stop_words="spanish",
    ngram_range=(1, 2),
    min_df=2,         # sube si hay mucho ruido; baja si el dataset es pequeño
    max_df=0.9
)
X = vectorizer.fit_transform(texts)

clusterer = MiniBatchKMeans(
    n_clusters=K,
    random_state=42,
    batch_size=2048,
    n_init="auto"
)
labels = clusterer.fit_predict(X)
df["cluster"] = labels

terms = np.array(vectorizer.get_feature_names_out())
centroids = clusterer.cluster_centers_

print("Tamaño por cluster:")
for c in range(K):
    print(f"  Cluster {c}: {(labels==c).sum()} textos")

print("\nTérminos top por cluster (para nombrarlos):")
for c in range(K):
    top_idx = centroids[c].argsort()[::-1][:TOP_TERMS]
    print(f"\n  Cluster {c}: " + ", ".join(terms[top_idx]))

# Ejemplos representativos = más cercanos al centroide (en coseno)
print("\nEjemplos representativos por cluster:")
for c in range(K):
    idx = np.where(labels == c)[0]
    if len(idx) == 0:
        continue

    # Distancia coseno contra el centroide: menor = más representativo
    D = cosine_distances(X[idx], centroids[c].reshape(1, -1)).ravel()
    best = idx[np.argsort(D)[:EXAMPLES_PER_CLUSTER]]

    print(f"\n  Cluster {c}:")
    for i in best:
        t = texts_raw.iloc[i]
        print("   -", t if len(t) <= 240 else (t[:240] + " ..."))

df.to_csv(OUTPUT_CSV, index=False)
print(f"\nListo. Exportado: {OUTPUT_CSV}")

Notas útiles para comentarios cortos

  • Si hay MUCHA ortografía creativa/emojis, prueba un vectorizador de caracteres:

    • `TfidfVectorizer(analyzer="charwb", ngramrange=(3,5))`

    Esto suele agrupar mejor variantes (“buenoo”, “buenísimo”, “buenisimo”).

Elegir K: codo + silhouette (rápido)

import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import MiniBatchKMeans
from sklearn.metrics import silhouette_score

INPUT_CSV = "input.csv"

df = pd.read_csv(INPUT_CSV)
texts = df["text"].astype(str).fillna("").tolist()

X = TfidfVectorizer(stop_words="spanish", ngram_range=(1,2), min_df=2, max_df=0.9).fit_transform(texts)

print("k, inertia, silhouette")
for k in range(2, 16):
    km = MiniBatchKMeans(n_clusters=k, random_state=42, batch_size=2048, n_init="auto")
    labels = km.fit_predict(X)
    sil = silhouette_score(X, labels)
    print(k, km.inertia_, sil)

Interpretación práctica:

  • silhouette alto suele ser mejor, pero en texto puede no ser enorme y aun así ser útil.
  • Elige el K donde:
    • los términos top “tienen sentido”
    • los ejemplos representativos se ven coherentes
    • y la métrica no empeora brutalmente

Script 2 (documentos grandes): TF-IDF -> SVD (LSA) -> K-Means

Para documentos largos, TF-IDF puede quedar en un espacio enorme y esparcido. Una receta típica:

  • TF-IDF (palabras)
  • `TruncatedSVD` (p.ej. 100–300 componentes)
  • Normalización
  • K-Means (en el espacio reducido)
import numpy as np
import pandas as pd

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.decomposition import TruncatedSVD
from sklearn.preprocessing import Normalizer
from sklearn.pipeline import make_pipeline
from sklearn.cluster import KMeans

INPUT_CSV = "input.csv"    # columna: text (puede ser documento grande)
K = 12
SVD_COMPONENTS = 200       # 100–300 es común
TOP_TERMS = 12

df = pd.read_csv(INPUT_CSV)
texts = df["text"].astype(str).fillna("").tolist()

vectorizer = TfidfVectorizer(
    stop_words="spanish",
    ngram_range=(1,2),
    min_df=2,
    max_df=0.9
)
X = vectorizer.fit_transform(texts)

svd = TruncatedSVD(n_components=SVD_COMPONENTS, random_state=42)
normalizer = Normalizer(copy=False)
X_lsa = make_pipeline(svd, normalizer).fit_transform(X)

km = KMeans(n_clusters=K, random_state=42, n_init="auto")
labels = km.fit_predict(X_lsa)
df["cluster"] = labels

terms = np.array(vectorizer.get_feature_names_out())

# “Términos top” aproximados en LSA: proyectamos centroides hacia el espacio TF-IDF
# (no es perfecto, pero sirve para interpretación rápida).
centroids_lsa = km.cluster_centers_                  # (K, SVD_COMPONENTS)
centroids_tfidf_approx = centroids_lsa @ svd.components_  # (K, vocab)

print("Tamaño por cluster:")
for c in range(K):
    print(f"  Cluster {c}: {(labels==c).sum()} documentos")

print("\nTérminos top por cluster:")
for c in range(K):
    top_idx = np.argsort(centroids_tfidf_approx[c])[::-1][:TOP_TERMS]
    print(f"\n  Cluster {c}: " + ", ".join(terms[top_idx]))

Cómo convertir clusters en “positivo/negativo” sin perder el enfoque no supervisado

K-Means NO sabe de “positivo/negativo”, solo agrupa por similitud. Pero puedes hacer algo muy útil:

  • (A) Haces clustering.
  • (B) Tomas 30–100 textos por cluster (los más representativos) y los etiquetas a mano como:
    • positivo / negativo / neutral / spam / etc.
  • (C) Con eso:
    • o bien “nombras” clusters (cluster 3 = “queja/negativo”, cluster 1 = “elogio/positivo”)
    • o entrenas un clasificador supervisado rápido (LogReg/SVM) para generalizar.

Esto suele ser más rápido que etiquetar todo desde cero, porque el clustering ya ordenó el caos.

Checklist para que te quede bien con redes sociales

  • [ ] Quitar duplicados/RT/spam.
  • [ ] Revisar si te conviene `analyzer="charwb"` (muchas variaciones y faltas).
  • [ ] Probar `ngramrange=(1,2)` y `mindf` (2–10 según tamaño).
  • [ ] Probar K en 5–20 y quedarte con el más interpretable.
  • [ ] Siempre imprimir: términos top + ejemplos representativos.

Entrada de datos sugerida (CSV)

Formato mínimo:

  • `text`: el comentario/tweet/documento

Opcional:

  • `id`, `source`, `date`, etc.

K-Means para Agrupar Tweets: Tutorial Básico

¿Qué es K-Means?

K-Means es un algoritmo que agrupa datos en K clusters (grupos). Cada dato se asigna al grupo cuyo centro (centroide) esté más cerca.

Aplicado a tweets: agrupa tweets similares automáticamente, sin que tú le digas las categorías de antemano.

¿Cómo funciona con texto?

1. Tweets (texto)
       │
       ▼
2. Vectorizar (TF-IDF)     ← Convierte texto en números
       │
       ▼
3. K-Means                  ← Agrupa los vectores similares
       │
       ▼
4. Clusters                 ← Grupos de tweets relacionados
  • TF-IDF convierte cada tweet en un vector numérico basado en la frecuencia e importancia de sus palabras.
  • K-Means toma esos vectores y los agrupa por similitud.

Requisitos

pip install scikit-learn matplotlib

Solo eso. Scikit-learn ya incluye TF-IDF y K-Means.

El código completo

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import KMeans
import matplotlib.pyplot as plt

# --- 1. Tweets de ejemplo ---
tweets = [
    # Deportes
    "Gran partido de fútbol ayer, el gol fue increíble",
    "El equipo ganó la liga, qué temporada tan buena",
    "Messi anotó un golazo en el último minuto",
    "La selección entrena para el mundial de fútbol",
    "Increíble jugada del portero, atajó todo",
    "El clásico del domingo fue muy emocionante",

    # Tecnología
    "Acabo de comprar el nuevo iPhone, la cámara es genial",
    "Python es el mejor lenguaje para aprender a programar",
    "La inteligencia artificial va a cambiar todo",
    "Actualizé mi laptop y ahora va mucho más rápido",
    "El nuevo procesador tiene un rendimiento increíble",
    "Aprendiendo machine learning con scikit-learn",

    # Comida
    "Las tacos de pastor de aquí son los mejores",
    "Hoy cociné una pasta con salsa de tomate casera",
    "El café de esta mañana estaba delicioso",
    "Probé un restaurante nuevo y la pizza estaba buenísima",
    "Nada como unos chilaquiles para desayunar",
    "Receta fácil: arroz con pollo y verduras",
]

# --- 2. Vectorizar los tweets con TF-IDF ---
vectorizador = TfidfVectorizer(
    stop_words=None,       # podríamos filtrar stopwords del español
    max_features=100,      # usar las 100 palabras más relevantes
    lowercase=True,        # todo a minúsculas
)
X = vectorizador.fit_transform(tweets)

print(f"Matriz TF-IDF: {X.shape[0]} tweets × {X.shape[1]} palabras")

# --- 3. Aplicar K-Means ---
k = 3  # queremos 3 grupos
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
kmeans.fit(X)

etiquetas = kmeans.labels_  # a qué cluster pertenece cada tweet

# --- 4. Mostrar resultados ---
for cluster_id in range(k):
    print(f"\n{'='*50}")
    print(f"  CLUSTER {cluster_id}")
    print(f"{'='*50}")
    for i, tweet in enumerate(tweets):
        if etiquetas[i] == cluster_id:
            print(f"  • {tweet}")

# --- 5. Palabras clave de cada cluster ---
print(f"\n{'='*50}")
print("  PALABRAS CLAVE POR CLUSTER")
print(f"{'='*50}")
nombres_palabras = vectorizador.get_feature_names_out()
centroides = kmeans.cluster_centers_

for cluster_id in range(k):
    # Obtener las 5 palabras con mayor peso en el centroide
    indices_top = centroides[cluster_id].argsort()[-5:][::-1]
    palabras_top = [nombres_palabras[i] for i in indices_top]
    print(f"  Cluster {cluster_id}: {', '.join(palabras_top)}")

# --- 6. Graficar (reducción a 2D con SVD) ---
from sklearn.decomposition import TruncatedSVD

svd = TruncatedSVD(n_components=2, random_state=42)
X_2d = svd.fit_transform(X)

colores = ['#e74c3c', '#3498db', '#2ecc71']
nombres_cluster = [f'Cluster {i}' for i in range(k)]

plt.figure(figsize=(10, 6))
for cluster_id in range(k):
    mask = etiquetas == cluster_id
    plt.scatter(
        X_2d[mask, 0], X_2d[mask, 1],
        c=colores[cluster_id],
        label=nombres_cluster[cluster_id],
        s=100, alpha=0.7, edgecolors='black'
    )
    for i in range(len(tweets)):
        if etiquetas[i] == cluster_id:
            plt.annotate(
                tweets[i][:25] + "...",
                (X_2d[i, 0], X_2d[i, 1]),
                fontsize=7, alpha=0.8
            )

plt.title("Agrupación de Tweets con K-Means")
plt.xlabel("Componente 1")
plt.ylabel("Componente 2")
plt.legend()
plt.tight_layout()
plt.savefig("kmeans_tweets.png", dpi=150)
plt.show()
print("\nGráfica guardada en kmeans_tweets.png")

Ejecutar

python tutorial_kmeans_tweets.py

Salida esperada

Matriz TF-IDF: 18 tweets × 100 palabras

==================================================
  CLUSTER 0
==================================================
  • Gran partido de fútbol ayer, el gol fue increíble
  • El equipo ganó la liga, qué temporada tan buena
  • Messi anotó un golazo en el último minuto
  ...

==================================================
  CLUSTER 1
==================================================
  • Acabo de comprar el nuevo iPhone, la cámara es genial
  • Python es el mejor lenguaje para aprender a programar
  ...

==================================================
  CLUSTER 2
==================================================
  • Las tacos de pastor de aquí son los mejores
  • Hoy cociné una pasta con salsa de tomate casera
  ...

  PALABRAS CLAVE POR CLUSTER
==================================================
  Cluster 0: fútbol, gol, equipo, partido, jugada
  Cluster 1: nuevo, programar, aprender, inteligencia, laptop
  Cluster 2: tacos, café, pizza, receta, pasta

¿Cómo elegir el valor de K?

El método del codo ayuda a decidir cuántos clusters usar:

inercias = []
rango_k = range(2, 8)

for k in rango_k:
    km = KMeans(n_clusters=k, random_state=42, n_init=10)
    km.fit(X)
    inercias.append(km.inertia_)

plt.figure(figsize=(8, 4))
plt.plot(rango_k, inercias, 'bo-')
plt.xlabel('Número de clusters (K)')
plt.ylabel('Inercia')
plt.title('Método del Codo')
plt.savefig("metodo_codo.png", dpi=150)
plt.show()

El codo de la curva indica el K óptimo: donde agregar más clusters ya no mejora mucho la agrupación.

Mejoras opcionales

Filtrar stopwords en español

stopwords_es = [
    "de", "la", "el", "en", "y", "los", "las", "un", "una",
    "es", "que", "del", "al", "con", "para", "por", "más",
    "fue", "son", "muy", "tan", "ya", "su", "hoy", "aquí",
]

vectorizador = TfidfVectorizer(
    stop_words=stopwords_es,
    max_features=100,
)

Usar tweets reales (CSV)

import csv

tweets = []
with open("mis_tweets.csv", encoding="utf-8") as f:
    lector = csv.DictReader(f)
    for fila in lector:
        tweets.append(fila["texto"])

Asignar un tweet nuevo a un cluster

nuevo = ["Me encanta la nueva actualización de Android"]
vector = vectorizador.transform(nuevo)
cluster = kmeans.predict(vector)
print(f"Este tweet pertenece al cluster {cluster[0]}")

Conceptos clave resumidos

Concepto Qué hace
TF-IDF Convierte texto en vectores numéricos
K-Means Agrupa vectores similares en K clusters
Centroide El "centro" promedio de cada cluster
Inercia Qué tan compactos son los clusters (menor = mejor)
Método codo Gráfica para elegir el mejor valor de K
SVD Reduce dimensiones para poder graficar en 2D

Rastreadores Web

Los rastreadores web (web crawlers o spiders) son programas que recorren sistemáticamente la World Wide Web para descubrir, obtener y a menudo almacenar contenido. Son la base de los buscadores, agregadores de noticias, monitorización de precios, análisis de redes sociales y muchas otras aplicaciones que dependen de datos públicos o semi-públicos en línea.

Este documento organiza los conceptos en torno al DOM, el alcance del rastreo, modelos arquitectónicos, herramientas como Scrapy, almacenamiento, redes sociales y aplicaciones típicas.

DOM y HTML parsing

El Document Object Model (DOM) es una representación en árbol del documento HTML (o XML) que el navegador construye tras analizar (parsear) el marcado. Cada nodo corresponde a elementos, atributos y texto; las APIs del navegador permiten consultar y modificar ese árbol.

Parsing de HTML

El parsing consiste en leer una cadena de caracteres (HTML) y producir una estructura utilizable (árbol DOM o similar). En entornos sin navegador (por ejemplo, scripts en Python), las librerías suelen:

  • Usar analizadores tolerantes a HTML “sucio” (como el que generan sitios legacy), por ejemplo html.parser, lxml.html o BeautifulSoup en el ecosistema Python.
  • Ofrecer selectores tipo CSS o XPath para localizar nodos sin recorrer manualmente todo el árbol.

Relación con el rastreo

En el rastreo no siempre se necesita un DOM completo como en el navegador: a menudo basta con extraer fragmentos (títulos, enlaces, tablas). Sin embargo, entender el DOM ayuda a:

  • Escribir selectores estables (por clase, id, atributos data-, jerarquía).
  • Distinguir contenido generado en el cliente (JavaScript) frente al HTML inicial del servidor, lo que condiciona si basta con una petición HTTP simple o hace falta un navegador headless.

Consideraciones prácticas

  • Codificación: declarar o detectar UTF-8 y variantes para no corromper texto.
  • HTML mal formado: los parsers indulgentes reconstruyen el árbol de forma heurística; los resultados pueden variar ligeramente entre librerías.
  • Namespaces y HTML5: elementos y atributos deben interpretarse según las reglas del estándar vigente del proyecto.

Alcance del rastreador web

El alcance define qué URLs y recursos entran en el proceso de rastreo y cuáles quedan fuera.

Dimensiones del alcance

  • Dominio y subdominios: limitar a un dominio concreto evita “salirse” del sitio objetivo.
  • Profundidad y anchura: cuántos niveles de enlaces seguir desde una URL semilla y cuántas URLs concurrentes por nivel.
  • Patrones de URL: incluir o excluir rutas mediante expresiones regulares o reglas (por ejemplo solo producto).
  • Tipo de recurso: HTML, PDF, APIs JSON, etc.

Políticas y restricciones

  • robots.txt: indica rutas que el propietario del sitio prefiere no rastrear; el respeto es una buena práctica y en muchos casos condición de uso del servicio.
  • Términos de uso del sitio o de la API: pueden prohibir el scraping automatizado.
  • Rate limiting: espaciar peticiones para no sobrecargar servidores ni provocar bloqueos.

Objetivos típicos por alcance

Alcance Uso típico
Sitio único Catálogo de una tienda, portal institucional
Varios dominios Comparadores, agregadores de noticias
Web abierta masiva Motores de búsqueda (índices muy grandes)

Modelos de rastreadores web

Modelo de cola de URLs (clásico)

  1. Partir de una lista de URLs semilla.
  2. Encolar URLs pendientes; desencolar, descargar y analizar cada página.
  3. Extraer enlaces (y otros identificadores) y añadirlos a la cola si no están visitados (deduplicación con un conjunto o Bloom filter).
  4. Repetir hasta vaciar la cola o alcanzar límites (tiempo, profundidad, número de páginas).

Este modelo encaja bien con frameworks como Scrapy, donde el “spider” genera requests siguientes a partir de cada respuesta.

Rastreo enfocado (focused crawling)

Prioriza enlaces según relevancia respecto a un tema (puntuación por texto del ancla, similitud con un modelo, etc.), útil cuando no interesa indexar todo el sitio.

Rastreo incremental

Mantiene estado de “última visita” o ETags / Last-Modified para volver solo a páginas cambiadas, reduciendo ancho de banda y carga.

Rastreo distribuido

Varios workers reparten URLs por partición (por dominio o hash) para escalar; requiere coordinación, deduplicación distribuida y políticas de cortesía por host.

Sitios dinámicos (SPA)

Cuando el contenido depende de JavaScript, el modelo puede incluir:

  • Navegador headless (Playwright, Puppeteer) que ejecute JS y exponga DOM final.
  • O consumo directo de APIs internas que el propio sitio llama vía XHR/fetch (si son accesibles y legales).

Librerías para desarrollo de rastreadores (Scrapy)

Rol de Scrapy

Scrapy es un framework en Python orientado al rastreo y extracción estructurada. No es solo un parser: incluye motor de peticiones concurrentes, middlewares, pipelines, extensiones y herramientas de línea de comandos.

Componentes principales

  • Spider: clase que define cómo seguir enlaces y qué datos extraer de cada respuesta.
  • Request / Response: flujo de trabajo asíncrono basado en Twisted (I/O no bloqueante).
  • Item: estructura de datos extraídos (similar a un dict tipado o dataclass).
  • Pipeline: procesamiento posterior (validación, limpieza, deduplicación, escritura a BD o ficheros).
  • Downloader middlewares: cabeceras, proxies, reintentos, manejo de cookies.
  • Spider middlewares: filtrado o modificación de requests/responses entre motor y spider.

Ventajas

  • Concurrencia y rendimiento elevados frente a scripts secuenciales con requests + bucle manual.
  • Separación clara entre extracción, almacenamiento y configuración.
  • Ecosistema de extensiones (Splash para JS, proxies rotativos, etc., según necesidad).

Alternativas y complementos

  • requests + BeautifulSoup / lxml: simple para proyectos pequeños o prototipos.
  • Playwright / Selenium: cuando el DOM depende de ejecución en cliente.
  • httpx, aiohttp: clientes HTTP asíncronos si se construye un rastreador a medida sin Scrapy.

Almacenamiento de datos

Los datos rastreados deben persistirse según volumen, velocidad de llegada y uso posterior.

Opciones comunes

  • Ficheros: CSV, JSON Lines (una línea por registro), Parquet para analítica.
  • Bases relacionales (PostgreSQL, SQLite): esquemas claros, integridad, consultas SQL.
  • NoSQL (MongoDB, Elasticsearch): documentos flexibles o búsqueda de texto completo.
  • Almacenes de objetos (S3, MinIO): volúmenes grandes de HTML crudo o binarios.
  • Colas de mensajes (Redis, RabbitMQ, Kafka): desacoplar rastreo de procesamiento pesado.

Buenas prácticas

  • Definir claves naturales o IDs para evitar duplicados.
  • Versionar esquemas cuando evolucionen los campos extraídos.
  • Comprimir archivos históricos y aplicar retención según normativa (RGPD, etc., si hay datos personales).

Metadatos útiles

Fecha de rastreo, URL canónica, código HTTP, hash del contenido para detectar cambios entre ejecuciones.

Redes sociales

Las redes sociales son fuentes de texto, enlaces, métricas de engagement y grafos sociales, pero su acceso está muy regulado.

Acceso típico

  • APIs oficiales (cuotas, autenticación OAuth, términos estrictos): preferible para proyectos legítimos y estables.
  • Exportaciones o datos proporcionados por el usuario.
  • El scraping directo del HTML público a menudo viola términos de servicio y puede cambiar de forma frecuente (anti-bot, login obligatorio).

Desafíos

  • Contenido detrás de login, infinite scroll, carga dinámica.
  • Limitaciones legales y éticas: consentimiento, derecho al olvido, uso de datos personales.

En trabajos académicos o de producto, suele priorizarse API oficial o conjuntos de datos abiertos ya anonimizados.

Aplicaciones

Idea general

Los rastreadores alimentan sistemas que van más allá del simple almacenamiento: monitorización, inteligencia competitiva, investigación, periodismo de datos, etc.

Reportes automáticos de texto a partir de la web

Objetivo: transformar colecciones de páginas o feeds en informes legibles (resúmenes, destacados, alertas) sin redacción manual constante.

  • Pipeline típico: rastreo programado → extracción del cuerpo principal (readability, boilerplate removal) → normalización de texto → opcionalmente NLP (resumen extractivo o abstractivo) → plantilla de informe (PDF, HTML, correo).
  • Casos: newsletters automáticos a partir de prensa sectorial, “digest” diario de cambios en normativa publicada en boletines oficiales, informes de menciones de marca en noticias.

Calidad del informe depende de la calidad de la extracción del artículo principal y de la deduplicación de noticias repetidas.

Minería de opinión

La minería de opinión (o análisis de sentimiento) extrae de textos actitudes positivas, negativas o neutras, y a veces emociones finas o aspectos (aspect-based sentiment).

  • Enfoques: léxicos de polaridad, modelos de machine learning o transformers entrenados en corpus de reseñas/redes.
  • Datos de entrada: comentarios de productos, tweets (vía API), foros, encuestas abiertas — muchas veces obtenidos tras rastreo o API y preprocesamiento.
  • Aplicaciones: reputación de marca, seguimiento de campañas, detección de crisis, análisis de satisfacción del cliente.

Limitaciones: ironía, contexto cultural y sesgos del modelo pueden distorsionar resultados; conviene validación humana en decisiones críticas.

Guía práctica: BeautifulSoup, rastreador simple, Scrapy y proyecto completo

Propósito de este apartado

Aprenderás cuatro enfoques: BeautifulSoup en un script, rastreador manual con colas, Scrapy con runspider y un proyecto Scrapy completo (startproject + scrapy crawl), además de una guía de problemas frecuentes. Todo sobre páginas de práctica, con código reproducible y documentado línea a línea.

Aviso legal y uso responsable

  • El rastreo automatizado puede estar prohibido por los términos de uso de muchos sitios. Este tutorial usa únicamente quotes.toscrape.com, un sitio público pensado para aprender scraping y Scrapy.
  • Respeta siempre robots.txt, limita la frecuencia de peticiones e identifica tu programa con un User-Agent claro (por ejemplo nombre del curso o proyecto).
  • No uses estos ejemplos contra redes sociales, bancos, sitios con datos personales o servicios que requieran credenciales sin autorización explícita.

Entorno recomendado (Python y dependencias)

Crea un entorno virtual e instala paquetes. Así no mezclas versiones con otros proyectos.

cd ~/Scripts
python3 -m venv venv-tutorial-rastreo
source venv-tutorial-rastreo/bin/activate   # En Windows: venv-tutorial-rastreo\Scripts\activate
pip install --upgrade pip
pip install -r requirements-tutorial-rastreo.txt
# alternative explícita:
# pip install requests beautifulsoup4 lxml scrapy

Qué instala cada paquete

Paquete Función breve
requests Peticiones HTTP sencillas (obtener HTML)
beautifulsoup4 Analizar HTML y buscar etiquetas con selectores
lxml Parser rápido; BeautifulSoup puede usarlo como motor
scrapy Framework de rastreo: colas, concurrencia, exportación

Parte 1 — BeautifulSoup: una sola página

Este script descarga una URL, construye el árbol del documento y extrae citas. Es el camino más directo para prototipos pequeños.

Código completo (bs4_una_pagina.py)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Extracción de citas con BeautifulSoup — sitio de práctica únicamente."""

import sys
import requests
from bs4 import BeautifulSoup

# URL permitida para este tutorial (sitio de demostración para scraping).
URL = "https://quotes.toscrape.com/"

# User-Agent: identifica quién hace la petición (buena práctica; evita bloqueos por "bot genérico").
HEADERS = {
    "User-Agent": "TutorialOrgEducacional/1.0 (curso; contacto: tu-email@ejemplo.org)",
    "Accept-Language": "es,en;q=0.8",
}


def main() -> None:
    """Descarga HTML, analiza y muestra cada cita en salida estándar."""
    try:
        # timeout evita quedarse colgado si el servidor no responde.
        respuesta = requests.get(URL, headers=HEADERS, timeout=15)
        # raise_for_status() lanza excepción si el código HTTP es 4xx o 5xx.
        respuesta.raise_for_status()
    except requests.RequestException as exc:
        print(f"Error de red al obtener {URL}: {exc}", file=sys.stderr)
        sys.exit(1)

    # BeautifulSoup parsea el texto HTML; "lxml" es el parser rápido que instalamos.
    sopa = BeautifulSoup(respuesta.text, "lxml")

    # select() usa selectores CSS: cada bloque de cita está en div.quote.
    for bloque in sopa.select("div.quote"):
        # Dentro del bloque, el texto de la cita está en span.text.
        texto_el = bloque.select_one("span.text")
        autor_el = bloque.select_one("small.author")
        etiquetas_els = bloque.select("div.tags a.tag")

        texto = texto_el.get_text(strip=True) if texto_el else ""
        autor = autor_el.get_text(strip=True) if autor_el else ""
        # get_text() en varios nodos: iteramos y limpiamos espacios.
        etiquetas = [a.get_text(strip=True) for a in etiquetas_els]

        print(f"Cita: {texto}")
        print(f"  Autor: {autor}")
        print(f"  Etiquetas: {', '.join(etiquetas)}")
        print()


if __name__ == "__main__":
    main()

Explicación línea por línea (archivo anterior)

  • #!/usr/bin/env python3 — En sistemas Unix, indica que el intérprete es Python 3 al ejecutar ./bs4_una_pagina.py.
  • =-- coding: utf-8 --=* — Declara codificación UTF-8 para el archivo fuente (acentos en comentarios).
  • Docstring del módulo — Describe el propósito y el ámbito (solo sitio de práctica).
  • import sys — Acceso a sys.stderr y sys.exit para errores claros.
  • import requests — Cliente HTTP para GET.
  • from bs4 import BeautifulSoup — Clase principal del parser.
  • URL …= — Constante: un solo origen; fácil de cambiar para otro sitio permitido.
  • HEADERS — Diccionario de cabeceras HTTP; User-Agent identifica el cliente (ética y menos bloqueos).
  • def main() -> None: — Función principal con anotación de tipo (None porque solo imprime).
  • try/except requests.RequestException — Captura fallos de red, DNS, SSL, timeouts.
  • requests.get(..., timeout=15) — Petición GET con límite de espera en segundos.
  • raise_for_status() — Falla explícitamente si la respuesta no es exitosa.
  • BeautifulSoup(respuesta.text, "lxml") — Construye el DOM lógico desde el HTML en texto.
  • sopa.select("div.quote") — Lista de nodos que coinciden con el selector CSS.
  • select_one — Primer match o None; evita errores si falta un elemento.
  • get_text(strip=True) — Solo texto visible, sin etiquetas; strip=True quita espacios extremos.
  • List comprehension en etiquetas — Colección ordenada de strings de cada enlace de etiqueta.

Cómo ejecutarlo

cd ~/Scripts
source venv-tutorial-rastreo/bin/activate
python3 bs4_una_pagina.py

Deberías ver en consola varias citas con autor y etiquetas.

Parte 2 — Rastreador web simple (cola + visitados)

Este script no usa Scrapy: implementa una cola de URLs, un conjunto de ya visitadas y un retardo entre peticiones — útil para entender el modelo mental del rastreo antes del framework.

Código completo (rastreador_simple_colas.py)

Solo sigue enlaces cuyo netloc sea quotes.toscrape.com para no salir del sitio de práctica.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Rastreador didáctico con cola + visitados — solo quotes.toscrape.com."""

from __future__ import annotations

import collections
import sys
import time
from typing import Optional
from urllib.parse import urljoin, urlparse

import requests
from bs4 import BeautifulSoup

SEED_URL = "https://quotes.toscrape.com/"
# Dominios permitidos: no seguiremos enlaces fuera de esta lista (seguridad).
ALLOWED_NETLOCS = {"quotes.toscrape.com"}

HEADERS = {
    "User-Agent": "TutorialOrgEducacional/1.0 (rastreador simple; solo sitio de práctica)",
}

# Segundos entre peticiones al mismo host (cortesía; reduce carga en el servidor).
DELAY_SECONDS = 1.0
# Máximo de páginas a visitar en esta demostración (evita ejecuciones infinitas).
MAX_PAGES = 15


def normalizar_url(base: str, href: Optional[str]) -> Optional[str]:
    """Convierte href relativo en URL absoluta; devuelve None si href vacío."""
    if not href:
        return None
    return urljoin(base, href)


def es_permitida(url: str) -> bool:
    """True solo si el dominio está en ALLOWED_NETLOCS (http/https)."""
    partes = urlparse(url)
    if partes.scheme not in ("http", "https"):
        return False
    return partes.netloc in ALLOWED_NETLOCS


def extraer_citas_y_enlaces(html: str, url_actual: str) -> tuple[list[dict], list[str]]:
    """Devuelve (lista de dicts cita, lista de URLs halladas en la página)."""
    sopa = BeautifulSoup(html, "lxml")
    citas: list[dict] = []

    for bloque in sopa.select("div.quote"):
        texto_el = bloque.select_one("span.text")
        autor_el = bloque.select_one("small.author")
        texto = texto_el.get_text(strip=True) if texto_el else ""
        autor = autor_el.get_text(strip=True) if autor_el else ""
        citas.append({"texto": texto, "autor": autor})

    enlaces: list[str] = []
    for a in sopa.select("a[href]"):
        href = a.get("href")
        absoluta = normalizar_url(url_actual, href)
        if absoluta and es_permitida(absoluta):
            enlaces.append(absoluta)

    return citas, enlaces


def main() -> None:
    cola: collections.deque[str] = collections.deque()
    visitados: set[str] = set()
    paginas_visitadas = 0

    cola.append(SEED_URL)

    while cola and paginas_visitadas < MAX_PAGES:
        url = cola.popleft()
        if url in visitados:
            continue
        visitados.add(url)

        try:
            time.sleep(DELAY_SECONDS)
            r = requests.get(url, headers=HEADERS, timeout=15)
            r.raise_for_status()
        except requests.RequestException as exc:
            print(f"Aviso: no se pudo leer {url}: {exc}", file=sys.stderr)
            continue

        paginas_visitadas += 1
        citas, enlaces = extraer_citas_y_enlaces(r.text, url)

        print(f"--- Página {paginas_visitadas}: {url} ({len(citas)} citas) ---")
        for c in citas:
            print(f"  · {c['autor']}: {c['texto'][:80]}...")

        for enlace in enlaces:
            if enlace not in visitados:
                cola.append(enlace)


if __name__ == "__main__":
    main()

Explicación línea por línea (ideas clave)

  • from __future__ import annotations — Permite anotaciones de tipo con tipos que aún no están definidos (compatible con versiones antiguas de Python 3.7+).
  • collections.deque — Cola eficiente por ambos extremos para URLs pendientes.
  • urljoin, urlparse — Unir base + href relativo y analizar esquema/host.
  • ALLOWED_NETLOCSPolítica de alcance: solo enlaces del mismo sitio de práctica.
  • DELAY_SECONDS / MAX_PAGES — Límites explícitos: cortesía y demostración acotada.
  • normalizar_url — Evita errores con enlaces como /page/2/ respecto a la URL actual.
  • es_permitida — Filtra javascript:, otros dominios, etc.
  • extraer_citas_y_enlaces — Una función pura: HTML → datos + nuevos enlaces.
  • Bucle principal — Mientras haya cola y no se supere MAX_PAGES, sacar URL, marcar visitada, dormir, GET, parsear, encolar nuevos enlaces no visitados.
  • Fragmento [:80] — Solo muestra un prefijo de la cita para salida legible.

Cómo ejecutarlo

cd ~/Scripts
source venv-tutorial-rastreo/bin/activate
python3 rastreador_simple_colas.py

Parte 3 — Scrapy: spider en un solo archivo

Scrapy gestiona concurrencia, reintentos y exportación (json, csv). Aquí usamos scrapy runspider con un único archivo para que sea fácil de copiar en el tutorial.

Código completo (scrapy_quotes_spider.py)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Spider Scrapy de ejemplo — solo https://quotes.toscrape.com/"""

import scrapy


class QuotesSpider(scrapy.Spider):
    # Nombre interno del spider (usado en logs y en línea de comandos).
    name = "quotes"

    # URLs iniciales desde las que Scrapy lanzará la primera petición.
    start_urls = ["https://quotes.toscrape.com/"]

    # Reglas de cortesía y cumplimiento: robots.txt, retardo, agente identificable.
    custom_settings = {
        "ROBOTSTXT_OBEY": True,
        "DOWNLOAD_DELAY": 1,
        "USER_AGENT": "TutorialOrgEducacional/1.0 (Scrapy; solo quotes.toscrape.com)",
    }

    def parse(self, response):
        # response.css usa selectores CSS de Scrapy; ::text extrae solo texto del nodo.
        for bloque in response.css("div.quote"):
            yield {
                "texto": bloque.css("span.text::text").get(),
                "autor": bloque.css("small.author::text").get(),
                "etiquetas": bloque.css("div.tags a.tag::text").getall(),
            }

        # Siguiente página: enlace relativo en li.next > a.
        siguiente = response.css("li.next a::attr(href)").get()
        if siguiente:
            # follow construye URL absoluta y programa otra petición con el mismo parse.
            yield response.follow(siguiente, callback=self.parse)

Explicación línea por línea (Scrapy)

  • class QuotesSpider(scrapy.Spider): — Toda spider hereda de scrapy.Spider e implementa al menos parse (u otros callbacks).
  • name — Identificador único del spider dentro del proyecto (aquí proyecto implícito al usar runspider).
  • start_urls — Lista de seeds; Scrapy crea un Request por cada una con callback=self.parse por defecto.
  • custom_settings — Diccionario que sobrescribe ajustes solo para este spider.
    • ROBOTSTXT_OBEY: True — Respeta robots.txt del sitio (buena práctica).
    • DOWNLOAD_DELAY: 1 — ~1 s entre descargas al mismo dominio (además del scheduler de Scrapy).
    • USER_AGENT — Identificación del cliente.
  • def parse(self, response): — Método llamado con la Response ya descargada (response.body, response.url, etc.).
  • response.css("div.quote") — Iterable de selectores; cada uno apunta a un fragmento del DOM.
  • .css("span.text::text").get() — Primer texto; getall() devolvería lista.
  • yield { ... } — En Scrapy, yield de un dict produce un item que pueden procesar pipelines o exportación.
  • response.css("li.next a::attr(href)").get() — Valor del atributo href del enlace “siguiente”, o None si no hay.
  • yield response.follow(siguiente, callback=self.parse) — Encola petición al siguiente path y reutiliza parse para la nueva página.

Cómo ejecutarlo y guardar JSON

cd ~/Scripts
source venv-tutorial-rastreo/bin/activate
scrapy runspider scrapy_quotes_spider.py -o citas_scrapy.json

El fichero citas_scrapy.json contendrá una lista de objetos JSON (una línea por item en formato JSON Lines si usas jl; con json Scrapy puede escribir lista JSON según versión — si prefieres JSON Lines explícito usa -o citas.jl).

Parte 4 — Proyecto Scrapy completo (scrapy startproject + scrapy crawl)

Además de scrapy runspider archivo.py, Scrapy suele organizarse como proyecto: un directorio con configuración global, posibles pipelines, middlewares y varias spiders. Así separas qué rastreas (spider) de cómo lo hace el motor (retardo, concurrencia, user-agent en settings.py).

Cómo se generó el proyecto de este tutorial

Desde ~/Scripts (con el entorno virtual activado y Scrapy instalado) se ejecutó:

cd ~/Scripts
source venv-tutorial-rastreo/bin/activate
scrapy startproject tutorial_scrapy_quotes

Eso creó la carpeta tutorial_scrapy_quotes/ con la plantilla oficial. Luego se añadió el spider quotes y se ajustó USER_AGENT en settings.py. Si repites el comando en otra máquina, obtienes la misma base; solo necesitas copiar o crear el fichero del spider que ves abajo.

Árbol relevante del proyecto

tutorial_scrapy_quotes/
├── scrapy.cfg                    # Indica a Scrapy dónde están los settings del proyecto
└── tutorial_scrapy_quotes/
    ├── items.py                  # Modelos de datos (opcional; aquí no es obligatorio)
    ├── middlewares.py            # Hooks de petición/respuesta (opcional)
    ├── pipelines.py              # Procesar items antes de guardarlos (opcional)
    ├── settings.py               # USER_AGENT, ROBOTSTXT_OBEY, retardos, pipelines…
    └── spiders/
        ├── __init__.py
        └── quotes.py             # Spider =quotes= de este tutorial
  • scrapy.cfg — Archivo INI mínimo: sección [settings] con default = tutorial_scrapy_quotes.settings. Al ejecutar scrapy crawl desde la carpeta que contiene este archivo, Scrapy carga esa configuración.
  • settings.py — Valores por defecto del proyecto. Aquí conviene centralizar USER_AGENT, ROBOTSTXT_OBEY, DOWNLOAD_DELAY y CONCURRENT_REQUESTS_PER_DOMAIN para que todas las spiders del proyecto hereden el mismo comportamiento responsable.
  • spiders/quotes.py — Define una clase que hereda de scrapy.Spider con name = "quotes"; ese nombre es el que usas en la línea de comandos.

Fragmentos importantes de settings.py (explicación línea por línea)

En el proyecto generado, estos valores educan el rastreo (plantilla Scrapy ya incorpora varios; el tutorial añade USER_AGENT explícito):

  • BOT_NAME — Nombre lógico del proyecto (aparece en logs).
  • SPIDER_MODULES / NEWSPIDERMODULE= — Rutas Python donde Scrapy busca clases spider.
  • ROBOTSTXT_OBEY = True — Antes de rastrear un dominio, descarga y respeta /robots.txt si existe (si no existe, ver Problemas frecuentes).
  • CONCURRENT_REQUESTS_PER_DOMAIN = 1 — Como máximo una petición concurrente por dominio (cortesía).
  • DOWNLOAD_DELAY = 1 — Pausa base entre peticiones al mismo dominio (segundos).
  • USER_AGENT — Cadena que identifica tu aplicación; debe ser honesta y reconocible.
  • FEED_EXPORT_ENCODING = "utf-8" — Al usar -o archivo.json, la exportación en UTF-8.

Código del spider (tutorial_scrapy_quotes/spiders/quotes.py)

La lógica es la misma que en la Parte 3, con dos diferencias típicas de proyecto:

  • allowed_domains — Lista blanca de dominios; Scrapy descarta peticiones fuera de ella (evita seguir enlaces externos por error).
  • Sin custom_settings en la clase — la cortesía ya está en settings.py del proyecto.
# -*- coding: utf-8 -*-
"""Spider del proyecto tutorial — solo https://quotes.toscrape.com/"""

import scrapy


class QuotesSpider(scrapy.Spider):
    name = "quotes"
    allowed_domains = ["quotes.toscrape.com"]
    start_urls = ["https://quotes.toscrape.com/"]

    def parse(self, response):
        for bloque in response.css("div.quote"):
            yield {
                "texto": bloque.css("span.text::text").get(),
                "autor": bloque.css("small.author::text").get(),
                "etiquetas": bloque.css("div.tags a.tag::text").getall(),
            }

        siguiente = response.css("li.next a::attr(href)").get()
        if siguiente:
            yield response.follow(siguiente, callback=self.parse)
  • name = "quotes" — Debe coincidir con el argumento de scrapy crawl quotes.
  • allowed_domains — Solo quotes.toscrape.com; coherente con el alcance seguro del tutorial.
  • Resto — Igual que en Parte 3: yield de dicts y response.follow para paginar.

Cómo ejecutar el proyecto

Hay que situarse en el directorio que contiene scrapy.cfg (no dentro de tutorial_scrapy_quotes/tutorial_scrapy_quotes/).

cd ~/Scripts/tutorial_scrapy_quotes
source ~/Scripts/venv-tutorial-rastreo/bin/activate
scrapy crawl quotes -o citas_proyecto.json

Salida: fichero citas_proyecto.json con los items (codificación UTF-8 según settings).

runspider (Parte 3) vs proyecto (Parte 4)

Aspecto scrapy runspider mi_spider.py Proyecto con scrapy crawl nombre
Configuración custom_settings en la clase o poco más settings.py compartido por varias spiders
Spiders múltiples Incómodo (varios archivos sueltos) Natural: una clase por fichero en spiders/
Pipelines / middlewares Manual Integrados por nombre en settings.py
Uso típico Prototipo, un solo archivo Cursos, equipos, despliegue con scrapyd

Tabla comparativa rápida

Enfoque Cuándo usarlo
BeautifulSoup + GET Pocas páginas, script rápido, aprendizaje
Cola + visitados Entender rastreo sin framework; control total
Scrapy runspider Prototipo Scrapy en un solo .py
Proyecto Scrapy (crawl) Varios spiders, settings y pipelines compartidos

Problemas frecuentes y soluciones

ModuleNotFoundError: No module named 'lxml' (o bs4, scrapy)

  • Causa: El intérprete de Python no es el del entorno virtual donde instalaste dependencias, o no ejecutaste pip install -r requirements-tutorial-rastreo.txt.
  • Solución: source .../venv-tutorial-rastreo/bin/activate y vuelve a instalar. Comprueba con which python y pip show scrapy que apuntan al mismo venv.

BeautifulSoup: Couldn't find a tree builder with the features you requested

  • Causa: Pediste el parser lxml pero no está instalado.
  • Solución: pip install lxml o, temporalmente, usa BeautifulSoup(html, "html.parser") (más lento, sin dependencia extra).

ssl.SSLError / CERTIFICATE_VERIFY_FAILED

  • Causa: Certificados del sistema desactualizados, proxy corporativo que intercepta TLS, o entorno muy antiguo.
  • Solución: Actualizar el paquete ca-certificates del SO o de Python; en redes con proxy, configurar variables de entorno o el middleware de proxy de Scrapy solo si tu organización lo permite. No desactives la verificación SSL en producción sin criterio de seguridad.

scrapy: command not found

  • Causa: venv no activado o Scrapy no instalado en ese entorno.
  • Solución: Activa el venv o ejecuta python -m scrapy crawl quotes desde el directorio del proyecto.

403 Forbidden o bloqueos similares

  • Causa: El sitio rechaza peticiones automáticas (User-Agent, falta de cabeceras, rate limit).
  • Solución educativa: En sitios permitidos suele bastar un USER_AGENT identificable y DOWNLOAD_DELAY. No intentes evadir protecciones de sitios que prohíben el rastreo.

Timeouts y ConnectionError

  • Causa: Red inestable, firewall, URL incorrecta o servidor caído.
  • Solución: Revisa la URL en el navegador; en requests usa timeout; en Scrapy revisa DOWNLOAD_TIMEOUT en documentación.

Log de Scrapy: robotstxt ... 404

  • Causa: El sitio no publica /robots.txt; el servidor responde 404.
  • Qué implica: No es un error fatal: Scrapy sigue; simplemente no hay reglas públicas en ese path. Sigue respetando leyes y términos del sitio.

ImportError al lanzar el spider del proyecto

  • Causa: Ejecutaste scrapy crawl desde un directorio incorrecto (sin scrapy.cfg) o el nombre del spider no coincide con name en la clase.
  • Solución: cd a la carpeta que contiene scrapy.cfg; comprueba scrapy list para ver spiders registradas.

Duplicados en el fichero de salida o URL visitadas dos veces

  • Causa: Misma página enlazada con query strings distintas o cola sin deduplicar en scripts manuales.
  • Solución: Normaliza URLs (sin fragmentos, parámetros opcionales); en Scrapy usa dont_filter solo cuando sea consciente.

XPath con Python: lxml, BeautifulSoup, rastreador y Scrapy

Cómo encaja XPath con “requests + BeautifulSoup + Scrapy + rastreador”

Puedes y debes combinarlo con Python. La idea clave es:

  • XPath es un lenguaje de consultas sobre el árbol del documento (similar en papel a SQL sobre tablas, pero sobre nodos XML/HTML).
  • Quien ejecuta XPath en tu stack suele ser:
    • lxml (lxml.html.fromstring + .xpath(...)),
    • Scrapy (response.xpath(...), internamente Parsel, muy parecido a lxml),
    • parsel (Selector(text=html).xpath(...)) para pruebas sin descargar.

BeautifulSoup y XPath

BeautifulSoup no tiene método .xpath(). Sus puntos fuertes son find, select (CSS) y recorrido del árbol en Python.

Combinación habitual: una petición requests → obtienes HTML como texto → puedes:

  1. Parsear con BeautifulSoup(html, "lxml") y usar solo CSS; o
  2. Parsear el mismo string con lxml.html.fromstring(html) y usar XPath.

Así “combinas” BS4 y XPath en un script: mismo HTML, dos APIs (el tutorial xpath_mismo_html_bs4_y_lxml.py lo demuestra). No hace falta mezclar en un solo objeto: basta con dos parseos del mismo contenido (coste mínimo en proyectos de aprendizaje).

Rastreador web (cola + visitados)

Un crawler casero (como rastreador_simple_colas.py) solo recorre URLs: en cada página obtienes html y debes extraer citas y enlaces. Esa extracción puede ser con CSS + BeautifulSoup o con XPath + lxml — el bucle de cola no cambia; solo cambia la función extraer_*. Por eso existe rastreador_simple_colas_xpath.py: mismo patrón de rastreador, motor XPath.

Scrapy

En Scrapy casi siempre usas response.css() o response.xpath() indistintamente en el mismo parse. Puedes tener un spider solo XPath (scrapy_quotes_xpath_spider.py) para practicar sin mezclar sintaxis.

Herramienta ¿Ejecuta XPath nativamente? Forma práctica de combinar
BeautifulSoup No Mismo HTML también parseado con lxml.html
lxml lh.fromstring(html).xpath("...")
Scrapy (response) response.xpath("//div...")
Rastreador + requests Depende del parser Tras requests.get, lxml o BS4 como elijas

¿Por qué un documento sobre XPath ?

Los selectores CSS son cortos y familiares si vienes del front. XPath describe rutas y relaciones (padre, hermano, posición) con una expresión: a veces menos frágil cuando las clases CSS son autogeneradas.

XPath no sustituye políticas legales ni robots.txt; solo ubicas nodos en el DOM.

Conceptos mínimos de XPath 1.0

La mayoría de motores en Python y Scrapy implementan XPath 1.0.

Rutas y predicados

  • //div[contains(@class,'quote')] — cualquier div con clase que contenga esa cadena (útil cuando hay varias clases en el mismo atributo).
  • .//span — desde el nodo actual, cualquier descendiente span (en Scrapy dentro de un selector ya acotado).
  • //a[contains(@href,'/page/')] — filtrar enlaces por parte del href.

Texto

  • string(.//span[...]) o normalize-space(...) — texto listo para guardar.
  • //a/@href — todos los valores del atributo (útil en crawlers para encolar URLs).

Receta 1 — Una página: BeautifulSoup (CSS) y lxml (XPath) sobre el mismo HTML

Archivo en disco: ~/Scripts/xpath_mismo_html_bs4_y_lxml.py

Flujo: una sola requests.getr.text → (A) BeautifulSoup + select → (B) lxml.html.fromstring + xpath. Comparas los mismos campos con dos sintaxis.

#!/usr/bin/env python3
import requests
from bs4 import BeautifulSoup
from lxml import html as lh

URL = "https://quotes.toscrape.com/"
r = requests.get(URL, headers={"User-Agent": "TutorialXPath/1.0"}, timeout=15)
r.raise_for_status()
html_texto = r.text

# A) BeautifulSoup: solo CSS / API propia
sopa = BeautifulSoup(html_texto, "lxml")
for bloque in sopa.select("div.quote"):
    ...

# B) lxml: XPath sobre el mismo string
arbol = lh.fromstring(html_texto)
for bloque in arbol.xpath("//div[contains(@class, 'quote')]"):
    texto = bloque.xpath("string(.//span[contains(@class,'text')])").strip()
    ...
  • Línea BeautifulSoup(..., "lxml") — El nombre "lxml" aquí es el parser interno de BS4 (rápido); no implica que BS4 ejecute XPath.
  • lh.fromstring(html_texto) — Construye el árbol que sí entiende .xpath().

Ejecutar

cd ~/Scripts && source venv-tutorial-rastreo/bin/activate
python3 xpath_mismo_html_bs4_y_lxml.py

Receta 2 — Rastreador con colas + XPath (requests + lxml)

Archivo: ~/Scripts/rastreador_simple_colas_xpath.py

Es el mismo diseño que rastreador_simple_colas.py (semilla, deque, visitados, retardo, lista blanca de dominio), pero la función de extracción usa:

  • doc.xpath("//div[contains(@class,'quote')]") para citas,
  • doc.xpath("//a/@href") para enlaces (luego urljoin + filtro de dominio).

Así ves que el web crawler es independiente del parser: puedes enseñar primero BS4 y luego sustituir solo el “parseo interno” por XPath.

python3 rastreador_simple_colas_xpath.py

Receta 3 — Scrapy solo con response.xpath

Archivo: ~/Scripts/scrapy_quotes_xpath_spider.py

scrapy runspider scrapy_quotes_xpath_spider.py -o citas_xpath.json

Dentro de parse no hace falta css: response.xpath("//...") devuelve selectores; sobre cada bloque usas rutas relativas .//....

Mezclar en un mismo spider

# Ilustración: un bloque con CSS, otro con XPath
for item in response.css("div.quote"):
    autor = item.xpath("normalize-space(.//small[contains(@class,'author')])").get()

Scrapy unifica la API: item es un Selector que tiene tanto css como xpath.

Probar XPath sin red (HTML guardado) — parsel

Scrapy instala parsel. Puedes pegar HTML en un fichero y experimentar:

from parsel import Selector

html = open("muestra.html", encoding="utf-8").read()
sel = Selector(text=html)
for x in sel.xpath("//div[contains(@class,'quote')]"):
    print(x.xpath("string(.)").get())

Equivalencias rápidas (quotes.toscrape.com)

Idea CSS (Scrapy) XPath
Bloque cita div.quote //div[contains(@class,'quote')]
Texto cita span.text::text .//span[contains(@class,'text')]
Siguiente página li.next a::attr(href) //li[contains(@class,'next')]/a/@href

Proyecto: Sistema Inteligente de Monitoreo y Análisis de Noticias Web 2026 Enero Junio

Descripción del Proyecto

El alumno desarrollará un Sistema Inteligente de Monitoreo y Análisis de Noticias Web (SIMANW). Este es un sistema completo que:

  1. Rastrea noticias de portales web automáticamente
  2. Procesa el texto con técnicas de lenguaje natural
  3. Clasifica las noticias por categoría y sentimiento
  4. Permite búsquedas inteligentes en lenguaje natural
  5. Detecta temas de conversación y sugiere contenido relacionado
  6. Almacena todo en un grafo de conocimiento semántico consultable
  7. Genera reportes automáticos y responde preguntas sobre los datos

El proyecto se construye de forma incremental: cada fase agrega funcionalidad al sistema y cubre los temas del programa. Al final, todas las piezas se integran en un pipeline funcional.

Arquitectura del Sistema SIMANW:

  ┌─────────────────────────────────────────────────────────────────┐
  │                         SIMANW                                  │
  │                                                                 │
  │  [Fase 1: Rastreo] ──→ [Fase 2: NLP] ──→ [Fase 3: Análisis]  │
  │         │                     │                    │            │
  │         ▼                     ▼                    ▼            │
  │  [Fase 4: Motor de Búsqueda] ←──── [Fase 5: Conversación]     │
  │         │                                          │            │
  │         ▼                                          ▼            │
  │  [Fase 6: Knowledge Graph Semántico + Datos Abiertos]          │
  │         │                                                       │
  │         ▼                                                       │
  │  [Fase 7: Reportes + Q&A]                                      │
  └─────────────────────────────────────────────────────────────────┘

Dependencias del proyecto

import subprocess, sys

dependencias = [
    'nltk', 'scikit-learn', 'numpy', 'pandas',
    'beautifulsoup4', 'requests', 'scrapy',
    'rdflib', 'SPARQLWrapper', 'transformers',
    'matplotlib'
]

for dep in dependencias:
    subprocess.check_call([sys.executable, '-m', 'pip', 'install', '-q', dep])

import nltk
nltk.download('punkt', quiet=True)
nltk.download('punkt_tab', quiet=True)
nltk.download('stopwords', quiet=True)
nltk.download('wordnet', quiet=True)
nltk.download('averaged_perceptron_tagger', quiet=True)
nltk.download('vader_lexicon', quiet=True)

Fase 1: Rastreador Web de Noticias

En esta fase el alumno construye el módulo de extracción automática de información de la web. El sistema necesita obtener noticias de forma autónoma, lo cual requiere entender el DOM HTML, definir el alcance del rastreo, y almacenar los datos extraídos.

1.1 Entendiendo el DOM y HTML Parsing

Lo primero es poder leer y navegar la estructura de una página web. El alumno implementa un parser que entiende la jerarquía del DOM.

from bs4 import BeautifulSoup

html_portal_noticias = """
<!DOCTYPE html>
<html>
<head><title>Portal de Noticias - SIMANW</title></head>
<body>
  <nav><a href="/">Inicio</a> <a href="/tech">Tech</a> <a href="/ciencia">Ciencia</a></nav>
  <main id="contenido">
    <h1>Últimas Noticias</h1>
    <article class="noticia" data-categoria="tecnologia">
      <h2>Avances en IA Generativa revolucionan la industria</h2>
      <p class="cuerpo">Los nuevos modelos de inteligencia artificial generativa
      están transformando múltiples industrias. Empresas de todo el mundo adoptan
      estas tecnologías para automatizar procesos creativos y analíticos.</p>
      <div class="meta">
        <span class="fecha">2026-05-10</span>
        <span class="autor">María García</span>
        <a href="/noticias/ia-generativa-2026" class="leer-mas">Leer más</a>
      </div>
    </article>
    <article class="noticia" data-categoria="economia">
      <h2>Mercados financieros muestran volatilidad ante incertidumbre global</h2>
      <p class="cuerpo">Los principales índices bursátiles registraron caídas
      significativas. Analistas señalan que la inflación persistente y las
      tensiones geopolíticas generan preocupación entre los inversores.</p>
      <div class="meta">
        <span class="fecha">2026-05-09</span>
        <span class="autor">Carlos Ruiz</span>
        <a href="/noticias/mercados-volatilidad" class="leer-mas">Leer más</a>
      </div>
    </article>
    <article class="noticia" data-categoria="ciencia">
      <h2>Descubrimiento científico sobre cambio climático alarma a expertos</h2>
      <p class="cuerpo">Un equipo internacional de investigadores publicó un
      estudio que revela datos preocupantes sobre el ritmo del calentamiento
      global. Los resultados superan las peores predicciones anteriores.</p>
      <div class="meta">
        <span class="fecha">2026-05-08</span>
        <span class="autor">Ana López</span>
        <a href="/noticias/clima-estudio-2026" class="leer-mas">Leer más</a>
      </div>
    </article>
    <article class="noticia" data-categoria="tecnologia">
      <h2>Python 3.14 trae mejoras significativas en rendimiento</h2>
      <p class="cuerpo">La nueva versión del lenguaje de programación Python
      incluye optimizaciones que mejoran la velocidad de ejecución hasta en un
      40%. La comunidad de desarrolladores celebra estos avances.</p>
      <div class="meta">
        <span class="fecha">2026-05-07</span>
        <span class="autor">Juan Hernández</span>
        <a href="/noticias/python-314" class="leer-mas">Leer más</a>
      </div>
    </article>
    <article class="noticia" data-categoria="gobierno">
      <h2>Gobierno lanza portal de datos abiertos con tecnología semántica</h2>
      <p class="cuerpo">La nueva plataforma gubernamental ofrece acceso a
      datasets públicos en formatos RDF y JSON-LD. Ciudadanos y desarrolladores
      pueden consultar información presupuestal y estadísticas mediante SPARQL.</p>
      <div class="meta">
        <span class="fecha">2026-05-06</span>
        <span class="autor">Pedro Sánchez</span>
        <a href="/noticias/datos-abiertos-gob" class="leer-mas">Leer más</a>
      </div>
    </article>
  </main>
  <aside>
    <h3>Tendencias</h3>
    <ul>
      <li><a href="/trend/1">#InteligenciaArtificial</a></li>
      <li><a href="/trend/2">#Python</a></li>
      <li><a href="/trend/3">#DatosAbiertos</a></li>
    </ul>
  </aside>
  <footer><p>© 2026 Portal SIMANW</p></footer>
</body>
</html>
"""

soup = BeautifulSoup(html_portal_noticias, 'html.parser')

print("=== FASE 1.1: Parsing del DOM ===\n")
print(f"Título del portal: {soup.title.string}")
print(f"Secciones de navegación: {[a.string for a in soup.nav.find_all('a')]}")
print(f"Total de artículos: {len(soup.find_all('article'))}")
print(f"Tendencias: {[li.a.string for li in soup.aside.find_all('li')]}")

print("\nEstructura del DOM detectada:")
print(f"  <html>")
print(f"    <head> → título")
print(f"    <body>")
print(f"      <nav> → {len(soup.nav.find_all('a'))} enlaces")
print(f"      <main> → {len(soup.main.find_all('article'))} artículos")
print(f"      <aside> → tendencias")
print(f"      <footer> → copyright")

1.2 Extractor de noticias (Spider)

El alumno construye el extractor que sabe navegar la estructura HTML del portal y obtener los datos estructurados de cada noticia.

class ExtractorNoticias:
    """
    Componente del SIMANW que extrae noticias de páginas HTML.
    En producción usaría Scrapy; aquí se muestra la lógica central.
    """

    def __init__(self):
        self.noticias_extraidas = []
        self.errores = []

    def extraer_de_html(self, html_content, url_base="https://portal.com"):
        """Extrae todas las noticias de una página HTML."""
        soup = BeautifulSoup(html_content, 'html.parser')
        articulos = soup.find_all('article', class_='noticia')

        for art in articulos:
            try:
                noticia = {
                    'titulo': art.find('h2').get_text(strip=True),
                    'cuerpo': art.find('p', class_='cuerpo').get_text(strip=True),
                    'fecha': art.find('span', class_='fecha').get_text(strip=True),
                    'autor': art.find('span', class_='autor').get_text(strip=True),
                    'categoria_original': art.get('data-categoria', 'sin_categoria'),
                    'url': url_base + art.find('a', class_='leer-mas')['href'],
                    'fuente': url_base,
                }
                self.noticias_extraidas.append(noticia)
            except Exception as e:
                self.errores.append(str(e))

        return self.noticias_extraidas

    def resumen_extraccion(self):
        return {
            'total_extraidas': len(self.noticias_extraidas),
            'errores': len(self.errores),
            'categorias': list(set(n['categoria_original'] for n in self.noticias_extraidas)),
            'rango_fechas': (
                min(n['fecha'] for n in self.noticias_extraidas),
                max(n['fecha'] for n in self.noticias_extraidas)
            ) if self.noticias_extraidas else None
        }


extractor = ExtractorNoticias()
noticias = extractor.extraer_de_html(html_portal_noticias)

print("=== FASE 1.2: Extracción de Noticias ===\n")
resumen = extractor.resumen_extraccion()
print(f"Noticias extraídas: {resumen['total_extraidas']}")
print(f"Errores: {resumen['errores']}")
print(f"Categorías encontradas: {resumen['categorias']}")
print(f"Rango de fechas: {resumen['rango_fechas']}")

print("\nNoticias:")
for i, n in enumerate(noticias, 1):
    print(f"  {i}. [{n['fecha']}] [{n['categoria_original']}] {n['titulo'][:60]}")
    print(f"     Autor: {n['autor']} | URL: {n['url']}")

1.3 Alcance y control del rastreo

El sistema necesita definir límites: qué URLs puede visitar, cuántas páginas rastrear, y respetar las reglas del sitio.

from urllib.parse import urlparse, urljoin
from collections import deque

class ControlRastreo:
    """Controla el alcance y la política del rastreador."""

    def __init__(self, url_semilla, modo='dominio', max_paginas=50, delay=2):
        self.url_semilla = url_semilla
        self.dominio = urlparse(url_semilla).netloc
        self.modo = modo
        self.max_paginas = max_paginas
        self.delay = delay
        self.visitadas = set()
        self.cola = deque([url_semilla])
        self.rechazadas = []

    def url_permitida(self, url):
        """Verifica si una URL está dentro del alcance definido."""
        parsed = urlparse(url)
        if self.modo == 'dominio':
            return parsed.netloc == self.dominio
        elif self.modo == 'directorio':
            base_path = urlparse(self.url_semilla).path.rsplit('/', 1)[0]
            return parsed.netloc == self.dominio and parsed.path.startswith(base_path)
        elif self.modo == 'subdominio':
            return parsed.netloc.endswith(self.dominio.split('.', 1)[-1])
        return False

    def registrar_visita(self, url):
        self.visitadas.add(url)

    def agregar_enlaces(self, enlaces):
        """Agrega enlaces descubiertos a la cola si son válidos."""
        agregados = 0
        for enlace in enlaces:
            url_abs = urljoin(self.url_semilla, enlace)
            if url_abs not in self.visitadas and self.url_permitida(url_abs):
                self.cola.append(url_abs)
                agregados += 1
            else:
                self.rechazadas.append(url_abs)
        return agregados

    def siguiente(self):
        """Obtiene la siguiente URL a visitar."""
        if self.cola and len(self.visitadas) < self.max_paginas:
            url = self.cola.popleft()
            self.registrar_visita(url)
            return url
        return None

    def estado(self):
        return {
            'visitadas': len(self.visitadas),
            'en_cola': len(self.cola),
            'rechazadas': len(self.rechazadas),
            'limite': self.max_paginas,
            'completado': len(self.visitadas) >= self.max_paginas or not self.cola
        }


control = ControlRastreo("https://portal-noticias.com/noticias/", modo='directorio', max_paginas=10)

enlaces_descubiertos = [
    "/noticias/pagina/2",
    "/noticias/tecnologia/ia-2026",
    "/deportes/futbol-liga",
    "https://otro-sitio.com/articulo",
    "/noticias/economia/mercados",
    "/noticias/ciencia/clima",
    "/contacto",
]

print("=== FASE 1.3: Control de Rastreo ===\n")
print(f"URL semilla: {control.url_semilla}")
print(f"Modo: {control.modo} | Máx páginas: {control.max_paginas}")
print(f"Delay entre peticiones: {control.delay}s")

agregados = control.agregar_enlaces(enlaces_descubiertos)
print(f"\nEnlaces descubiertos: {len(enlaces_descubiertos)}")
print(f"Agregados a la cola: {agregados}")
print(f"Rechazados: {len(control.rechazadas)}")

print("\nSimulación de rastreo:")
while True:
    url = control.siguiente()
    if not url:
        break
    print(f"  Visitando: {url}")

estado = control.estado()
print(f"\nEstado final: {estado}")

1.4 Estructura del spider en Scrapy (producción)

Así se vería el rastreador en un entorno real con Scrapy:

scrapy_code = '''
import scrapy
from scrapy.spiders import CrawlSpider, Rule
from scrapy.linkextractors import LinkExtractor

class SIMANWSpider(CrawlSpider):
    """Spider del SIMANW para producción."""
    name = 'simanw_noticias'
    allowed_domains = ['portal-noticias.com']
    start_urls = ['https://portal-noticias.com/noticias/']

    rules = (
        Rule(LinkExtractor(allow=r'/noticias/'), callback='parse_noticia', follow=True),
    )

    custom_settings = {
        'DOWNLOAD_DELAY': 2,
        'ROBOTSTXT_OBEY': True,
        'CONCURRENT_REQUESTS': 4,
        'FEED_FORMAT': 'json',
        'FEED_URI': 'noticias_%(time)s.json',
    }

    def parse_noticia(self, response):
        for articulo in response.css('article.noticia'):
            yield {
                'titulo': articulo.css('h2::text').get(),
                'cuerpo': articulo.css('p.cuerpo::text').get(),
                'fecha': articulo.css('span.fecha::text').get(),
                'autor': articulo.css('span.autor::text').get(),
                'categoria': articulo.attrib.get('data-categoria'),
                'url': response.url,
            }
'''
print("=== Código Scrapy para producción ===")
print(scrapy_code)
print("# Ejecución: scrapy crawl simanw_noticias")

Fase 2: Procesamiento de Lenguaje Natural

Ahora que el sistema tiene noticias crudas, necesita procesarlas para poder trabajar con ellas computacionalmente. Aquí el alumno construye el pipeline NLP del SIMANW.

2.1 Pipeline de pre-procesamiento

import nltk
from nltk.tokenize import word_tokenize, sent_tokenize
from nltk.corpus import stopwords
from nltk.stem import SnowballStemmer
import re
from collections import Counter

class PipelineNLP:
    """Pipeline de procesamiento de lenguaje natural del SIMANW."""

    def __init__(self, idioma='spanish'):
        self.idioma = idioma
        self.stemmer = SnowballStemmer(idioma)
        self.stop_words = set(stopwords.words(idioma))

    def limpiar(self, texto):
        """Paso 1: Limpieza básica."""
        texto = texto.lower()
        texto = re.sub(r'[^\w\sáéíóúñü]', ' ', texto)
        texto = re.sub(r'\d+', '', texto)
        texto = re.sub(r'\s+', ' ', texto).strip()
        return texto

    def tokenizar(self, texto):
        """Paso 2: Tokenización."""
        return word_tokenize(texto, language=self.idioma)

    def eliminar_stopwords(self, tokens):
        """Paso 3: Eliminar palabras vacías."""
        return [t for t in tokens if t not in self.stop_words and len(t) > 2]

    def aplicar_stemming(self, tokens):
        """Paso 4: Reducir a raíz (stem)."""
        return [self.stemmer.stem(t) for t in tokens]

    def procesar(self, texto):
        """Ejecuta el pipeline completo."""
        limpio = self.limpiar(texto)
        tokens = self.tokenizar(limpio)
        sin_sw = self.eliminar_stopwords(tokens)
        stems = self.aplicar_stemming(sin_sw)
        return {
            'original': texto,
            'limpio': limpio,
            'tokens': tokens,
            'sin_stopwords': sin_sw,
            'stems': stems,
            'num_oraciones': len(sent_tokenize(texto, language=self.idioma)),
            'vocabulario_unico': len(set(sin_sw)),
            'riqueza_lexica': len(set(sin_sw)) / max(len(sin_sw), 1)
        }

    def estadisticas_corpus(self, textos_procesados):
        """Genera estadísticas del corpus completo."""
        todos_tokens = []
        todos_stems = []
        for tp in textos_procesados:
            todos_tokens.extend(tp['sin_stopwords'])
            todos_stems.extend(tp['stems'])
        return {
            'total_documentos': len(textos_procesados),
            'total_tokens': len(todos_tokens),
            'vocabulario_total': len(set(todos_tokens)),
            'stems_unicos': len(set(todos_stems)),
            'palabras_frecuentes': Counter(todos_tokens).most_common(10),
            'promedio_tokens_doc': len(todos_tokens) / max(len(textos_procesados), 1)
        }


pipeline = PipelineNLP()

print("=== FASE 2.1: Pipeline NLP ===\n")
print("Procesando noticias extraídas...\n")

noticias_procesadas = []
for noticia in noticias:
    texto_completo = f"{noticia['titulo']}. {noticia['cuerpo']}"
    resultado = pipeline.procesar(texto_completo)
    noticia['nlp'] = resultado
    noticias_procesadas.append(resultado)
    print(f"  [{noticia['categoria_original']}] {noticia['titulo'][:50]}...")
    print(f"    Tokens: {len(resultado['tokens'])} → Sin SW: {len(resultado['sin_stopwords'])} → Stems: {len(resultado['stems'])}")
    print(f"    Riqueza léxica: {resultado['riqueza_lexica']:.3f}")
    print()

stats = pipeline.estadisticas_corpus(noticias_procesadas)
print("--- Estadísticas del Corpus ---")
for k, v in stats.items():
    if k != 'palabras_frecuentes':
        print(f"  {k}: {v}")
print(f"\n  Palabras más frecuentes:")
for palabra, freq in stats['palabras_frecuentes']:
    print(f"    '{palabra}': {freq}")

2.2 Representación vectorial (TF-IDF)

El sistema convierte las noticias en vectores numéricos para poder compararlas matemáticamente.

from sklearn.feature_extraction.text import TfidfVectorizer
import numpy as np

class RepresentacionVectorial:
    """Convierte los documentos del SIMANW en vectores TF-IDF."""

    def __init__(self, max_features=2000, ngram_range=(1, 2)):
        self.vectorizer = TfidfVectorizer(
            max_features=max_features,
            ngram_range=ngram_range,
            sublinear_tf=True
        )
        self.matriz = None
        self.documentos_texto = []

    def construir_matriz(self, documentos):
        """Construye la matriz TF-IDF a partir de textos pre-procesados."""
        self.documentos_texto = documentos
        self.matriz = self.vectorizer.fit_transform(documentos)
        return self.matriz

    def vocabulario(self):
        return self.vectorizer.get_feature_names_out()

    def top_terminos_documento(self, doc_idx, n=8):
        """Términos más importantes de un documento."""
        vector = self.matriz[doc_idx].toarray().flatten()
        terminos = self.vocabulario()
        indices_top = vector.argsort()[::-1][:n]
        return [(terminos[i], vector[i]) for i in indices_top if vector[i] > 0]

    def info_matriz(self):
        return {
            'documentos': self.matriz.shape[0],
            'features': self.matriz.shape[1],
            'densidad': self.matriz.nnz / (self.matriz.shape[0] * self.matriz.shape[1]),
            'terminos_promedio_doc': self.matriz.nnz / self.matriz.shape[0]
        }


# Usar textos pre-procesados (sin stopwords, reunidos)
textos_para_vectorizar = [' '.join(n['nlp']['sin_stopwords']) for n in noticias]

representacion = RepresentacionVectorial()
representacion.construir_matriz(textos_para_vectorizar)

print("=== FASE 2.2: Representación Vectorial TF-IDF ===\n")
info = representacion.info_matriz()
print(f"Matriz TF-IDF: {info['documentos']} docs × {info['features']} features")
print(f"Densidad: {info['densidad']:.4f}")
print(f"Términos promedio por documento: {info['terminos_promedio_doc']:.1f}")

print(f"\nVocabulario (muestra): {list(representacion.vocabulario()[:15])}")

print("\nTérminos más relevantes por noticia:")
for i, noticia in enumerate(noticias):
    print(f"\n  [{noticia['categoria_original']}] {noticia['titulo'][:45]}...")
    top = representacion.top_terminos_documento(i, n=5)
    for termino, peso in top:
        print(f"    {termino:<20} = {peso:.4f}")

2.3 Cálculo de similitud entre noticias

Con los vectores, el sistema puede determinar qué noticias son parecidas entre sí.

from sklearn.metrics.pairwise import cosine_similarity

class CalculadorSimilitud:
    """Calcula similitudes entre documentos del SIMANW."""

    def __init__(self, matriz_tfidf):
        self.matriz = matriz_tfidf
        self.sim_matrix = cosine_similarity(matriz_tfidf)

    def similitud_par(self, doc_i, doc_j):
        return self.sim_matrix[doc_i][doc_j]

    def documentos_similares(self, doc_idx, top_n=3):
        """Encuentra los documentos más similares a uno dado."""
        similitudes = self.sim_matrix[doc_idx]
        indices = similitudes.argsort()[::-1][1:top_n+1]
        return [(idx, similitudes[idx]) for idx in indices]

    def agrupar_por_similitud(self, umbral=0.15):
        """Agrupa documentos que superen un umbral de similitud."""
        grupos = []
        visitados = set()
        for i in range(len(self.sim_matrix)):
            if i in visitados:
                continue
            grupo = [i]
            visitados.add(i)
            for j in range(i+1, len(self.sim_matrix)):
                if j not in visitados and self.sim_matrix[i][j] >= umbral:
                    grupo.append(j)
                    visitados.add(j)
            grupos.append(grupo)
        return grupos


calculador = CalculadorSimilitud(representacion.matriz)

print("=== FASE 2.3: Similitud entre Noticias ===\n")
print("Matriz de similitud coseno:")
print(f"{'':>5}", end="")
for i in range(len(noticias)):
    print(f"{'N'+str(i+1):>7}", end="")
print()
for i in range(len(noticias)):
    print(f"N{i+1:>3}", end=" ")
    for j in range(len(noticias)):
        print(f"{calculador.similitud_par(i,j):>7.3f}", end="")
    print()

print("\nNoticias más similares entre sí:")
for i, noticia in enumerate(noticias):
    similares = calculador.documentos_similares(i, top_n=1)
    if similares:
        j, sim = similares[0]
        if sim > 0.05:
            print(f"  N{i+1} ↔ N{j+1} (sim={sim:.3f})")
            print(f"    '{noticia['titulo'][:40]}...'")
            print(f"    '{noticias[j]['titulo'][:40]}...'")

print("\nGrupos temáticos detectados:")
grupos = calculador.agrupar_por_similitud(umbral=0.1)
for g_idx, grupo in enumerate(grupos):
    print(f"  Grupo {g_idx+1}: {['N'+str(i+1) for i in grupo]}")
    for i in grupo:
        print(f"    - {noticias[i]['titulo'][:50]}")

Fase 3: Clasificación y Análisis Automático

El sistema ahora debe categorizar automáticamente cada noticia y analizar su tono (sentimiento). También detecta temas en conversaciones para poder orientar publicidad.

3.1 Clasificador automático de noticias

from sklearn.naive_bayes import MultinomialNB
from sklearn.svm import LinearSVC
from sklearn.model_selection import cross_val_score
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import classification_report
import numpy as np

class ClasificadorNoticias:
    """Clasifica noticias automáticamente por categoría."""

    def __init__(self):
        self.vectorizer = TfidfVectorizer(max_features=3000, ngram_range=(1, 2))
        self.clasificador = LinearSVC(max_iter=2000)
        self.categorias = []
        self.entrenado = False

    def entrenar(self, textos, etiquetas):
        """Entrena el clasificador con datos etiquetados."""
        X = self.vectorizer.fit_transform(textos)
        self.clasificador.fit(X, etiquetas)
        self.categorias = list(set(etiquetas))
        self.entrenado = True

        scores = cross_val_score(self.clasificador, X, etiquetas, cv=min(3, len(textos)//2))
        return {
            'accuracy_cv': scores.mean(),
            'categorias': self.categorias,
            'n_muestras': len(textos)
        }

    def predecir(self, textos):
        """Predice la categoría de nuevos textos."""
        X = self.vectorizer.transform(textos)
        return self.clasificador.predict(X)

    def predecir_con_confianza(self, texto):
        """Predice con scores de decisión."""
        X = self.vectorizer.transform([texto])
        decision = self.clasificador.decision_function(X)[0]
        prediccion = self.clasificador.predict(X)[0]
        return prediccion, dict(zip(self.clasificador.classes_, decision))


# Datos de entrenamiento (en producción vendrían del rastreo acumulado)
textos_entrenamiento = [
    "inteligencia artificial machine learning algoritmos redes neuronales deep learning",
    "nuevo procesador computadora software desarrollo programación tecnología",
    "startup tecnológica lanza aplicación innovadora plataforma digital",
    "robot automatización industria software empresa tecnología",
    "mercados financieros bolsa acciones inversión capital rendimiento",
    "inflación economía banco central tasas interés política monetaria",
    "desempleo crisis económica recesión PIB crecimiento producto interno",
    "comercio internacional exportaciones importaciones aranceles tratado",
    "estudio científico investigadores descubrimiento laboratorio publicación",
    "cambio climático calentamiento global temperatura emisiones carbono",
    "vacuna tratamiento médico salud enfermedad hospital pacientes",
    "espacio NASA cohete satélite misión exploración astronauta",
    "elecciones candidato presidente congreso voto democracia partido",
    "gobierno ley reforma política pública decreto legislación",
    "seguridad pública policía crimen delito justicia tribunal",
    "presupuesto gasto público programa social gobierno federal",
]

etiquetas_entrenamiento = [
    'tecnologia', 'tecnologia', 'tecnologia', 'tecnologia',
    'economia', 'economia', 'economia', 'economia',
    'ciencia', 'ciencia', 'ciencia', 'ciencia',
    'politica', 'politica', 'politica', 'politica',
]

clasificador = ClasificadorNoticias()
resultado_entrenamiento = clasificador.entrenar(textos_entrenamiento, etiquetas_entrenamiento)

print("=== FASE 3.1: Clasificador de Noticias ===\n")
print(f"Entrenamiento completado:")
print(f"  Muestras: {resultado_entrenamiento['n_muestras']}")
print(f"  Categorías: {resultado_entrenamiento['categorias']}")
print(f"  Accuracy (CV): {resultado_entrenamiento['accuracy_cv']:.3f}")

print("\nClasificación automática de noticias del SIMANW:")
for noticia in noticias:
    texto = f"{noticia['titulo']} {noticia['cuerpo']}"
    prediccion, scores = clasificador.predecir_con_confianza(texto)
    noticia['categoria_predicha'] = prediccion
    print(f"\n  Título: {noticia['titulo'][:55]}...")
    print(f"  Cat. original: {noticia['categoria_original']} | Predicha: {prediccion}")
    top_scores = sorted(scores.items(), key=lambda x: -x[1])[:3]
    print(f"  Scores: {', '.join(f'{c}={s:.2f}' for c,s in top_scores)}")

3.2 Análisis de sentimientos

El SIMANW necesita saber si las noticias tienen un tono positivo, negativo o neutral para generar reportes de percepción.

from nltk.sentiment import SentimentIntensityAnalyzer

class AnalizadorSentimientos:
    """Analiza el sentimiento de las noticias del SIMANW."""

    def __init__(self):
        self.sia = SentimentIntensityAnalyzer()

    def analizar(self, texto):
        scores = self.sia.polarity_scores(texto)
        compound = scores['compound']
        if compound >= 0.05:
            etiqueta = 'positivo'
        elif compound <= -0.05:
            etiqueta = 'negativo'
        else:
            etiqueta = 'neutral'
        return {
            'positivo': scores['pos'],
            'negativo': scores['neg'],
            'neutral': scores['neu'],
            'compound': compound,
            'etiqueta': etiqueta
        }

    def analizar_corpus(self, documentos):
        """Analiza sentimiento de un conjunto de documentos."""
        resultados = [self.analizar(doc) for doc in documentos]
        distribucion = Counter(r['etiqueta'] for r in resultados)
        promedio = sum(r['compound'] for r in resultados) / len(resultados)
        return resultados, {
            'distribucion': dict(distribucion),
            'sentimiento_promedio': promedio,
            'tono_general': 'positivo' if promedio > 0.05 else 'negativo' if promedio < -0.05 else 'neutral'
        }


analizador_sent = AnalizadorSentimientos()

print("=== FASE 3.2: Análisis de Sentimientos ===\n")
textos_noticias = [n['cuerpo'] for n in noticias]
resultados_sent, resumen_sent = analizador_sent.analizar_corpus(textos_noticias)

for i, (noticia, sent) in enumerate(zip(noticias, resultados_sent)):
    noticia['sentimiento'] = sent
    indicador = "↑" if sent['etiqueta'] == 'positivo' else "↓" if sent['etiqueta'] == 'negativo' else "→"
    print(f"  {indicador} [{sent['compound']:+.3f}] {noticia['titulo'][:55]}")

print(f"\n--- Resumen de Sentimiento del Corpus ---")
print(f"  Distribución: {resumen_sent['distribucion']}")
print(f"  Promedio: {resumen_sent['sentimiento_promedio']:+.3f}")
print(f"  Tono general: {resumen_sent['tono_general'].upper()}")

3.3 Sistema de recomendación por contenido

El SIMANW recomienda noticias relacionadas al usuario basándose en similitud de contenido.

class SistemaRecomendacion:
    """Recomienda noticias relacionadas basándose en contenido."""

    def __init__(self, noticias, matriz_similitud):
        self.noticias = noticias
        self.sim_matrix = matriz_similitud

    def recomendar(self, noticia_idx, top_n=2, excluir_misma_cat=False):
        """Recomienda noticias similares."""
        similitudes = self.sim_matrix[noticia_idx]
        candidatos = []
        for i, sim in enumerate(similitudes):
            if i == noticia_idx:
                continue
            if excluir_misma_cat and \
               self.noticias[i]['categoria_original'] == self.noticias[noticia_idx]['categoria_original']:
                continue
            candidatos.append((i, sim))
        candidatos.sort(key=lambda x: -x[1])
        return candidatos[:top_n]

    def recomendar_por_perfil(self, indices_leidos, top_n=3):
        """Recomienda basándose en múltiples noticias leídas (perfil de usuario)."""
        sim_acumulada = np.zeros(len(self.noticias))
        for idx in indices_leidos:
            sim_acumulada += self.sim_matrix[idx]
        for idx in indices_leidos:
            sim_acumulada[idx] = 0
        mejores = sim_acumulada.argsort()[::-1][:top_n]
        return [(i, sim_acumulada[i]) for i in mejores if sim_acumulada[i] > 0]


recomendador = SistemaRecomendacion(noticias, calculador.sim_matrix)

print("=== FASE 3.3: Sistema de Recomendación ===\n")
print("Si leíste esta noticia, te recomendamos:")
for i in range(len(noticias)):
    recomendaciones = recomendador.recomendar(i, top_n=2)
    print(f"\n  Leíste: '{noticias[i]['titulo'][:50]}...'")
    for j, sim in recomendaciones:
        print(f"    → [{sim:.3f}] {noticias[j]['titulo'][:50]}...")

print("\n\nRecomendación por perfil (si leyó noticias 1 y 4 - tecnología):")
perfil_recs = recomendador.recomendar_por_perfil([0, 3], top_n=2)
for idx, score in perfil_recs:
    print(f"  → [{score:.3f}] {noticias[idx]['titulo'][:55]}")

3.4 Detección de temas en conversación para publicidad dirigida

El SIMANW incluye un componente de chat donde los usuarios discuten noticias. El sistema detecta el tema de la conversación para mostrar publicidad relevante.

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from collections import Counter

class DetectorTemasPublicidad:
    """
    Detecta el tema de una conversación en tiempo real
    y sugiere publicidad dirigida.
    """

    def __init__(self):
        self.mensajes = []
        self.catalogo_publicidad = {
            'tecnologia': [
                "Curso de IA y Machine Learning - 50% descuento",
                "Laptop para programadores - i9 + 32GB RAM",
                "Conferencia Tech 2026 - Boletos disponibles",
            ],
            'economia': [
                "App de inversiones - Comienza con $100",
                "Curso de finanzas personales gratuito",
                "Tarjeta de crédito sin anualidad",
            ],
            'ciencia': [
                "Suscripción a revista científica digital",
                "Telescopio astronómico - envío gratis",
                "Curso de ciencia de datos online",
            ],
            'politica': [
                "Portal de transparencia gubernamental",
                "Notificaciones de cambios legislativos",
                "Foro de participación ciudadana",
            ],
        }
        self.perfiles_tema = None
        self._construir_perfiles()

    def _construir_perfiles(self):
        """Construye vectores representativos de cada tema."""
        temas_texto = {
            'tecnologia': "inteligencia artificial programación software apps tecnología computadora robot digital innovación",
            'economia': "dinero inversión mercado bolsa finanzas banco economía empleo trabajo",
            'ciencia': "investigación científico descubrimiento estudio laboratorio clima universo",
            'politica': "gobierno elecciones presidente ley congreso partido democracia",
        }
        self.temas = list(temas_texto.keys())
        self.vec_temas = TfidfVectorizer()
        self.matriz_temas = self.vec_temas.fit_transform(temas_texto.values())

    def agregar_mensaje(self, usuario, texto):
        self.mensajes.append({'usuario': usuario, 'texto': texto})

    def detectar_tema(self, ventana=5):
        """Detecta el tema dominante en los últimos N mensajes."""
        if not self.mensajes:
            return 'general', 0.0

        ultimos = self.mensajes[-ventana:]
        texto_ventana = ' '.join(m['texto'] for m in ultimos)
        vec_conversacion = self.vec_temas.transform([texto_ventana])
        similitudes = cosine_similarity(vec_conversacion, self.matriz_temas)[0]

        mejor_idx = similitudes.argmax()
        return self.temas[mejor_idx], similitudes[mejor_idx]

    def obtener_publicidad(self, tema):
        """Selecciona publicidad para el tema detectado."""
        import random
        if tema in self.catalogo_publicidad:
            return random.choice(self.catalogo_publicidad[tema])
        return "Descubre las mejores ofertas del día"

    def simular_chat(self, conversacion):
        """Simula un chat completo con detección de publicidad."""
        resultados = []
        for usuario, mensaje in conversacion:
            self.agregar_mensaje(usuario, mensaje)
            tema, confianza = self.detectar_tema()
            publicidad = self.obtener_publicidad(tema)
            resultados.append({
                'usuario': usuario,
                'mensaje': mensaje,
                'tema': tema,
                'confianza': confianza,
                'publicidad': publicidad
            })
        return resultados


detector = DetectorTemasPublicidad()

conversacion_usuarios = [
    ("Laura", "¿Vieron la noticia sobre la nueva IA de Google?"),
    ("Miguel", "Sí, dicen que puede programar mejor que muchos desarrolladores"),
    ("Laura", "Me preocupa el futuro del trabajo en tecnología"),
    ("Roberto", "Yo creo que es una oportunidad, hay que aprender machine learning"),
    ("Miguel", "Cambiando de tema, ¿cómo ven la economía este trimestre?"),
    ("Laura", "Los mercados están muy volátiles, mis inversiones bajaron"),
    ("Roberto", "El banco central anunció que subirá las tasas de interés"),
    ("Miguel", "Mejor hay que diversificar, quizá invertir en fondos indexados"),
]

print("=== FASE 3.4: Detección de Temas + Publicidad ===\n")
print("Simulación de chat con publicidad dirigida:")
print("─" * 65)
resultados_chat = detector.simular_chat(conversacion_usuarios)

for r in resultados_chat:
    print(f"  [{r['usuario']}]: {r['mensaje']}")
    print(f"    Tema: {r['tema'].upper()} (confianza: {r['confianza']:.3f})")
    print(f"    Ad: {r['publicidad']}")
    print()

print("─" * 65)
temas_conv = Counter(r['tema'] for r in resultados_chat)
print(f"Resumen de temas en la conversación: {dict(temas_conv)}")

Fase 4: Motor de Búsqueda Inteligente

El alumno construye el motor de búsqueda del SIMANW que permite a los usuarios encontrar noticias usando lenguaje natural, y evalúa su efectividad con métricas formales.

  • 4.1 Índice invertido y búsqueda
    import math
    from collections import Counter, defaultdict
    
    class MotorBusqueda:
        """Motor de búsqueda del SIMANW con índice invertido y ranking TF-IDF."""
    
        def __init__(self):
            self.documentos = {}
            self.indice_invertido = defaultdict(dict)
            self.doc_lengths = {}
            self.N = 0
            self.vectorizer = TfidfVectorizer(max_features=3000, ngram_range=(1, 2))
            self.matriz_busqueda = None
    
        def indexar(self, documentos):
            """Construye el índice invertido."""
            self.N = len(documentos)
            textos = []
            for doc_id, doc in enumerate(documentos):
                self.documentos[doc_id] = doc
                texto = f"{doc['titulo']} {doc['cuerpo']}"
                textos.append(texto)
                tokens = texto.lower().split()
                tf = Counter(tokens)
                self.doc_lengths[doc_id] = len(tokens)
                for term, freq in tf.items():
                    self.indice_invertido[term][doc_id] = freq
    
            self.matriz_busqueda = self.vectorizer.fit_transform(textos)
    
        def buscar_booleana(self, consulta, modo='AND'):
            """Búsqueda booleana simple."""
            terminos = consulta.lower().split()
            if modo == 'AND':
                result = set(self.indice_invertido.get(terminos[0], {}).keys()) if terminos else set()
                for t in terminos[1:]:
                    result &= set(self.indice_invertido.get(t, {}).keys())
            else:  # OR
                result = set()
                for t in terminos:
                    result |= set(self.indice_invertido.get(t, {}).keys())
            return list(result)
    
        def buscar_vectorial(self, consulta, top_k=5):
            """Búsqueda por similitud vectorial (ranking)."""
            consulta_vec = self.vectorizer.transform([consulta])
            similitudes = cosine_similarity(consulta_vec, self.matriz_busqueda)[0]
            indices = similitudes.argsort()[::-1][:top_k]
            resultados = []
            for idx in indices:
                if similitudes[idx] > 0:
                    resultados.append({
                        'doc_id': idx,
                        'titulo': self.documentos[idx]['titulo'],
                        'relevancia': float(similitudes[idx]),
                        'categoria': self.documentos[idx].get('categoria_predicha',
                                     self.documentos[idx].get('categoria_original', '?')),
                        'sentimiento': self.documentos[idx].get('sentimiento', {}).get('etiqueta', '?'),
                        'snippet': self.documentos[idx]['cuerpo'][:80] + '...'
                    })
            return resultados
    
        def info_indice(self):
            return {
                'documentos_indexados': self.N,
                'terminos_en_indice': len(self.indice_invertido),
                'tamano_promedio_posting': sum(len(v) for v in self.indice_invertido.values()) / max(len(self.indice_invertido), 1)
            }
    
    
    motor = MotorBusqueda()
    motor.indexar(noticias)
    
    print("=== FASE 4.1: Motor de Búsqueda ===\n")
    info = motor.info_indice()
    print(f"Índice construido:")
    print(f"  Documentos: {info['documentos_indexados']}")
    print(f"  Términos únicos: {info['terminos_en_indice']}")
    print(f"  Postings promedio: {info['tamano_promedio_posting']:.2f}")
    
    consultas = [
        "inteligencia artificial tecnología",
        "mercados financieros economía",
        "datos abiertos gobierno semántica",
        "Python programación desarrollo",
        "cambio climático investigación científica",
    ]
    
    print("\n--- Resultados de Búsqueda ---")
    for consulta in consultas:
        resultados = motor.buscar_vectorial(consulta, top_k=2)
        print(f"\n  Consulta: '{consulta}'")
        for r in resultados:
            print(f"    [{r['relevancia']:.3f}] {r['titulo'][:50]}...")
            print(f"      Cat: {r['categoria']} | Sent: {r['sentimiento']}")
    
  • 4.2 Evaluación del motor de búsqueda
    class EvaluadorIRS:
        """Evalúa la efectividad del motor de búsqueda del SIMANW."""
    
        @staticmethod
        def precision(recuperados, relevantes):
            recuperados = set(recuperados)
            relevantes = set(relevantes)
            tp = len(recuperados & relevantes)
            return tp / len(recuperados) if recuperados else 0
    
        @staticmethod
        def recall(recuperados, relevantes):
            recuperados = set(recuperados)
            relevantes = set(relevantes)
            tp = len(recuperados & relevantes)
            return tp / len(relevantes) if relevantes else 0
    
        @staticmethod
        def f1(precision, recall):
            if precision + recall == 0:
                return 0
            return 2 * precision * recall / (precision + recall)
    
        @staticmethod
        def precision_at_k(ranking, relevantes, k):
            """Precision considerando solo los top-k resultados."""
            top_k = set(ranking[:k])
            relevantes = set(relevantes)
            return len(top_k & relevantes) / k
    
        @staticmethod
        def average_precision(ranking, relevantes):
            """Average Precision para un ranking."""
            relevantes = set(relevantes)
            suma = 0
            relevantes_encontrados = 0
            for i, doc in enumerate(ranking, 1):
                if doc in relevantes:
                    relevantes_encontrados += 1
                    suma += relevantes_encontrados / i
            return suma / len(relevantes) if relevantes else 0
    
        def evaluar_consulta(self, recuperados_ids, relevantes_ids, total_docs):
            """Evaluación completa de una consulta."""
            p = self.precision(recuperados_ids, relevantes_ids)
            r = self.recall(recuperados_ids, relevantes_ids)
            f = self.f1(p, r)
            ap = self.average_precision(recuperados_ids, relevantes_ids)
            return {
                'precision': p,
                'recall': r,
                'f1': f,
                'average_precision': ap
            }
    
    
    evaluador = EvaluadorIRS()
    
    # Evaluación simulada: para "inteligencia artificial", las noticias 0 y 3 son relevantes
    evaluaciones = [
        {'consulta': 'inteligencia artificial', 'relevantes': [0, 3], 'recuperados': [0, 3, 1]},
        {'consulta': 'economía mercados', 'relevantes': [1], 'recuperados': [1, 4, 2]},
        {'consulta': 'datos gobierno', 'relevantes': [4], 'recuperados': [4, 2]},
    ]
    
    print("=== FASE 4.2: Evaluación del Motor de Búsqueda ===\n")
    print(f"{'Consulta':<25} {'Precision':>10} {'Recall':>10} {'F1':>10} {'AP':>10}")
    print("─" * 67)
    
    map_total = 0
    for ev in evaluaciones:
        metricas = evaluador.evaluar_consulta(ev['recuperados'], ev['relevantes'], len(noticias))
        map_total += metricas['average_precision']
        print(f"{ev['consulta']:<25} {metricas['precision']:>10.3f} {metricas['recall']:>10.3f} "
              f"{metricas['f1']:>10.3f} {metricas['average_precision']:>10.3f}")
    
    map_score = map_total / len(evaluaciones)
    print(f"\n  MAP (Mean Average Precision): {map_score:.3f}")
    
    print("\n--- Precision@K para 'inteligencia artificial' ---")
    ranking = [0, 3, 1, 2, 4]
    relevantes = [0, 3]
    for k in range(1, 6):
        pk = evaluador.precision_at_k(ranking, relevantes, k)
        print(f"  P@{k} = {pk:.3f}")
    
  • 4.3 Búsqueda en lenguaje natural (como consultar una base de datos)

    El usuario puede hacer preguntas naturales y el sistema las entiende.

    class BusquedaNatural:
        """Permite buscar noticias con frases naturales en español."""
    
        def __init__(self, motor_busqueda):
            self.motor = motor_busqueda
    
        def interpretar_consulta(self, consulta_natural):
            """Interpreta una consulta en lenguaje natural."""
            consulta_natural = consulta_natural.lower()
            filtros = {'sentimiento': None, 'categoria': None}
    
            if any(p in consulta_natural for p in ['buena', 'positiva', 'optimista']):
                filtros['sentimiento'] = 'positivo'
            elif any(p in consulta_natural for p in ['mala', 'negativa', 'pesimista', 'preocupante']):
                filtros['sentimiento'] = 'negativo'
    
            categorias_map = {
                'tecnología': 'tecnologia', 'tech': 'tecnologia', 'computación': 'tecnologia',
                'economía': 'economia', 'mercados': 'economia', 'finanzas': 'economia',
                'ciencia': 'ciencia', 'científico': 'ciencia', 'investigación': 'ciencia',
                'gobierno': 'politica', 'política': 'politica',
            }
            for keyword, cat in categorias_map.items():
                if keyword in consulta_natural:
                    filtros['categoria'] = cat
                    break
    
            return filtros
    
        def buscar_natural(self, consulta_natural, top_k=3):
            """Búsqueda que entiende lenguaje natural."""
            filtros = self.interpretar_consulta(consulta_natural)
            resultados = self.motor.buscar_vectorial(consulta_natural, top_k=top_k * 2)
    
            # Aplicar filtros
            filtrados = []
            for r in resultados:
                if filtros['sentimiento'] and r['sentimiento'] != filtros['sentimiento']:
                    continue
                if filtros['categoria'] and r['categoria'] != filtros['categoria']:
                    continue
                filtrados.append(r)
    
            return filtrados[:top_k] if filtrados else resultados[:top_k]
    
    
    busqueda_nl = BusquedaNatural(motor)
    
    consultas_naturales = [
        "Muéstrame noticias positivas sobre tecnología",
        "¿Qué noticias hay sobre datos del gobierno?",
        "Busco información preocupante sobre el clima",
        "¿Hay algo nuevo de programación en Python?",
    ]
    
    print("=== FASE 4.3: Búsqueda en Lenguaje Natural ===\n")
    for consulta in consultas_naturales:
        resultados = busqueda_nl.buscar_natural(consulta, top_k=2)
        print(f"  Usuario: \"{consulta}\"")
        if resultados:
            for r in resultados:
                print(f"    → [{r['relevancia']:.3f}] {r['titulo'][:50]}...")
        else:
            print(f"    → Sin resultados relevantes")
        print()
    

Fase 5: Chatbot y Sistema Question/Answering

El alumno integra un chatbot que responde preguntas sobre las noticias procesadas por el sistema, combinando recuperación de información con generación de respuestas.

  • 5.1 Chatbot basado en similitud
    class ChatbotSIMANW:
        """
        Chatbot del SIMANW que responde preguntas sobre las noticias
        procesadas usando cálculo de similitud.
        """
    
        def __init__(self, noticias):
            self.noticias = noticias
            self.historial = []
            self.vectorizer = TfidfVectorizer(ngram_range=(1, 2), max_features=2000)
            self._construir_base()
    
        def _construir_base(self):
            """Construye la base de conocimiento a partir de las noticias."""
            self.pares_qa = []
            for n in self.noticias:
                titulo = n['titulo']
                cuerpo = n['cuerpo']
                cat = n.get('categoria_predicha', n.get('categoria_original', ''))
                sent = n.get('sentimiento', {}).get('etiqueta', '')
    
                self.pares_qa.append({
                    'contexto': f"{titulo} {cuerpo}",
                    'respuesta': f"{titulo}. {cuerpo}",
                    'tipo': 'contenido'
                })
                self.pares_qa.append({
                    'contexto': f"categoría tema tipo {cat} {titulo}",
                    'respuesta': f"Esa noticia pertenece a la categoría '{cat}': {titulo}",
                    'tipo': 'categoria'
                })
                self.pares_qa.append({
                    'contexto': f"sentimiento opinión tono {sent} {titulo}",
                    'respuesta': f"El tono de esa noticia es {sent}: {titulo}",
                    'tipo': 'sentimiento'
                })
    
            contextos = [p['contexto'] for p in self.pares_qa]
            self.matriz_qa = self.vectorizer.fit_transform(contextos)
    
        def responder(self, pregunta, umbral=0.05):
            """Genera respuesta a una pregunta del usuario."""
            pregunta_vec = self.vectorizer.transform([pregunta])
            similitudes = cosine_similarity(pregunta_vec, self.matriz_qa)[0]
            mejor_idx = similitudes.argmax()
            confianza = similitudes[mejor_idx]
    
            self.historial.append({'pregunta': pregunta, 'confianza': confianza})
    
            if confianza < umbral:
                return "No tengo información suficiente para responder eso. ¿Puedes reformular tu pregunta?", 0.0
    
            return self.pares_qa[mejor_idx]['respuesta'], confianza
    
        def resumen_interaccion(self):
            n = len(self.historial)
            if n == 0:
                return "Sin interacciones"
            avg = sum(h['confianza'] for h in self.historial) / n
            return f"{n} preguntas, confianza promedio: {avg:.3f}"
    
    
    chatbot = ChatbotSIMANW(noticias)
    
    print("=== FASE 5.1: Chatbot del SIMANW ===\n")
    preguntas_usuario = [
        "¿Qué noticias hay sobre inteligencia artificial?",
        "¿Cuál es el tono de la noticia de los mercados financieros?",
        "¿Hay algo sobre datos abiertos del gobierno?",
        "¿Qué noticias de tecnología tienen sentimiento positivo?",
        "¿Cuál es la capital de Francia?",
    ]
    
    for pregunta in preguntas_usuario:
        respuesta, confianza = chatbot.responder(pregunta)
        print(f"  Usuario: {pregunta}")
        print(f"  Bot [{confianza:.3f}]: {respuesta[:100]}...")
        print()
    
    print(f"Resumen: {chatbot.resumen_interaccion()}")
    
  • 5.2 Sistema Question/Answering completo

    El sistema Q&A combina recuperación de información con comprensión de preguntas para dar respuestas precisas.

    import re
    
    class SistemaQA:
        """
        Sistema de pregunta-respuesta que comprende la intención del usuario
        y genera respuestas a partir de la información indexada.
        """
    
        def __init__(self, noticias, motor_busqueda):
            self.noticias = noticias
            self.motor = motor_busqueda
            self.historial_conversacion = []
    
        def clasificar_intencion(self, pregunta):
            """Determina qué tipo de información busca el usuario."""
            pregunta_lower = pregunta.lower()
            if any(w in pregunta_lower for w in ['cuántas', 'cuantas', 'total', 'número']):
                return 'conteo'
            elif any(w in pregunta_lower for w in ['resumen', 'resume', 'sintetiza']):
                return 'resumen'
            elif any(w in pregunta_lower for w in ['sentimiento', 'tono', 'opinión', 'positiv', 'negativ']):
                return 'sentimiento'
            elif any(w in pregunta_lower for w in ['categoría', 'tema', 'tipo', 'clasifica']):
                return 'categoria'
            elif any(w in pregunta_lower for w in ['compara', 'diferencia', 'relación']):
                return 'comparacion'
            elif any(w in pregunta_lower for w in ['recomienda', 'sugiere', 'similar']):
                return 'recomendacion'
            else:
                return 'busqueda'
    
        def generar_respuesta(self, pregunta):
            """Genera una respuesta inteligente según la intención."""
            intencion = self.clasificar_intencion(pregunta)
    
            if intencion == 'conteo':
                return self._respuesta_conteo(pregunta)
            elif intencion == 'sentimiento':
                return self._respuesta_sentimiento(pregunta)
            elif intencion == 'categoria':
                return self._respuesta_categoria(pregunta)
            elif intencion == 'resumen':
                return self._respuesta_resumen()
            elif intencion == 'recomendacion':
                return self._respuesta_recomendacion(pregunta)
            else:
                return self._respuesta_busqueda(pregunta)
    
        def _respuesta_conteo(self, pregunta):
            cats = Counter(n.get('categoria_predicha', n['categoria_original']) for n in self.noticias)
            resp = f"Tengo {len(self.noticias)} noticias indexadas. "
            resp += "Distribución: " + ", ".join(f"{c}: {n}" for c, n in cats.most_common())
            return resp, 'conteo', 1.0
    
        def _respuesta_sentimiento(self, pregunta):
            sentimientos = Counter(n['sentimiento']['etiqueta'] for n in self.noticias if 'sentimiento' in n)
            promedio = sum(n['sentimiento']['compound'] for n in self.noticias if 'sentimiento' in n) / len(self.noticias)
            resp = f"Análisis de sentimiento: {dict(sentimientos)}. "
            resp += f"El tono general es {'positivo' if promedio > 0 else 'negativo'} (promedio: {promedio:+.3f})."
            return resp, 'sentimiento', 0.9
    
        def _respuesta_categoria(self, pregunta):
            cats = Counter(n.get('categoria_predicha', n['categoria_original']) for n in self.noticias)
            resp = "Categorías detectadas en las noticias:\n"
            for cat, count in cats.most_common():
                ejemplos = [n['titulo'][:40] for n in self.noticias
                           if n.get('categoria_predicha', n['categoria_original']) == cat]
                resp += f"  - {cat} ({count}): {ejemplos[0]}...\n"
            return resp, 'categoria', 0.9
    
        def _respuesta_resumen(self):
            resp = f"Resumen del corpus ({len(self.noticias)} noticias):\n"
            for n in self.noticias:
                sent = n.get('sentimiento', {}).get('etiqueta', '?')
                cat = n.get('categoria_predicha', n['categoria_original'])
                resp += f"  - [{cat}][{sent}] {n['titulo'][:50]}\n"
            return resp, 'resumen', 1.0
    
        def _respuesta_recomendacion(self, pregunta):
            resultados = self.motor.buscar_vectorial(pregunta, top_k=3)
            if resultados:
                resp = "Te recomiendo estas noticias relacionadas:\n"
                for r in resultados:
                    resp += f"  - [{r['relevancia']:.2f}] {r['titulo'][:50]}...\n"
                return resp, 'recomendacion', resultados[0]['relevancia']
            return "No encontré noticias para recomendar sobre ese tema.", 'recomendacion', 0.0
    
        def _respuesta_busqueda(self, pregunta):
            resultados = self.motor.buscar_vectorial(pregunta, top_k=2)
            if resultados:
                mejor = resultados[0]
                resp = f"{mejor['titulo']}.\n{mejor['snippet']}"
                return resp, 'busqueda', mejor['relevancia']
            return "No encontré información relevante para tu pregunta.", 'busqueda', 0.0
    
        def conversar(self, pregunta):
            """Interfaz conversacional con historial."""
            respuesta, tipo, confianza = self.generar_respuesta(pregunta)
            self.historial_conversacion.append({
                'pregunta': pregunta,
                'tipo': tipo,
                'confianza': confianza
            })
            return respuesta, tipo, confianza
    
    
    qa_system = SistemaQA(noticias, motor)
    
    print("=== FASE 5.2: Sistema Question/Answering ===\n")
    preguntas_qa = [
        "¿Cuántas noticias tienes?",
        "¿Cuál es el sentimiento general de las noticias?",
        "¿Qué categorías de noticias hay?",
        "Dame un resumen de las noticias",
        "Recomiéndame algo sobre tecnología",
        "¿Qué dice la noticia sobre Python?",
    ]
    
    for pregunta in preguntas_qa:
        respuesta, tipo, confianza = qa_system.conversar(pregunta)
        print(f"  Pregunta: {pregunta}")
        print(f"  [{tipo}][{confianza:.2f}] {respuesta[:120]}")
        print()
    

Fase 6: Knowledge Graph y Web Semántica

Todo lo que el SIMANW ha procesado se almacena en un Knowledge Graph semántico. Esto permite consultas SPARQL avanzadas y conectar los datos con fuentes externas de datos abiertos.

  • 6.1 Construcción del Knowledge Graph
    from rdflib import Graph, Namespace, Literal, URIRef, RDF, RDFS, OWL, XSD
    from rdflib.namespace import DC, FOAF, DCTERMS
    
    class KnowledgeGraphSIMANW:
        """Knowledge Graph semántico del sistema SIMANW."""
    
        def __init__(self):
            self.graph = Graph()
            self.NS = Namespace("http://simanw.org/ontology/")
            self.DATA = Namespace("http://simanw.org/data/")
            self.graph.bind("simanw", self.NS)
            self.graph.bind("data", self.DATA)
            self.graph.bind("dc", DC)
            self.graph.bind("foaf", FOAF)
            self._definir_ontologia()
    
        def _definir_ontologia(self):
            """Define la ontología del SIMANW."""
            # Clases
            self.graph.add((self.NS.Noticia, RDF.type, OWL.Class))
            self.graph.add((self.NS.Autor, RDF.type, OWL.Class))
            self.graph.add((self.NS.Categoria, RDF.type, OWL.Class))
            self.graph.add((self.NS.Fuente, RDF.type, OWL.Class))
    
            # Propiedades de objeto
            self.graph.add((self.NS.tieneAutor, RDF.type, OWL.ObjectProperty))
            self.graph.add((self.NS.tieneAutor, RDFS.domain, self.NS.Noticia))
            self.graph.add((self.NS.tieneAutor, RDFS.range, self.NS.Autor))
            self.graph.add((self.NS.tieneCategoria, RDF.type, OWL.ObjectProperty))
            self.graph.add((self.NS.provieneDe, RDF.type, OWL.ObjectProperty))
            self.graph.add((self.NS.relacionadaCon, RDF.type, OWL.ObjectProperty))
    
            # Propiedades de datos
            self.graph.add((self.NS.sentimientoScore, RDF.type, OWL.DatatypeProperty))
            self.graph.add((self.NS.sentimientoEtiqueta, RDF.type, OWL.DatatypeProperty))
    
        def agregar_noticia(self, noticia, noticia_id):
            """Agrega una noticia procesada al knowledge graph."""
            uri = self.DATA[f"noticia_{noticia_id}"]
            self.graph.add((uri, RDF.type, self.NS.Noticia))
            self.graph.add((uri, DC.title, Literal(noticia['titulo'], lang="es")))
            self.graph.add((uri, DC.description, Literal(noticia['cuerpo'][:200], lang="es")))
            self.graph.add((uri, DC.date, Literal(noticia['fecha'], datatype=XSD.date)))
    
            # Autor
            autor_uri = self.DATA[f"autor_{noticia['autor'].replace(' ', '_')}"]
            self.graph.add((autor_uri, RDF.type, self.NS.Autor))
            self.graph.add((autor_uri, FOAF.name, Literal(noticia['autor'])))
            self.graph.add((uri, self.NS.tieneAutor, autor_uri))
    
            # Categoría
            cat = noticia.get('categoria_predicha', noticia.get('categoria_original', 'general'))
            cat_uri = self.DATA[f"categoria_{cat}"]
            self.graph.add((cat_uri, RDF.type, self.NS.Categoria))
            self.graph.add((cat_uri, RDFS.label, Literal(cat, lang="es")))
            self.graph.add((uri, self.NS.tieneCategoria, cat_uri))
    
            # Sentimiento
            if 'sentimiento' in noticia:
                sent = noticia['sentimiento']
                self.graph.add((uri, self.NS.sentimientoScore,
                               Literal(sent['compound'], datatype=XSD.float)))
                self.graph.add((uri, self.NS.sentimientoEtiqueta,
                               Literal(sent['etiqueta'])))
    
            # URL fuente
            if 'url' in noticia:
                self.graph.add((uri, self.NS.urlOriginal, Literal(noticia['url'], datatype=XSD.anyURI)))
    
        def consultar(self, sparql_query):
            """Ejecuta una consulta SPARQL."""
            return list(self.graph.query(sparql_query))
    
        def total_triples(self):
            return len(self.graph)
    
        def serializar(self, formato='turtle'):
            return self.graph.serialize(format=formato)
    
    
    # Construir el Knowledge Graph con las noticias procesadas
    kg = KnowledgeGraphSIMANW()
    for i, noticia in enumerate(noticias):
        kg.agregar_noticia(noticia, i+1)
    
    print("=== FASE 6.1: Knowledge Graph ===\n")
    print(f"Knowledge Graph construido:")
    print(f"  Total de triples: {kg.total_triples()}")
    print(f"  Noticias almacenadas: {len(noticias)}")
    
    print(f"\nOntología (fragmento en Turtle):")
    turtle = kg.serializar('turtle')
    lineas = [l for l in turtle.split('\n') if l.strip()][:25]
    for l in lineas:
        print(f"  {l}")
    
  • 6.2 Consultas SPARQL sobre el Knowledge Graph
    print("=== FASE 6.2: Consultas SPARQL ===\n")
    
    # Consulta 1: Todas las noticias con autor y categoría
    query1 = """
    PREFIX simanw: <http://simanw.org/ontology/>
    PREFIX data: <http://simanw.org/data/>
    PREFIX dc: <http://purl.org/dc/elements/1.1/>
    PREFIX foaf: <http://xmlns.com/foaf/0.1/>
    PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
    
    SELECT ?titulo ?autor ?categoria ?fecha
    WHERE {
        ?noticia a simanw:Noticia ;
                 dc:title ?titulo ;
                 dc:date ?fecha ;
                 simanw:tieneAutor ?autorURI ;
                 simanw:tieneCategoria ?catURI .
        ?autorURI foaf:name ?autor .
        ?catURI rdfs:label ?categoria .
    }
    ORDER BY DESC(?fecha)
    """
    print("Consulta 1: Noticias con metadatos")
    print("─" * 60)
    for row in kg.consultar(query1):
        print(f"  [{row.fecha}] [{row.categoria}] {str(row.titulo)[:45]}... - {row.autor}")
    
    # Consulta 2: Noticias con sentimiento negativo
    query2 = """
    PREFIX simanw: <http://simanw.org/ontology/>
    PREFIX dc: <http://purl.org/dc/elements/1.1/>
    PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
    
    SELECT ?titulo ?score ?etiqueta
    WHERE {
        ?noticia a simanw:Noticia ;
                 dc:title ?titulo ;
                 simanw:sentimientoScore ?score ;
                 simanw:sentimientoEtiqueta ?etiqueta .
        FILTER(?score < -0.05)
    }
    ORDER BY ?score
    """
    print("\n\nConsulta 2: Noticias con sentimiento negativo")
    print("─" * 60)
    for row in kg.consultar(query2):
        print(f"  [{float(row.score):+.3f}] {str(row.titulo)[:55]}")
    
    # Consulta 3: Conteo por categoría
    query3 = """
    PREFIX simanw: <http://simanw.org/ontology/>
    PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
    
    SELECT ?categoria (COUNT(?noticia) as ?total)
    WHERE {
        ?noticia a simanw:Noticia ;
                 simanw:tieneCategoria ?catURI .
        ?catURI rdfs:label ?categoria .
    }
    GROUP BY ?categoria
    ORDER BY DESC(?total)
    """
    print("\n\nConsulta 3: Distribución por categoría")
    print("─" * 60)
    for row in kg.consultar(query3):
        print(f"  {row.categoria}: {row.total} noticia(s)")
    
    # Consulta 4: Autores y sus noticias
    query4 = """
    PREFIX simanw: <http://simanw.org/ontology/>
    PREFIX dc: <http://purl.org/dc/elements/1.1/>
    PREFIX foaf: <http://xmlns.com/foaf/0.1/>
    
    SELECT ?autor (COUNT(?n) as ?publicaciones) (GROUP_CONCAT(?titulo; separator="; ") as ?titulos)
    WHERE {
        ?n a simanw:Noticia ;
           dc:title ?titulo ;
           simanw:tieneAutor ?a .
        ?a foaf:name ?autor .
    }
    GROUP BY ?autor
    """
    print("\n\nConsulta 4: Productividad por autor")
    print("─" * 60)
    for row in kg.consultar(query4):
        print(f"  {row.autor}: {row.publicaciones} publicación(es)")
    
  • 6.3 Datos abiertos y conexión con fuentes externas

    El SIMANW se conecta con datos abiertos gubernamentales, enriqueciendo la información con datos públicos en formato semántico.

    import json
    
    class ConectorDatosAbiertos:
        """Conecta el SIMANW con fuentes de datos abiertos."""
    
        def __init__(self, knowledge_graph):
            self.kg = knowledge_graph
            self.DCAT = Namespace("http://www.w3.org/ns/dcat#")
            self.GOB = Namespace("http://datos.gob.mx/")
            self.kg.graph.bind("dcat", self.DCAT)
            self.kg.graph.bind("gob", self.GOB)
    
        def cargar_dataset_gobierno(self, nombre, datos, publicador, tema):
            """Integra un dataset de datos abiertos al knowledge graph."""
            ds_uri = self.GOB[f"dataset/{nombre.replace(' ', '_')}"]
            self.kg.graph.add((ds_uri, RDF.type, self.DCAT.Dataset))
            self.kg.graph.add((ds_uri, DC.title, Literal(nombre, lang="es")))
            self.kg.graph.add((ds_uri, DC.publisher, Literal(publicador)))
            self.kg.graph.add((ds_uri, self.GOB.tema, Literal(tema)))
    
            for i, registro in enumerate(datos):
                reg_uri = self.GOB[f"registro/{nombre.replace(' ', '_')}_{i}"]
                self.kg.graph.add((ds_uri, self.GOB.tieneRegistro, reg_uri))
                for campo, valor in registro.items():
                    if isinstance(valor, (int, float)):
                        self.kg.graph.add((reg_uri, self.GOB[campo],
                                         Literal(valor, datatype=XSD.float)))
                    else:
                        self.kg.graph.add((reg_uri, self.GOB[campo], Literal(valor, lang="es")))
    
        def consultar_datos(self, tema=None):
            """Consulta los datos abiertos cargados."""
            filtro = f'FILTER(?tema = "{tema}")' if tema else ''
            query = f"""
            PREFIX dcat: <http://www.w3.org/ns/dcat#>
            PREFIX dc: <http://purl.org/dc/elements/1.1/>
            PREFIX gob: <http://datos.gob.mx/>
    
            SELECT ?titulo ?publicador ?tema
            WHERE {{
                ?ds a dcat:Dataset ;
                    dc:title ?titulo ;
                    dc:publisher ?publicador ;
                    gob:tema ?tema .
                {filtro}
            }}
            """
            return list(self.kg.graph.query(query))
    
        def enlazar_noticias_con_datos(self):
            """Enlaza noticias con datasets relacionados semánticamente."""
            query = """
            PREFIX simanw: <http://simanw.org/ontology/>
            PREFIX dc: <http://purl.org/dc/elements/1.1/>
            PREFIX dcat: <http://www.w3.org/ns/dcat#>
            PREFIX gob: <http://datos.gob.mx/>
            PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
    
            SELECT ?noticia_titulo ?dataset_titulo ?tema
            WHERE {
                ?noticia a simanw:Noticia ;
                         dc:title ?noticia_titulo ;
                         simanw:tieneCategoria ?cat .
                ?cat rdfs:label ?cat_label .
                ?ds a dcat:Dataset ;
                    dc:title ?dataset_titulo ;
                    gob:tema ?tema .
                FILTER(CONTAINS(LCASE(?tema), LCASE(?cat_label)))
            }
            """
            return list(self.kg.graph.query(query))
    
    
    # Integrar datos abiertos
    conector = ConectorDatosAbiertos(kg)
    
    conector.cargar_dataset_gobierno(
        "Presupuesto TIC Federal 2026",
        [
            {"dependencia": "SEP", "monto_mdp": 12500, "concepto": "Infraestructura digital educativa"},
            {"dependencia": "SALUD", "monto_mdp": 8900, "concepto": "Expediente clínico electrónico"},
            {"dependencia": "SAT", "monto_mdp": 15600, "concepto": "Plataformas de recaudación"},
        ],
        publicador="Secretaría de Hacienda",
        tema="tecnologia"
    )
    
    conector.cargar_dataset_gobierno(
        "Indicadores Económicos Mayo 2026",
        [
            {"indicador": "Inflación anual", "valor": 4.2, "unidad": "porcentaje"},
            {"indicador": "Tipo de cambio", "valor": 18.5, "unidad": "pesos por dólar"},
            {"indicador": "Tasa de desempleo", "valor": 3.1, "unidad": "porcentaje"},
        ],
        publicador="INEGI / Banco de México",
        tema="economia"
    )
    
    conector.cargar_dataset_gobierno(
        "Emisiones CO2 por Sector 2025",
        [
            {"sector": "Energía", "emisiones_mtco2": 450, "variacion": -2.1},
            {"sector": "Transporte", "emisiones_mtco2": 180, "variacion": 1.5},
            {"sector": "Industria", "emisiones_mtco2": 120, "variacion": -3.8},
        ],
        publicador="SEMARNAT",
        tema="ciencia"
    )
    
    print("=== FASE 6.3: Datos Abiertos Integrados ===\n")
    print(f"Triples totales en KG (con datos abiertos): {kg.total_triples()}")
    
    print("\nDatasets de datos abiertos cargados:")
    for row in conector.consultar_datos():
        print(f"  [{row.tema}] {row.titulo} - {row.publicador}")
    
    print("\nEnlaces noticias ↔ datos abiertos:")
    enlaces = conector.enlazar_noticias_con_datos()
    if enlaces:
        for row in enlaces:
            print(f"  Noticia: {str(row.noticia_titulo)[:40]}...")
            print(f"  Dataset: {row.dataset_titulo}")
            print()
    else:
        print("  (Los enlaces se generan cuando las categorías coinciden con los temas)")
    
  • 6.4 Consulta a endpoints SPARQL externos
    from SPARQLWrapper import SPARQLWrapper, JSON
    
    print("=== FASE 6.4: Endpoints SPARQL Externos ===\n")
    
    # Ejemplo de consulta a Wikidata (requiere internet)
    query_wikidata = """
    SELECT ?item ?itemLabel ?description WHERE {
      ?item wdt:P31 wd:Q7397;      # instancia de: software
            wdt:P277 wd:Q28865;    # lenguaje de programación: Python
            wdt:P366 wd:Q11660.    # uso: inteligencia artificial
      SERVICE wikibase:label { bd:serviceParam wikibase:language "es". }
    }
    LIMIT 10
    """
    
    print("Consulta para Wikidata (software de IA en Python):")
    print(query_wikidata)
    
    # Ejemplo de consulta a DBpedia
    query_dbpedia = """
    PREFIX dbo: <http://dbpedia.org/ontology/>
    PREFIX dbr: <http://dbpedia.org/resource/>
    PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
    
    SELECT ?nombre ?descripcion WHERE {
      ?s dbo:genre dbr:Natural_language_processing ;
         rdfs:label ?nombre ;
         rdfs:comment ?descripcion .
      FILTER(LANG(?nombre) = 'es')
      FILTER(LANG(?descripcion) = 'es')
    }
    LIMIT 5
    """
    
    print("\nConsulta para DBpedia (herramientas NLP):")
    print(query_dbpedia)
    
    print("""
    Endpoints SPARQL disponibles para el SIMANW:
      - Wikidata:  https://query.wikidata.org/sparql
      - DBpedia:   http://dbpedia.org/sparql
      - datos.gob: Portal de datos abiertos de México
    
    # Código para ejecutar (requiere internet):
    # sparql = SPARQLWrapper("https://query.wikidata.org/sparql")
    # sparql.setQuery(query_wikidata)
    # sparql.setReturnFormat(JSON)
    # results = sparql.query().convert()
    """)
    

Fase 7: Reportes Automáticos y Entrega Final

El sistema genera reportes completos automáticamente a partir de toda la información procesada y almacenada.

  • 7.1 Generador de reportes
    from datetime import datetime
    from collections import Counter
    
    class GeneradorReportes:
        """Genera reportes automáticos del SIMANW."""
    
        def __init__(self, noticias, knowledge_graph):
            self.noticias = noticias
            self.kg = knowledge_graph
    
        def reporte_completo(self):
            """Genera el reporte integrador completo."""
            lineas = []
            lineas.append("=" * 70)
            lineas.append("  REPORTE AUTOMÁTICO - SISTEMA SIMANW")
            lineas.append(f"  Generado: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
            lineas.append("=" * 70)
    
            # Sección 1: Resumen ejecutivo
            lineas.append("\n1. RESUMEN EJECUTIVO")
            lineas.append("─" * 40)
            lineas.append(f"   Noticias procesadas: {len(self.noticias)}")
            lineas.append(f"   Triples en Knowledge Graph: {self.kg.total_triples()}")
    
            cats = Counter(n.get('categoria_predicha', n['categoria_original']) for n in self.noticias)
            lineas.append(f"   Categorías detectadas: {len(cats)}")
    
            sents = [n['sentimiento']['compound'] for n in self.noticias if 'sentimiento' in n]
            promedio = sum(sents) / len(sents) if sents else 0
            lineas.append(f"   Sentimiento promedio: {promedio:+.3f}")
            lineas.append(f"   Tono general: {'POSITIVO' if promedio > 0.05 else 'NEGATIVO' if promedio < -0.05 else 'NEUTRAL'}")
    
            # Sección 2: Distribución por categoría
            lineas.append("\n2. DISTRIBUCIÓN POR CATEGORÍA")
            lineas.append("─" * 40)
            for cat, count in cats.most_common():
                barra = "█" * (count * 8)
                pct = 100 * count / len(self.noticias)
                lineas.append(f"   {cat:<12} {barra} {count} ({pct:.0f}%)")
    
            # Sección 3: Análisis de sentimiento
            lineas.append("\n3. ANÁLISIS DE SENTIMIENTO")
            lineas.append("─" * 40)
            sent_dist = Counter(n['sentimiento']['etiqueta'] for n in self.noticias if 'sentimiento' in n)
            for etiqueta, count in sent_dist.most_common():
                emoji = "+" if etiqueta == 'positivo' else "-" if etiqueta == 'negativo' else "~"
                lineas.append(f"   [{emoji}] {etiqueta}: {count} noticia(s)")
    
            lineas.append("\n   Detalle por noticia:")
            for n in sorted(self.noticias, key=lambda x: x.get('sentimiento', {}).get('compound', 0)):
                if 'sentimiento' in n:
                    s = n['sentimiento']
                    lineas.append(f"   [{s['compound']:+.3f}] {n['titulo'][:50]}")
    
            # Sección 4: Noticias procesadas
            lineas.append("\n4. CATÁLOGO DE NOTICIAS")
            lineas.append("─" * 40)
            for i, n in enumerate(self.noticias, 1):
                cat = n.get('categoria_predicha', n['categoria_original'])
                sent = n.get('sentimiento', {}).get('etiqueta', '?')
                lineas.append(f"   {i}. [{n['fecha']}] [{cat}] [{sent}]")
                lineas.append(f"      {n['titulo']}")
                lineas.append(f"      Autor: {n['autor']} | Fuente: {n['fuente']}")
                lineas.append("")
    
            # Sección 5: Capacidades del sistema
            lineas.append("\n5. CAPACIDADES DEMOSTRADAS")
            lineas.append("─" * 40)
            capacidades = [
                ("Rastreo Web", "Extracción automática con BeautifulSoup/Scrapy"),
                ("NLP", "Tokenización, stemming, stopwords, representación TF-IDF"),
                ("Clasificación", "Categorización automática con SVM/NB"),
                ("Sentimientos", "Análisis de polaridad con VADER"),
                ("Recomendación", "Sugerencias basadas en similitud coseno"),
                ("Publicidad", "Detección de temas en conversación"),
                ("Búsqueda", "Motor con índice invertido y ranking vectorial"),
                ("Evaluación IRS", "Precision, Recall, F1, MAP, P@K"),
                ("Chatbot", "Respuestas por similitud semántica"),
                ("Q&A", "Pregunta-respuesta con comprensión de intención"),
                ("Knowledge Graph", "Ontología OWL + triples RDF"),
                ("SPARQL", "Consultas semánticas sobre el grafo"),
                ("Datos Abiertos", "Integración con datasets gubernamentales"),
                ("Reportes", "Generación automática de resúmenes"),
            ]
            for nombre, desc in capacidades:
                lineas.append(f"   [OK] {nombre:<16}{desc}")
    
            lineas.append("\n" + "=" * 70)
            lineas.append("  FIN DEL REPORTE")
            lineas.append("=" * 70)
    
            return "\n".join(lineas)
    
    
    reportero = GeneradorReportes(noticias, kg)
    print(reportero.reporte_completo())
    
  • 7.2 Integración final: Pipeline completo
    print("""
    ╔══════════════════════════════════════════════════════════════════════╗
    ║           SISTEMA SIMANW - PIPELINE COMPLETO                        ║
    ╠══════════════════════════════════════════════════════════════════════╣
    ║                                                                      ║
    ║  Fase 1: RASTREO WEB                                                ║
    ║    → HTML parsing (BeautifulSoup)                                   ║
    ║    → Control de alcance (dominio/directorio)                        ║
    ║    → Spider (Scrapy en producción)                                  ║
    ║    → Almacenamiento (JSON/CSV)                                      ║
    ║          │                                                           ║
    ║          ▼                                                           ║
    ║  Fase 2: PROCESAMIENTO NLP                                          ║
    ║    → Tokenización + Limpieza                                        ║
    ║    → Stopwords + Stemming                                           ║
    ║    → Vectorización TF-IDF                                           ║
    ║    → Cálculo de similitudes (coseno)                                ║
    ║          │                                                           ║
    ║          ▼                                                           ║
    ║  Fase 3: ANÁLISIS AUTOMÁTICO                                        ║
    ║    → Clasificación (SVM/NB)                                         ║
    ║    → Sentimiento (VADER)                                            ║
    ║    → Recomendación (similitud contenido)                            ║
    ║    → Detección temas + publicidad                                   ║
    ║          │                                                           ║
    ║          ▼                                                           ║
    ║  Fase 4: MOTOR DE BÚSQUEDA                                         ║
    ║    → Índice invertido                                               ║
    ║    → Búsqueda booleana y vectorial                                  ║
    ║    → Evaluación (P, R, F1, MAP)                                     ║
    ║    → Búsqueda en lenguaje natural                                   ║
    ║          │                                                           ║
    ║          ▼                                                           ║
    ║  Fase 5: CHATBOT + Q&A                                              ║
    ║    → Chatbot por similitud                                          ║
    ║    → Sistema pregunta-respuesta                                     ║
    ║    → Comprensión de intención                                       ║
    ║          │                                                           ║
    ║          ▼                                                           ║
    ║  Fase 6: WEB SEMÁNTICA                                              ║
    ║    → Ontología OWL del dominio                                      ║
    ║    → Knowledge Graph (RDF triples)                                  ║
    ║    → Consultas SPARQL                                               ║
    ║    → Datos abiertos + Linked Data                                   ║
    ║          │                                                           ║
    ║          ▼                                                           ║
    ║  Fase 7: REPORTES + ENTREGA                                        ║
    ║    → Generación automática de reportes                              ║
    ║    → Estadísticas y visualización                                   ║
    ║                                                                      ║
    ╚══════════════════════════════════════════════════════════════════════╝
    """)
    

Actividades Complementarias

Las siguientes actividades deben integrarse al proyecto SIMANW para reforzar y extender cada fase. Cada actividad tiene complejidad media: requiere investigación y programación adicional, pero se apoya en lo ya construido.

  • AC-1: Rastreo de un sitio real con paginación (Fase 1)

    El alumno debe adaptar el extractor para rastrear un portal de noticias real (por ejemplo, un periódico local o un blog tecnológico) que tenga paginación. Debe:

    • Respetar el archivo robots.txt del sitio
    • Implementar un delay entre peticiones (mínimo 3 segundos)
    • Navegar automáticamente a las siguientes páginas (paginación)
    • Extraer al menos 20 noticias reales
    • Almacenar los resultados en un archivo JSON
    import time
    import json
    import requests
    from bs4 import BeautifulSoup
    from urllib.parse import urljoin
    
    class RastreadorPaginado:
        """
        AC-1: Rastreador que navega paginación de un sitio real.
        El alumno debe adaptarlo a un portal específico.
        """
    
        def __init__(self, url_base, selector_articulos, selector_siguiente,
                     delay=3, max_paginas=5):
            self.url_base = url_base
            self.selector_articulos = selector_articulos
            self.selector_siguiente = selector_siguiente
            self.delay = delay
            self.max_paginas = max_paginas
            self.resultados = []
    
        def extraer_pagina(self, html, url_actual):
            """El alumno implementa la extracción según el sitio elegido."""
            soup = BeautifulSoup(html, 'html.parser')
            articulos = soup.select(self.selector_articulos)
            noticias_pagina = []
            for art in articulos:
                titulo_elem = art.find(['h2', 'h3', 'h1'])
                parrafo_elem = art.find('p')
                enlace_elem = art.find('a')
                if titulo_elem:
                    noticias_pagina.append({
                        'titulo': titulo_elem.get_text(strip=True),
                        'resumen': parrafo_elem.get_text(strip=True) if parrafo_elem else '',
                        'url': urljoin(url_actual, enlace_elem['href']) if enlace_elem and enlace_elem.get('href') else '',
                    })
            return noticias_pagina
    
        def obtener_siguiente_pagina(self, html, url_actual):
            """Encuentra el enlace a la siguiente página."""
            soup = BeautifulSoup(html, 'html.parser')
            siguiente = soup.select_one(self.selector_siguiente)
            if siguiente and siguiente.get('href'):
                return urljoin(url_actual, siguiente['href'])
            return None
    
        def rastrear(self):
            """Ejecuta el rastreo completo con paginación."""
            url_actual = self.url_base
            paginas_visitadas = 0
    
            while url_actual and paginas_visitadas < self.max_paginas:
                print(f"  Rastreando página {paginas_visitadas + 1}: {url_actual[:60]}...")
    
                # En producción: response = requests.get(url_actual)
                # Aquí simulamos para no depender de conexión
                html_simulado = f"""
                <div class="articulos">
                  <article><h2>Noticia {paginas_visitadas*3 + 1} de ejemplo</h2>
                  <p>Contenido de la noticia extraída del sitio real.</p>
                  <a href="/noticia/{paginas_visitadas*3 + 1}">Leer</a></article>
                  <article><h2>Noticia {paginas_visitadas*3 + 2} de ejemplo</h2>
                  <p>Otra noticia con información relevante.</p>
                  <a href="/noticia/{paginas_visitadas*3 + 2}">Leer</a></article>
                  <article><h2>Noticia {paginas_visitadas*3 + 3} de ejemplo</h2>
                  <p>Tercera noticia de esta página.</p>
                  <a href="/noticia/{paginas_visitadas*3 + 3}">Leer</a></article>
                </div>
                <a class="next-page" href="/noticias?page={paginas_visitadas+2}">Siguiente</a>
                """
    
                noticias = self.extraer_pagina(html_simulado, url_actual)
                self.resultados.extend(noticias)
    
                url_siguiente = self.obtener_siguiente_pagina(html_simulado, url_actual)
                paginas_visitadas += 1
    
                if paginas_visitadas < self.max_paginas and url_siguiente:
                    url_actual = url_siguiente
                    time.sleep(0.1)  # En producción: time.sleep(self.delay)
                else:
                    break
    
            return self.resultados
    
        def guardar_json(self, archivo):
            with open(archivo, 'w', encoding='utf-8') as f:
                json.dump(self.resultados, f, ensure_ascii=False, indent=2)
            return len(self.resultados)
    
    
    # Demostración
    rastreador_paginado = RastreadorPaginado(
        url_base="https://ejemplo-noticias.com/ultimas",
        selector_articulos="article",
        selector_siguiente="a.next-page",
        delay=3,
        max_paginas=4
    )
    
    print("=== AC-1: Rastreo con Paginación ===\n")
    resultados = rastreador_paginado.rastrear()
    print(f"\nTotal noticias extraídas: {len(resultados)}")
    print(f"Primeras 5:")
    for r in resultados[:5]:
        print(f"  - {r['titulo']}")
    
  • AC-2: Nube de palabras y estadísticas de un discurso (Fase 2)

    El alumno debe tomar un texto largo (discurso político, artículo científico, o reseña extensa) y generar:

    • Frecuencia de bigramas y trigramas (no solo unigramas)
    • Análisis de riqueza léxica por secciones del texto
    • Identificación de entidades nombradas (personas, lugares, organizaciones)
    • Resumen estadístico comparativo si se tienen múltiples textos
    from nltk.tokenize import word_tokenize, sent_tokenize
    from nltk.corpus import stopwords
    from nltk import bigrams, trigrams
    from collections import Counter
    import re
    
    class AnalisisDiscurso:
        """
        AC-2: Análisis estadístico profundo de textos.
        El alumno lo aplica a textos reales (discursos, artículos, etc.)
        """
    
        def __init__(self, idioma='spanish'):
            self.stop_words = set(stopwords.words(idioma))
            self.idioma = idioma
    
        def analizar(self, texto, titulo="Documento"):
            """Análisis completo de un texto."""
            oraciones = sent_tokenize(texto, language=self.idioma)
            tokens = word_tokenize(texto.lower(), language=self.idioma)
            tokens_alfa = [t for t in tokens if t.isalpha() and len(t) > 2]
            tokens_filtrados = [t for t in tokens_alfa if t not in self.stop_words]
    
            # N-gramas
            bigs = list(bigrams(tokens_filtrados))
            trigs = list(trigrams(tokens_filtrados))
    
            # Riqueza léxica por secciones (dividir en cuartos)
            cuarto = len(tokens_filtrados) // 4
            riqueza_secciones = []
            for i in range(4):
                seccion = tokens_filtrados[i*cuarto:(i+1)*cuarto]
                if seccion:
                    rl = len(set(seccion)) / len(seccion)
                    riqueza_secciones.append(rl)
    
            # Entidades (heurística simple: palabras que inician con mayúscula)
            tokens_original = word_tokenize(texto, language=self.idioma)
            posibles_entidades = [t for t in tokens_original
                                if t[0].isupper() and t.isalpha() and len(t) > 2
                                and t.lower() not in self.stop_words]
    
            return {
                'titulo': titulo,
                'oraciones': len(oraciones),
                'palabras_totales': len(tokens_alfa),
                'vocabulario_unico': len(set(tokens_filtrados)),
                'riqueza_lexica_global': len(set(tokens_filtrados)) / max(len(tokens_filtrados), 1),
                'riqueza_por_seccion': riqueza_secciones,
                'promedio_palabras_oracion': len(tokens_alfa) / max(len(oraciones), 1),
                'top_unigramas': Counter(tokens_filtrados).most_common(10),
                'top_bigramas': Counter(bigs).most_common(7),
                'top_trigramas': Counter(trigs).most_common(5),
                'posibles_entidades': Counter(posibles_entidades).most_common(8),
            }
    
        def comparar_textos(self, analisis_lista):
            """Compara estadísticas entre múltiples textos."""
            comparativa = []
            for a in analisis_lista:
                comparativa.append({
                    'titulo': a['titulo'],
                    'palabras': a['palabras_totales'],
                    'vocabulario': a['vocabulario_unico'],
                    'riqueza': a['riqueza_lexica_global'],
                    'promedio_oracion': a['promedio_palabras_oracion']
                })
            return comparativa
    
    
    analizador_disc = AnalisisDiscurso()
    
    texto_discurso = """La educación es la herramienta más poderosa para transformar
    una sociedad. En México, la inversión en educación debe ser prioritaria para
    garantizar el desarrollo económico y social. Los jóvenes mexicanos merecen
    oportunidades de calidad en todos los niveles educativos. Las universidades
    tecnológicas y los institutos de investigación son pilares fundamentales para
    la innovación. La ciencia y la tecnología son motores del progreso nacional.
    El Instituto Tecnológico de Morelia ha formado generaciones de ingenieros que
    contribuyen al desarrollo del país. La inteligencia artificial y la programación
    son competencias esenciales para el futuro laboral. México necesita más
    profesionales en ciencias computacionales y recuperación de información."""
    
    texto_cientifico = """El procesamiento de lenguaje natural permite a las computadoras
    comprender y generar texto humano. Los modelos de aprendizaje profundo como BERT
    y GPT han revolucionado este campo. La representación vectorial de documentos
    mediante TF-IDF sigue siendo fundamental para sistemas de recuperación de información.
    Los algoritmos de clasificación como Naive Bayes y SVM logran alta precisión en
    categorización de texto. El análisis de sentimientos combina técnicas léxicas con
    aprendizaje automático para determinar la polaridad emocional de un texto."""
    
    print("=== AC-2: Análisis Estadístico de Discursos ===\n")
    
    analisis1 = analizador_disc.analizar(texto_discurso, "Discurso Educativo")
    analisis2 = analizador_disc.analizar(texto_cientifico, "Texto Científico")
    
    for analisis in [analisis1, analisis2]:
        print(f"--- {analisis['titulo']} ---")
        print(f"  Oraciones: {analisis['oraciones']}")
        print(f"  Palabras: {analisis['palabras_totales']}")
        print(f"  Vocabulario: {analisis['vocabulario_unico']}")
        print(f"  Riqueza léxica: {analisis['riqueza_lexica_global']:.3f}")
        print(f"  Prom. palabras/oración: {analisis['promedio_palabras_oracion']:.1f}")
        print(f"  Riqueza por sección: {[f'{r:.3f}' for r in analisis['riqueza_por_seccion']]}")
        print(f"  Top bigramas: {analisis['top_bigramas'][:4]}")
        print(f"  Posibles entidades: {[e[0] for e in analisis['posibles_entidades'][:5]]}")
        print()
    
    print("--- Comparativa ---")
    comp = analizador_disc.comparar_textos([analisis1, analisis2])
    print(f"{'Texto':<20} {'Palabras':>9} {'Vocab':>7} {'Riqueza':>8} {'P/Oración':>10}")
    for c in comp:
        print(f"{c['titulo']:<20} {c['palabras']:>9} {c['vocabulario']:>7} {c['riqueza']:>8.3f} {c['promedio_oracion']:>10.1f}")
    
  • AC-3: Clasificador multimodelo con selección automática (Fase 3)

    El alumno entrena múltiples clasificadores y el sistema selecciona automáticamente el mejor según los datos, aplicando validación cruzada.

    from sklearn.naive_bayes import MultinomialNB
    from sklearn.svm import LinearSVC
    from sklearn.linear_model import LogisticRegression
    from sklearn.ensemble import RandomForestClassifier, VotingClassifier
    from sklearn.feature_extraction.text import TfidfVectorizer
    from sklearn.model_selection import cross_val_score, StratifiedKFold
    from sklearn.metrics import classification_report
    import numpy as np
    
    class SelectorModelo:
        """
        AC-3: Entrena múltiples modelos y selecciona el mejor automáticamente.
        El alumno debe agregar más datos de entrenamiento de sus noticias reales.
        """
    
        def __init__(self):
            self.modelos = {
                'Naive Bayes': MultinomialNB(alpha=0.1),
                'SVM Lineal': LinearSVC(max_iter=3000, C=1.0),
                'Logistic Regression': LogisticRegression(max_iter=1000, C=1.0),
                'Random Forest': RandomForestClassifier(n_estimators=100, random_state=42),
            }
            self.vectorizer = TfidfVectorizer(max_features=3000, ngram_range=(1, 2))
            self.mejor_modelo = None
            self.resultados = {}
    
        def evaluar_todos(self, textos, etiquetas, cv_folds=3):
            """Evalúa todos los modelos con validación cruzada."""
            X = self.vectorizer.fit_transform(textos)
            cv = StratifiedKFold(n_splits=cv_folds, shuffle=True, random_state=42)
    
            for nombre, modelo in self.modelos.items():
                try:
                    scores = cross_val_score(modelo, X, etiquetas, cv=cv, scoring='accuracy')
                    self.resultados[nombre] = {
                        'accuracy_mean': scores.mean(),
                        'accuracy_std': scores.std(),
                        'scores': scores.tolist()
                    }
                except Exception as e:
                    self.resultados[nombre] = {'error': str(e)}
    
            # Seleccionar el mejor
            validos = {k: v for k, v in self.resultados.items() if 'accuracy_mean' in v}
            if validos:
                mejor_nombre = max(validos, key=lambda k: validos[k]['accuracy_mean'])
                self.mejor_modelo = (mejor_nombre, self.modelos[mejor_nombre])
                # Entrenar el mejor con todos los datos
                self.mejor_modelo[1].fit(X, etiquetas)
    
            return self.resultados
    
        def predecir(self, textos):
            """Predice usando el mejor modelo seleccionado."""
            if not self.mejor_modelo:
                raise ValueError("Primero ejecuta evaluar_todos()")
            X = self.vectorizer.transform(textos)
            return self.mejor_modelo[1].predict(X)
    
        def reporte(self):
            """Genera reporte comparativo de modelos."""
            lineas = ["Modelo                  | Accuracy   | Std Dev"]
            lineas.append("-" * 50)
            for nombre, res in sorted(self.resultados.items(),
                                      key=lambda x: x[1].get('accuracy_mean', 0),
                                      reverse=True):
                if 'accuracy_mean' in res:
                    marca = " ★" if self.mejor_modelo and nombre == self.mejor_modelo[0] else ""
                    lineas.append(f"{nombre:<23} | {res['accuracy_mean']:.4f}     | {res['accuracy_std']:.4f}{marca}")
                else:
                    lineas.append(f"{nombre:<23} | ERROR      | {res.get('error', '')[:20]}")
            return "\n".join(lineas)
    
    
    # Datos expandidos para demostración
    textos_ac3 = [
        "inteligencia artificial deep learning redes neuronales transformers",
        "programación software desarrollo aplicaciones web python javascript",
        "startup tecnológica innovación digital plataforma cloud",
        "ciberseguridad hackers vulnerabilidad protección datos privacidad",
        "inflación tasas interés banco central política monetaria",
        "bolsa acciones mercado valores inversión rendimiento portafolio",
        "desempleo recesión económica crisis laboral empleo informal",
        "comercio exportaciones importaciones balanza aranceles tratado",
        "investigación científica laboratorio experimento publicación revista",
        "cambio climático emisiones carbono calentamiento temperatura global",
        "vacuna medicamento ensayo clínico pacientes tratamiento hospital",
        "espacio cohete satélite misión astronauta exploración lunar",
        "elecciones presidente candidato partido campaña votación democracia",
        "congreso legisladores reforma ley aprobación dictamen senado",
        "seguridad policía crimen organizado justicia tribunal sentencia",
        "gobierno programa social presupuesto política pública decreto",
    ]
    etiquetas_ac3 = [
        'tecnologia', 'tecnologia', 'tecnologia', 'tecnologia',
        'economia', 'economia', 'economia', 'economia',
        'ciencia', 'ciencia', 'ciencia', 'ciencia',
        'politica', 'politica', 'politica', 'politica',
    ]
    
    selector = SelectorModelo()
    resultados = selector.evaluar_todos(textos_ac3, etiquetas_ac3, cv_folds=3)
    
    print("=== AC-3: Selección Automática de Modelo ===\n")
    print(selector.reporte())
    print(f"\nModelo seleccionado: {selector.mejor_modelo[0]}")
    
    # Probar con nuevos textos
    nuevos = [
        "nueva aplicación de machine learning para detectar fraudes",
        "el presidente anunció reformas al sistema de justicia",
        "los mercados cerraron con pérdidas por tercer día consecutivo",
    ]
    predicciones = selector.predecir(nuevos)
    print(f"\nPredicciones con el mejor modelo:")
    for texto, pred in zip(nuevos, predicciones):
        print(f"  [{pred}] {texto[:50]}...")
    
  • AC-4: Análisis de hilos de discusión de red social (Fase 3-4)

    El alumno rastrea (o simula) un hilo de discusión de una red social, analiza la evolución del sentimiento, detecta los subtemas discutidos y genera un resumen automático del hilo.

    from collections import Counter, defaultdict
    from nltk.sentiment import SentimentIntensityAnalyzer
    from sklearn.feature_extraction.text import TfidfVectorizer
    from sklearn.cluster import KMeans
    import numpy as np
    import re
    
    class AnalizadorHiloDiscusion:
        """
        AC-4: Analiza un hilo completo de red social.
        El alumno debe aplicarlo a datos reales (Twitter/X, Reddit, foros).
        """
    
        def __init__(self):
            self.sia = SentimentIntensityAnalyzer()
            self.mensajes = []
    
        def cargar_hilo(self, mensajes):
            """Carga un hilo de discusión."""
            self.mensajes = mensajes
            for msg in self.mensajes:
                msg['sentimiento'] = self.sia.polarity_scores(msg['texto'])['compound']
    
        def evolucion_sentimiento(self, ventana=3):
            """Analiza cómo evoluciona el sentimiento a lo largo del hilo."""
            sentimientos = [m['sentimiento'] for m in self.mensajes]
            evolucion = []
            for i in range(len(sentimientos)):
                inicio = max(0, i - ventana + 1)
                promedio_ventana = sum(sentimientos[inicio:i+1]) / (i - inicio + 1)
                evolucion.append({
                    'posicion': i + 1,
                    'sentimiento_puntual': sentimientos[i],
                    'tendencia': promedio_ventana
                })
            return evolucion
    
        def detectar_subtemas(self, n_clusters=3):
            """Detecta subtemas en el hilo usando clustering."""
            textos = [m['texto'] for m in self.mensajes]
            vec = TfidfVectorizer(max_features=500, stop_words='english')
            X = vec.fit_transform(textos)
    
            n_clusters = min(n_clusters, len(textos))
            km = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
            clusters = km.fit_predict(X)
    
            subtemas = defaultdict(list)
            for i, cluster_id in enumerate(clusters):
                subtemas[cluster_id].append(i)
    
            # Extraer palabras clave de cada subtema
            terminos = vec.get_feature_names_out()
            subtemas_info = {}
            for cluster_id, indices in subtemas.items():
                centroide = km.cluster_centers_[cluster_id]
                top_idx = centroide.argsort()[-5:][::-1]
                keywords = [terminos[j] for j in top_idx]
                subtemas_info[cluster_id] = {
                    'keywords': keywords,
                    'n_mensajes': len(indices),
                    'mensajes_idx': indices
                }
    
            return subtemas_info
    
        def usuarios_mas_activos(self, top_n=5):
            """Identifica usuarios más participativos."""
            participacion = Counter(m['usuario'] for m in self.mensajes)
            return participacion.most_common(top_n)
    
        def resumen_hilo(self):
            """Genera resumen automático del hilo."""
            total = len(self.mensajes)
            sent_promedio = sum(m['sentimiento'] for m in self.mensajes) / total
            positivos = sum(1 for m in self.mensajes if m['sentimiento'] > 0.05)
            negativos = sum(1 for m in self.mensajes if m['sentimiento'] < -0.05)
    
            hashtags = Counter()
            for m in self.mensajes:
                tags = re.findall(r'#(\w+)', m['texto'])
                hashtags.update(tags)
    
            return {
                'total_mensajes': total,
                'participantes': len(set(m['usuario'] for m in self.mensajes)),
                'sentimiento_promedio': sent_promedio,
                'tono': 'positivo' if sent_promedio > 0.05 else 'negativo' if sent_promedio < -0.05 else 'mixto',
                'positivos_pct': 100 * positivos / total,
                'negativos_pct': 100 * negativos / total,
                'hashtags_top': hashtags.most_common(5),
                'usuarios_activos': self.usuarios_mas_activos(3),
            }
    
    
    # Simular un hilo de discusión sobre IA
    hilo_ia = [
        {"usuario": "@dev_laura", "texto": "Just tried the new AI coding assistant and it's amazing! #AI #coding",
         "timestamp": "10:00"},
        {"usuario": "@tech_mike", "texto": "I agree, the code suggestions are incredibly accurate #AI",
         "timestamp": "10:05"},
        {"usuario": "@skeptic_joe", "texto": "But what about job displacement? This AI thing worries me a lot",
         "timestamp": "10:08"},
        {"usuario": "@dev_laura", "texto": "Good point Joe, but I think it's a tool not a replacement #AItools",
         "timestamp": "10:12"},
        {"usuario": "@data_sara", "texto": "The real concern is bias in training data, we need better datasets",
         "timestamp": "10:15"},
        {"usuario": "@tech_mike", "texto": "True, but the progress is undeniable. Exciting times! #innovation",
         "timestamp": "10:20"},
        {"usuario": "@skeptic_joe", "texto": "I lost my freelance gig because of AI. This is terrible for workers",
         "timestamp": "10:25"},
        {"usuario": "@prof_chen", "texto": "Research shows AI creates more jobs than it destroys historically",
         "timestamp": "10:30"},
        {"usuario": "@data_sara", "texto": "We need regulation and ethical guidelines urgently #AIethics",
         "timestamp": "10:35"},
        {"usuario": "@dev_laura", "texto": "Totally agree with Sara. Responsible AI development is key #responsible",
         "timestamp": "10:40"},
        {"usuario": "@tech_mike", "texto": "Companies investing in AI training for employees is the best approach",
         "timestamp": "10:45"},
        {"usuario": "@prof_chen", "texto": "Great discussion everyone! The future needs both innovation and responsibility",
         "timestamp": "10:50"},
    ]
    
    analizador_hilo = AnalizadorHiloDiscusion()
    analizador_hilo.cargar_hilo(hilo_ia)
    
    print("=== AC-4: Análisis de Hilo de Discusión ===\n")
    
    # Resumen
    resumen = analizador_hilo.resumen_hilo()
    print("--- Resumen del Hilo ---")
    print(f"  Mensajes: {resumen['total_mensajes']}")
    print(f"  Participantes: {resumen['participantes']}")
    print(f"  Tono general: {resumen['tono']} ({resumen['sentimiento_promedio']:+.3f})")
    print(f"  Positivos: {resumen['positivos_pct']:.0f}% | Negativos: {resumen['negativos_pct']:.0f}%")
    print(f"  Hashtags: {resumen['hashtags_top']}")
    print(f"  Más activos: {resumen['usuarios_activos']}")
    
    # Evolución del sentimiento
    print("\n--- Evolución del Sentimiento ---")
    evolucion = analizador_hilo.evolucion_sentimiento(ventana=3)
    for e in evolucion:
        barra = "+" * int(max(0, e['tendencia'] * 10)) + "-" * int(max(0, -e['tendencia'] * 10))
        print(f"  Msg {e['posicion']:>2}: [{e['sentimiento_puntual']:+.2f}] tendencia: {e['tendencia']:+.3f} |{barra}")
    
    # Subtemas
    print("\n--- Subtemas Detectados ---")
    subtemas = analizador_hilo.detectar_subtemas(n_clusters=3)
    for cluster_id, info in subtemas.items():
        print(f"  Subtema {cluster_id+1} ({info['n_mensajes']} msgs): {info['keywords']}")
    
  • AC-5: Evaluación comparativa de modelos de búsqueda (Fase 4)

    El alumno implementa y compara formalmente el modelo booleano vs. el modelo vectorial usando las mismas consultas y métricas.

    from sklearn.feature_extraction.text import TfidfVectorizer
    from sklearn.metrics.pairwise import cosine_similarity
    import numpy as np
    
    class ComparadorModelos:
        """
        AC-5: Compara formalmente modelo booleano vs. vectorial.
        El alumno documenta qué modelo funciona mejor y por qué.
        """
    
        def __init__(self, documentos):
            self.documentos = documentos
            self.textos = [f"{d['titulo']} {d['cuerpo']}" for d in documentos]
            self.vectorizer = TfidfVectorizer(ngram_range=(1, 2))
            self.matriz = self.vectorizer.fit_transform(self.textos)
    
            # Índice invertido para modelo booleano
            self.indice = {}
            for i, texto in enumerate(self.textos):
                for palabra in texto.lower().split():
                    if palabra not in self.indice:
                        self.indice[palabra] = set()
                    self.indice[palabra].add(i)
    
        def busqueda_booleana(self, consulta):
            """Modelo booleano: AND de todos los términos."""
            terminos = consulta.lower().split()
            if not terminos:
                return []
            resultado = self.indice.get(terminos[0], set()).copy()
            for t in terminos[1:]:
                resultado &= self.indice.get(t, set())
            return sorted(resultado)
    
        def busqueda_vectorial(self, consulta, top_k=5):
            """Modelo vectorial: ranking por similitud coseno."""
            q_vec = self.vectorizer.transform([consulta])
            sims = cosine_similarity(q_vec, self.matriz)[0]
            indices = sims.argsort()[::-1][:top_k]
            return [(i, sims[i]) for i in indices if sims[i] > 0]
    
        def evaluar_ambos(self, consulta, relevantes):
            """Evalúa ambos modelos con la misma consulta y juicio de relevancia."""
            # Booleano
            bool_result = self.busqueda_booleana(consulta)
            bool_precision = len(set(bool_result) & set(relevantes)) / max(len(bool_result), 1)
            bool_recall = len(set(bool_result) & set(relevantes)) / max(len(relevantes), 1)
    
            # Vectorial
            vec_result = [idx for idx, _ in self.busqueda_vectorial(consulta, top_k=len(self.documentos))]
            vec_top_k = vec_result[:len(bool_result)] if bool_result else vec_result[:3]
            vec_precision = len(set(vec_top_k) & set(relevantes)) / max(len(vec_top_k), 1)
            vec_recall = len(set(vec_top_k) & set(relevantes)) / max(len(relevantes), 1)
    
            return {
                'consulta': consulta,
                'booleano': {
                    'recuperados': len(bool_result),
                    'precision': bool_precision,
                    'recall': bool_recall
                },
                'vectorial': {
                    'recuperados': len(vec_top_k),
                    'precision': vec_precision,
                    'recall': vec_recall
                }
            }
    
    
    comparador = ComparadorModelos(noticias)
    
    # Consultas de evaluación con juicios de relevancia manuales
    consultas_eval = [
        {"consulta": "inteligencia artificial", "relevantes": [0, 3]},
        {"consulta": "mercados volatilidad economía", "relevantes": [1]},
        {"consulta": "datos abiertos gobierno", "relevantes": [4]},
        {"consulta": "cambio climático científico", "relevantes": [2]},
    ]
    
    print("=== AC-5: Comparación Booleano vs. Vectorial ===\n")
    print(f"{'Consulta':<30} | {'Modelo':<10} | {'Recup':>5} | {'Prec':>6} | {'Recall':>6}")
    print("─" * 75)
    
    sum_bool_p, sum_vec_p = 0, 0
    sum_bool_r, sum_vec_r = 0, 0
    
    for ce in consultas_eval:
        resultado = comparador.evaluar_ambos(ce['consulta'], ce['relevantes'])
        b = resultado['booleano']
        v = resultado['vectorial']
        sum_bool_p += b['precision']
        sum_vec_p += v['precision']
        sum_bool_r += b['recall']
        sum_vec_r += v['recall']
        print(f"{ce['consulta']:<30} | {'Booleano':<10} | {b['recuperados']:>5} | {b['precision']:>6.3f} | {b['recall']:>6.3f}")
        print(f"{'':30} | {'Vectorial':<10} | {v['recuperados']:>5} | {v['precision']:>6.3f} | {v['recall']:>6.3f}")
        print()
    
    n = len(consultas_eval)
    print("─" * 75)
    print(f"{'PROMEDIO':<30} | {'Booleano':<10} | {'':>5} | {sum_bool_p/n:>6.3f} | {sum_bool_r/n:>6.3f}")
    print(f"{'':30} | {'Vectorial':<10} | {'':>5} | {sum_vec_p/n:>6.3f} | {sum_vec_r/n:>6.3f}")
    print(f"\nConclusion: El modelo {'vectorial' if sum_vec_p > sum_bool_p else 'booleano'} tiene mejor precision promedio.")
    
  • AC-6: Interfaz conversacional con memoria de contexto (Fase 5)

    El chatbot debe recordar la conversación anterior y usar ese contexto para mejorar sus respuestas (no responder de forma aislada).

    class ChatbotContextual:
        """
        AC-6: Chatbot con memoria de contexto.
        Las respuestas se enriquecen con el historial de la conversación.
        """
    
        def __init__(self, noticias, motor_busqueda):
            self.noticias = noticias
            self.motor = motor_busqueda
            self.historial = []
            self.contexto_temas = Counter()
            self.usuario_preferencias = {}
    
        def actualizar_contexto(self, pregunta, respuesta_tipo):
            """Actualiza el contexto basándose en la interacción."""
            self.historial.append({'pregunta': pregunta, 'tipo': respuesta_tipo})
            palabras_clave = pregunta.lower().split()
            for p in palabras_clave:
                if p in ['tecnología', 'ia', 'python', 'programación', 'software']:
                    self.contexto_temas['tecnologia'] += 1
                elif p in ['economía', 'mercado', 'finanzas', 'dinero']:
                    self.contexto_temas['economia'] += 1
                elif p in ['ciencia', 'clima', 'investigación']:
                    self.contexto_temas['ciencia'] += 1
    
        def responder(self, pregunta):
            """Genera respuesta considerando el contexto previo."""
            pregunta_lower = pregunta.lower()
    
            # Detectar referencias al contexto
            if any(ref in pregunta_lower for ref in ['eso', 'esa', 'anterior', 'más sobre', 'otra']):
                if self.historial:
                    ultimo = self.historial[-1]
                    pregunta_expandida = f"{ultimo['pregunta']} {pregunta}"
                    resultados = self.motor.buscar_vectorial(pregunta_expandida, top_k=2)
                    if resultados:
                        tipo = 'contextual'
                        resp = f"Basándome en nuestra conversación anterior, encontré: {resultados[0]['titulo']}"
                        self.actualizar_contexto(pregunta, tipo)
                        return resp, tipo, resultados[0]['relevancia']
    
            # Respuesta con sesgo hacia temas de interés del usuario
            resultados = self.motor.buscar_vectorial(pregunta, top_k=5)
            if resultados:
                # Priorizar resultados en categorías que le interesan al usuario
                if self.contexto_temas:
                    tema_favorito = self.contexto_temas.most_common(1)[0][0]
                    for r in resultados:
                        if r['categoria'] == tema_favorito:
                            tipo = 'personalizada'
                            resp = f"Como te interesa {tema_favorito}, mira esto: {r['titulo']}"
                            self.actualizar_contexto(pregunta, tipo)
                            return resp, tipo, r['relevancia']
    
                tipo = 'directa'
                resp = f"{resultados[0]['titulo']}. {resultados[0]['snippet']}"
                self.actualizar_contexto(pregunta, tipo)
                return resp, tipo, resultados[0]['relevancia']
    
            tipo = 'fallback'
            resp = "No encontré algo específico. ¿Puedes darme más detalles?"
            self.actualizar_contexto(pregunta, tipo)
            return resp, tipo, 0.0
    
        def estadisticas_sesion(self):
            return {
                'interacciones': len(self.historial),
                'temas_interes': dict(self.contexto_temas.most_common()),
                'tipos_respuesta': Counter(h['tipo'] for h in self.historial)
            }
    
    
    chatbot_ctx = ChatbotContextual(noticias, motor)
    
    print("=== AC-6: Chatbot con Memoria de Contexto ===\n")
    
    conversacion_sesion = [
        "¿Qué noticias hay de tecnología?",
        "Cuéntame más sobre eso",
        "¿Hay algo sobre inteligencia artificial?",
        "¿Y algo de economía?",
        "Dame otra noticia similar a la anterior",
    ]
    
    for pregunta in conversacion_sesion:
        respuesta, tipo, confianza = chatbot_ctx.responder(pregunta)
        print(f"  Usuario: {pregunta}")
        print(f"  Bot [{tipo}][{confianza:.2f}]: {respuesta[:80]}...")
        print()
    
    stats = chatbot_ctx.estadisticas_sesion()
    print(f"Estadísticas de sesión:")
    print(f"  Interacciones: {stats['interacciones']}")
    print(f"  Temas de interés: {stats['temas_interes']}")
    print(f"  Tipos de respuesta: {dict(stats['tipos_respuesta'])}")
    
  • AC-7: Enriquecimiento del Knowledge Graph con Wikidata (Fase 6)

    El alumno conecta entidades del Knowledge Graph local con Wikidata, enriqueciendo la información con datos externos enlazados.

    from rdflib import Graph, Namespace, Literal, URIRef, RDF, RDFS, OWL, XSD
    from rdflib.namespace import DC, FOAF, SKOS
    
    class EnriquecedorKG:
        """
        AC-7: Enriquece el KG local conectando con Wikidata/DBpedia.
        El alumno debe ejecutar consultas reales a endpoints SPARQL.
        """
    
        def __init__(self, knowledge_graph):
            self.kg = knowledge_graph
            self.WD = Namespace("http://www.wikidata.org/entity/")
            self.WDT = Namespace("http://www.wikidata.org/prop/direct/")
            self.kg.graph.bind("wd", self.WD)
            self.kg.graph.bind("wdt", self.WDT)
            self.enlaces_externos = []
    
        def enlazar_entidad(self, entidad_local, wikidata_id, etiqueta):
            """Enlaza una entidad local con su equivalente en Wikidata."""
            self.kg.graph.add((entidad_local, OWL.sameAs, self.WD[wikidata_id]))
            self.kg.graph.add((entidad_local, SKOS.exactMatch, self.WD[wikidata_id]))
            self.kg.graph.add((self.WD[wikidata_id], RDFS.label, Literal(etiqueta, lang="es")))
            self.enlaces_externos.append({
                'local': str(entidad_local),
                'wikidata': wikidata_id,
                'etiqueta': etiqueta
            })
    
        def agregar_datos_externos(self, entidad_local, propiedades):
            """Agrega propiedades obtenidas de fuentes externas."""
            for prop, valor in propiedades.items():
                self.kg.graph.add((entidad_local, self.WDT[prop], Literal(valor)))
    
        def consulta_enriquecimiento(self):
            """Query SPARQL para verificar enlaces externos."""
            query = """
            PREFIX owl: <http://www.w3.org/2002/07/owl#>
            PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
            PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
    
            SELECT ?local ?externo ?etiqueta
            WHERE {
                ?local owl:sameAs ?externo .
                ?externo rdfs:label ?etiqueta .
            }
            """
            return list(self.kg.graph.query(query))
    
        def generar_query_wikidata(self, tema):
            """Genera consultas SPARQL para Wikidata según el tema."""
            queries = {
                'tecnologia': """
    # Consulta: Herramientas de NLP en Wikidata
    SELECT ?item ?itemLabel ?description WHERE {
      ?item wdt:P31 wd:Q7397 .          # instancia de software
      ?item wdt:P366 wd:Q30642 .        # uso: NLP
      SERVICE wikibase:label { bd:serviceParam wikibase:language "es,en". }
    }
    LIMIT 10""",
                'ciencia': """
    # Consulta: Investigaciones sobre cambio climático
    SELECT ?item ?itemLabel ?date WHERE {
      ?item wdt:P31 wd:Q13442814 .      # instancia de artículo científico
      ?item wdt:P921 wd:Q7942 .         # tema: cambio climático
      ?item wdt:P577 ?date .
      FILTER(YEAR(?date) >= 2024)
      SERVICE wikibase:label { bd:serviceParam wikibase:language "es,en". }
    }
    LIMIT 10""",
            }
            return queries.get(tema, "# No hay consulta predefinida para este tema")
    
    
    # Enriquecer el KG del SIMANW
    enriquecedor = EnriquecedorKG(kg)
    
    # Enlazar categorías con Wikidata
    DATA = Namespace("http://simanw.org/data/")
    enriquecedor.enlazar_entidad(DATA["categoria_tecnologia"], "Q11016", "Tecnología de la información")
    enriquecedor.enlazar_entidad(DATA["categoria_economia"], "Q159810", "Economía")
    enriquecedor.enlazar_entidad(DATA["categoria_ciencia"], "Q336", "Ciencia")
    enriquecedor.enlazar_entidad(DATA["categoria_gobierno"], "Q7188", "Gobierno")
    
    # Simular datos obtenidos de Wikidata
    enriquecedor.agregar_datos_externos(DATA["categoria_tecnologia"], {
        "P279": "Sector económico terciario",
        "P910": "Categoría: Tecnología de la información"
    })
    
    print("=== AC-7: Enriquecimiento con Wikidata ===\n")
    print(f"Triples totales tras enriquecimiento: {kg.total_triples()}")
    print(f"Enlaces externos creados: {len(enriquecedor.enlaces_externos)}")
    
    print("\nEnlaces locales → Wikidata:")
    for enlace in enriquecedor.consulta_enriquecimiento():
        local_short = str(enlace.local).split('/')[-1]
        externo_short = str(enlace.externo).split('/')[-1]
        print(f"  {local_short}{externo_short} ({enlace.etiqueta})")
    
    print("\nQuery sugerida para Wikidata (tecnología):")
    print(enriquecedor.generar_query_wikidata('tecnologia'))
    

Expediente X: Búsqueda y Recuperación de Información

Expediente X: Búsqueda y Recuperación de la Información

Introduación para los Agentes

La búsqueda en colecciones de texto no siempre es un cuadro de texto y un botón. Detrás hay modelos de coincidencia, índices, ponderación de términos y, a veces, filtros que actúan como llaves antes de rankear resultados. Su misión de hoy consta de tres fases para localizar fragmentos clasificados dentro de un corpus interceptado (documentos de ejemplo que ustedes mismos construirán o cargarán desde variables en código).

Misión 1: La Telaraña de Tokens (Investigación e Índice Invertido Básico)

  • La Historia

    Hemos interceptado una serie de informes en texto plano (corpus_mision1). Los archivos no están cifrados, pero están desordenados conceptualmente: necesitamos saber en qué documentos aparece cada palabra para poder responder consultas tipo biblioteca clásica.

  • Tu Tarea
    1. Fase de Investigación: Antes de programar, investiga en internet:
      • ¿Qué es un índice invertido (inverted index)?
      • ¿Qué relación tiene con la tokenización y la lista de postings?
    2. Fase de Construcción: Construye en Python un índice invertido case-insensitive (minúsculas) a partir del diccionario de documentos siguiente (cada clave es un doc_id, cada valor es el texto). Debes:
      • Tokenizar (puedes usar solo split() y limpieza básica de puntuación simple).
      • Para la consulta booleana agente AND red, devolver la intersección de listas de postings (documentos que contienen ambas palabras).
      • Imprimir los doc_id resultantes ordenados alfabéticamente.
    corpus_mision1 = {
        "d1": "La red de agentes interceptó tráfico sospechoso en el nodo norte.",
        "d2": "El agente de campo reportó actividad normal en la red interna.",
        "d3": "Manual de procedimientos: la red no debe apagarse sin autorización.",
        "d4": "Mantenimiento programado del agente automático de respaldo.",
    }
    
    # ESCRIBE TU CÓDIGO AQUÍ:
    # 1) Tokenizar y construir índice invertido: término -> lista de doc_id
    # 2) Resolver la consulta booleana: "agente" AND "red"
    # 3) Imprimir doc_id coincidentes, ordenados
    
    

Misión 2: Operación Vector de Consulta (Recuperación por TF-IDF)

  • La Historia

    El enemigo dejó de organizar la información solo con palabras sueltas. Ahora los informes son casi sinónimos entre sí: la coincidencia literal (AND) falla. Interceptamos un corpus_mision2 más grande; debemos rankear documentos por similitud vectorial frente a una consulta en lenguaje natural.

  • Las Pistas
    • Puedes usar sklearn.feature_extraction.text.TfidfVectorizer y sklearn.metrics.pairwise.cosine_similarity.
    • Si no tienes instalada la librería: pip install scikit-learn.
  • Tu Tarea
    1. Representa los documentos y la consulta en el espacio TF-IDF.
    2. Calcula la similitud coseno entre la consulta y cada documento.
    3. Imprime un ranking (de mayor a menor similitud) con formato: doc_id: puntuación para los tres documentos más relevantes.
    from sklearn.feature_extraction.text import TfidfVectorizer
    from sklearn.metrics.pairwise import cosine_similarity
    
    corpus_mision2 = {
        "a1": "Protocolo de evacuación silenciosa en instalaciones subterráneas.",
        "a2": "Guía de rutas de escape y puntos de reunión sin alarmas audibles.",
        "a3": "Receta de cocina: pasta con tomate y albahaca para el personal.",
        "a4": "Mapa de salidas de emergencia y señalética fotoluminiscente.",
        "a5": "Informe meteorológico: probabilidad de lluvia en la región este.",
    }
    
    consulta_mision2 = "evacuación silenciosa rutas de escape emergencia"
    
    # ESCRIBE TU CÓDIGO AQUÍ:
    # 1) Vectorizar corpus + consulta (mismo vectorizador)
    # 2) cosine_similarity entre vector de consulta y matriz de documentos
    # 3) Imprimir top-3 doc_id con puntuación
    
    

Misión 3: El Cifrado de Colección (Reto Híbrido: Filtro + TF-IDF)

  • La Historia

    ¡El reto final! El enemigo duplicó vocabulario en toda la base: hay ruido semántico global. Los fragmentos realmente clasificados están marcados con una llave de metadatos: solo los documentos con nivel"SIGILO"= contienen, concatenados en el orden del ranking, pistas que terminan con el delimitador ###FIN### en el texto original (simulado aquí como un solo documento “jefe” que debes encontrar).

  • Las Pistas
    • Llave de colección: procesar únicamente los documentos cuyo campo nivel sea SIGILO.
    • Luego aplica TF-IDF solo sobre ese subconjunto y rankea frente a la consulta dada.
    • El mensaje recuperado es el texto completo del documento rankeado en primer lugar (debe contener ###FIN### al final en la plantilla de datos; si generas tus propios textos, respeta ese delimitador en el doc top-1).
  • Tu Tarea
    1. Filtra la lista de documentos por nivel = "SIGILO"=.
    2. Construye TF-IDF y rankea con similitud coseno frente a la consulta.
    3. Imprime el doc_id ganador y las primeras 120 caracteres de su texto (suficiente para ver contexto y el delimitador si está al final).
    from sklearn.feature_extraction.text import TfidfVectorizer
    from sklearn.metrics.pairwise import cosine_similarity
    
    documentos_mision3 = [
        {"doc_id": "x1", "nivel": "PUBLICO", "texto": "Boletín de prensa sobre obras en la avenida central y tráfico lento."},
        {"doc_id": "x2", "nivel": "SIGILO", "texto": "Rumor operativo: el contacto cambió frecuencia; verificar handoff nocturno. ###FIN###"},
        {"doc_id": "x3", "nivel": "PUBLICO", "texto": "Convocatoria a curso de primeros auxilios para voluntarios municipales."},
        {"doc_id": "x4", "nivel": "SIGILO", "texto": "Inventario de papelería y tóner para el almacén B del cuartel general."},
        {"doc_id": "x5", "nivel": "RESERVADO", "texto": "Lista de proveedores homologados para catering y cafetería interna."},
    ]
    
    consulta_mision3 = "contacto frecuencia operativo handoff nocturno"
    
    # ESCRIBE TU CÓDIGO AQUÍ:
    # 1) Filtrar nivel SIGILO
    # 2) TF-IDF + cosine similarity vs consulta
    # 3) Imprimir doc_id top-1 y recorte de 120 caracteres del texto
    
    

Entregable: Reporte de Misión (Formato Markdown)

Deben entregar un archivo reporte_mision_busqueda.md con la siguiente estructura. Deberán incluir sus códigos, salidas (texto) de cada misión en consola, y responder a las preguntas del Análisis del Analista.

#  Reporte de Misión: Búsqueda y Recuperación de Información
**Agente Especial:** [Tu Nombre/Matrícula]

---
## Misión 1, 2 y 3
[Incluir aquí los bloques de código Python y las salidas (rankings, doc_id, etc.)]

---
##  Análisis del Analista (Reflexiones Finales)

1. **Sobre la Investigación (Misión 1):** Explica con tus propias palabras qué es un índice invertido y por qué la intersección de postings implementa un AND booleano en este modelo.
> *[Tu respuesta]*

2. **Sobre TF-IDF (Misión 2):** ¿Por qué un término muy frecuente en *todos* los documentos suele discriminar peor que un término raro pero presente en pocos? Relacióna tu explicación con *idf*.
> *[Tu respuesta]*

3. **Sobre la Lógica de Recuperación (Misión 3):** Si aplicaras TF-IDF a *toda* la colección (sin filtrar por =SIGILO=), ¿cómo podría cambiar el documento top-1 y por qué el filtro actúa como una *llave de acceso* antes del ranking?
> *[Tu respuesta]*

Autor: Eduardo Alcaraz

Created: 2026-05-13 mié 08:24

Validate