Normalizar textos con Stemming o lemmatización

image

Con normalizar el texto — mediante stemming o lemmatización — convertimos docenas de formas flexionadas en una sola representación canónica; la ganancia es doble: índices de palabras más pequeños y comparaciones semánticas más finas. Python pone esta tarea al alcance de cualquiera gracias a librerías open-source (NLTK, spaCy) y a algoritmos clásicos como Porter (1980) que siguen vigentes hoy.

1. ¿Por qué importa la normalización?

Los sinónimos morfológicos (p. ej. “correr”, “corriendo”, “corrió”) diluyen las frecuencias reales de un término si se contabilizan por separado; al reducirlos a una raíz (stem) o a un lema se agrupan bajo un mismo concepto y se refuerza el análisis temático  .

Stemming elimina sufijos con reglas heurísticas (Porter, Snowball), es rápido pero puede cortar de más y generar raíces no existentes  .

La lemmatización, en cambio, recurre a diccionarios y/o modelos contextuales para devolver la forma base reconocida por el idioma, a costa de mayor complejidad  .

Para español contamos con el stemmer Snowball  , la lemmatización de spaCy (modelos es_core_news)  y pipelines neuronales como Stanza  .

2. Prompt #1 — Stemming ligero con NLTK

Prompt

Rol: Analista SEO técnico.

Objetivo: Desarrolla un **script Python de un único fichero** (por ejemplo `stemmer.py`) que:

1. **Entrada**  
   - Lea `pages.csv` (UTF-8) con, al menos, las columnas:
     - `url` (string)
     - `texto` (string)

2. **Procesamiento**  
   - Utilice **pandas** para cargar y procesar los datos de forma vectorizada.  
   - Aplique **NLTK SnowballStemmer** para español a cada texto, **omitiendo stop-words** en español:
     - Cargue la lista de stop-words desde NLTK (o un fichero local) y filtrelas antes de stemmar.
   - Optimice el bucle de stemming usando, por ejemplo, `apply` o comprensión de listas, y muestre una barra de progreso con **tqdm**.

3. **Salida intermedia**  
   - Calcule la frecuencia de cada raíz (stem) en todo el corpus.
   - Seleccione las **20 raíces más frecuentes**.

4. **Salida**  
   - Exporte un CSV `stems.csv` (UTF-8) con columnas:
     - `stem` (string)
     - `frecuencia` (int)
   - Asegúrese de que el fichero tenga cabecera y no incluya índices.

5. **Requisitos de calidad**  
   - **Único fichero** Python ejecutable desde CLI, con:
     - Shebang (`#!/usr/bin/env python3`)
     - Bloque `if __name__ == "__main__":`
     - Uso de **argparse** para parámetros de entrada y salida (`--input`, `--output`, `--top-n`, opcionalmente `--stopwords`).
     - **Tipado estático** (módulo `typing`).
     - **Docstrings** en todas las funciones y en el módulo principal (formato Google o NumPy).
     - **Manejo de errores** con `try/except` para:
       - Ficheros no encontrados.
       - Problemas de codificación.
       - Datos mal formados.
     - Registro de la ejecución con **logging** (niveles INFO/ERROR).
   - Sea robusto ante datos faltantes (`NaN`) y textos vacíos.
   - Sea razonablemente eficiente con datasets grandes (p. ej., streaming o chunks si excede cierta memoria).

6. **Entorno y dependencias**  
   - Liste al principio (en comentarios) las dependencias necesarias:
     ```python
     # Requisitos:
     #   pandas>=1.0
     #   nltk>=3.5
     #   tqdm>=4.0
     ```
   - Instrucciones breves para descargar recursos de NLTK (stop-words, snowball):
     ```python
     # Antes de ejecutar por primera vez:
     #   import nltk; nltk.download("stopwords")
     ```

El resultado debe ser un script **autocontenido**, listo para integrarse en pipelines SEO, con la mejor práctica de código Python para producción.```

Script

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Stemmer script for SEO corpus analysis.

Requisitos:
    pandas>=1.0
    nltk>=3.5
    tqdm>=4.0

Antes de ejecutar por primera vez:
    import nltk; nltk.download("stopwords")
"""

import argparse
import logging
import sys
from collections import Counter
from math import ceil
from typing import Iterator, List, Set

import pandas as pd
from nltk.corpus import stopwords
from nltk.stem.snowball import SnowballStemmer
from tqdm import tqdm


def parse_args() -> argparse.Namespace:
    """Parse command-line arguments."""
    parser = argparse.ArgumentParser(
        description="Genera los stems más frecuentes de un corpus CSV de páginas."
    )
    parser.add_argument(
        "--input", "-i", required=True,
        help="Fichero CSV de entrada (UTF-8) con columnas 'url' y 'texto'."
    )
    parser.add_argument(
        "--output", "-o", required=True,
        help="Fichero CSV de salida (UTF-8) con columnas 'stem' y 'frecuencia'."
    )
    parser.add_argument(
        "--top-n", "-n", type=int, default=20,
        help="Número de stems más frecuentes a exportar (por defecto: 20)."
    )
    parser.add_argument(
        "--stopwords", "-s",
        help="Fichero local de stop-words (una palabra por línea)."
    )
    parser.add_argument(
        "--chunksize", "-c", type=int, default=10000,
        help="Número de filas por chunk para procesado en streaming (por defecto: 10000)."
    )
    return parser.parse_args()


def load_stopwords(path: str = None) -> Set[str]:
    """
    Carga stop-words en español.

    Si se proporciona 'path', carga el fichero local (una palabra por línea).
    En caso contrario, usa NLTK.
    """
    if path:
        try:
            with open(path, encoding="utf-8") as f:
                return {line.strip().lower() for line in f if line.strip()}
        except FileNotFoundError:
            logging.error(f"No se encontró el fichero de stop-words: {path}")
            sys.exit(1)
        except UnicodeDecodeError:
            logging.error(f"Problema de codificación al leer: {path}")
            sys.exit(1)
    else:
        try:
            return set(stopwords.words("spanish"))
        except LookupError:
            logging.error("Stop-words de NLTK no disponibles. Ejecute 'nltk.download(\"stopwords\")'")
            sys.exit(1)


def iter_chunks(
    infile: str, chunksize: int
) -> Iterator[pd.DataFrame]:
    """
    Generator que devuelve DataFrames de tamaño 'chunksize' para streaming.
    """
    try:
        return pd.read_csv(
            infile,
            usecols=["url", "texto"],
            encoding="utf-8",
            chunksize=chunksize,
            dtype={"url": str, "texto": str},
            na_filter=False
        )
    except FileNotFoundError:
        logging.error(f"Fichero de entrada no encontrado: {infile}")
        sys.exit(1)
    except UnicodeDecodeError:
        logging.error(f"Problema de codificación al leer: {infile}")
        sys.exit(1)
    except pd.errors.EmptyDataError:
        logging.error(f"Fichero vacío o mal formado: {infile}")
        sys.exit(1)


def count_stems(
    chunks: Iterator[pd.DataFrame],
    stemmer: SnowballStemmer,
    stop_words: Set[str],
    total_rows: int
) -> Counter:
    """
    Procesa cada chunk, stemmiza y cuenta frecuencias de stems.
    Devuelve un Counter con conteo global.
    """
    counter = Counter()
    # Iterar con barra de progreso basada en filas
    processed = 0
    pbar = tqdm(total=total_rows, desc="Procesando filas", unit="filas")
    for df in chunks:
        for text in df["texto"].fillna("").astype(str):
            # tokenización simple: palabras alfanuméricas
            words = (
                w for w in pd.Series(text.lower())
                for w in pd.Series(text.lower()).str.findall(r"\b\w+\b")
            )
            # aplicar stemmer omitiendo stop-words y actualizando contador
            for w in [w for w in words if w not in stop_words]:
                counter[stemmer.stem(w)] += 1
        processed += len(df)
        pbar.update(len(df))
    pbar.close()
    return counter


def estimate_total_rows(infile: str) -> int:
    """
    Estima número total de filas (sin contar cabecera) para la barra de progreso.
    """
    try:
        with open(infile, encoding="utf-8", errors="ignore") as f:
            return sum(1 for _ in f) - 1
    except Exception:
        return 0


def write_top_stems(
    counter: Counter, top_n: int, outfile: str
) -> None:
    """
    Extrae los 'top_n' stems más frecuentes y escribe CSV de salida.
    """
    most_common = counter.most_common(top_n)
    df_out = pd.DataFrame(most_common, columns=["stem", "frecuencia"])
    try:
        df_out.to_csv(outfile, index=False, encoding="utf-8")
    except Exception as e:
        logging.error(f"Error al escribir CSV de salida: {e}")
        sys.exit(1)


def main() -> None:
    """Función principal."""
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S"
    )
    args = parse_args()

    logging.info("Cargando stop-words...")
    stop_words = load_stopwords(args.stopwords)

    logging.info("Inicializando SnowballStemmer (español)...")
    stemmer = SnowballStemmer("spanish")

    logging.info("Estimando tamaño del corpus para la barra de progreso...")
    total_rows = estimate_total_rows(args.input)

    logging.info("Iniciando procesamiento en chunks...")
    chunks = iter_chunks(args.input, args.chunksize)
    stem_counts = count_stems(chunks, stemmer, stop_words, total_rows)

    logging.info(f"Seleccionando los {args.top_n} stems más frecuentes...")
    write_top_stems(stem_counts, args.top_n, args.output)

    logging.info(f"Proceso completado. Resultado en '{args.output}'.")


if __name__ == "__main__":
    main()

Por qué funciona

  • El stemmer Snowball soporta español y se incluye en NLTK  .
  • El algoritmo deriva del Porter original (1980), optimizado para cada idioma  .
  • En SEO exploratorio basta para señalizar temas duplicados en grandes lotes de páginas  .

3. Prompt #2 — Lemmatización precisa con spaCy

Prompt

Rol: Ingeniero de datos de contenido.

Objetivo: Escribir un script Python de un solo fichero que haga lo siguiente:
  1. Cargue el modelo spaCy “es_core_news_md”.
  2. Lea un CSV llamado `pages.csv` con al menos las columnas:
     - `page_id`: identificador único de la página.
     - `text`: contenido textual a procesar.
  3. Procese cada texto:
     a. Tokenice y analice con spaCy.
     b. Filtre tokens que sean sustantivos (NOUN) o verbos (VERB).
     c. Excluya stop-words y puntuación.
     d. Extraiga el lema de cada token restante.
  4. Calcule el conteo de apariciones de cada lema por página, y el conteo total global.
  5. Genere un CSV `lemmas.csv` con columnas:
     - `page_id`
     - `lemma`
     - `pos` (NOUN o VERB)
     - `count` (veces que aparece en esa página)
  6. Tras procesar todas las páginas, imprima por consola:
     - Número de páginas procesadas.
     - Total de lemas extraídos.
     - Tiempo de ejecución.
  7. Asegure:
     - Uso de **funciones** claras: p. ej. `load_data()`, `extract_lemmas()`, `save_results()`.
     - Comprobación de errores (ficheros no existentes, modelo no cargado, corrupción de datos…).
     - Uso de `argparse` para poder indicar rutas de entrada y salida por línea de comandos.
     - Documentación: docstrings en cada función, comentarios explicativos.
     - Buenas prácticas: bloque `if __name__ == "__main__":`, logs con el módulo `logging`, y manejo eficiente de memoria.

Requisitos no funcionales:
  - Código limpio y legible (pep8).
  - Modificado para escalabilidad (p. ej. usando comprensiones y generadores donde proceda).
  - Dependencias declaradas en un encabezado (solo spaCy y librerías estándar).

Script

#!/usr/bin/env python3
"""
Script para extraer lemas de textos en español usando spaCy.

Dependencias:
    - spaCy (con el modelo es_core_news_md instalado)
    - Librerías estándar: argparse, csv, logging, os, sys, time, collections
"""

import argparse
import csv
import logging
import os
import sys
import time
from collections import Counter

import spacy

# Etiquetas de POS a extraer
POS_TAGS = {"NOUN", "VERB"}


def load_data(input_path):
    """
    Carga el CSV de entrada y genera tuplas (page_id, text).

    :param input_path: Ruta al fichero CSV con columnas 'page_id' y 'text'.
    :yields: (page_id: str, text: str)
    :raises FileNotFoundError: Si no existe el fichero.
    :raises csv.Error: Si el CSV está corrupto.
    """
    if not os.path.isfile(input_path):
        raise FileNotFoundError(f"No se encontró el fichero de entrada: {input_path}")

    with open(input_path, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        if "page_id" not in reader.fieldnames or "text" not in reader.fieldnames:
            raise csv.Error(f"Faltan columnas requeridas en {input_path}")
        for row in reader:
            yield row["page_id"], row["text"]


def extract_lemmas(nlp, pages):
    """
    Procesa cada página, filtra tokens por POS, elimina stop-words y puntuación,
    y cuenta lemas.

    :param nlp: Objeto spaCy cargado.
    :param pages: Iterador de (page_id, text).
    :returns: Generador de (page_id, page_counts: Counter[(lemma, pos)])
    """
    for page_id, text in pages:
        doc = nlp(text)
        # Generador de tuples (lemma, pos) para contar
        lemmas = (
            (token.lemma_.lower(), token.pos_)
            for token in doc
            if token.pos_ in POS_TAGS and not token.is_stop and not token.is_punct
        )
        page_counts = Counter(lemmas)
        yield page_id, page_counts


def save_results(output_path, results):
    """
    Guarda los resultados por página en un CSV con columnas
    page_id, lemma, pos, count.

    :param output_path: Ruta del CSV de salida.
    :param results: Iterador de (page_id, page_counts).
    """
    # Asegurarse de que el directorio de salida existe
    os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)

    with open(output_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(["page_id", "lemma", "pos", "count"])
        for page_id, page_counts in results:
            for (lemma, pos), count in page_counts.items():
                writer.writerow([page_id, lemma, pos, count])


def parse_args():
    """
    Define y parsea los argumentos de línea de comandos.

    :returns: Namespace con atributos 'input' y 'output'.
    """
    parser = argparse.ArgumentParser(
        description="Extrae lemas (sustantivos y verbos) de textos en español."
    )
    parser.add_argument(
        "-i", "--input", default="pages.csv",
        help="Fichero CSV de entrada (defecto: pages.csv)"
    )
    parser.add_argument(
        "-o", "--output", default="lemmas.csv",
        help="Fichero CSV de salida (defecto: lemmas.csv)"
    )
    return parser.parse_args()


def main():
    # Configuración de logging
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )

    args = parse_args()
    input_path = args.input
    output_path = args.output

    # Cargar modelo spaCy
    try:
        logging.info("Cargando modelo spaCy 'es_core_news_md'...")
        nlp = spacy.load("es_core_news_md")
    except OSError as e:
        logging.error(f"No se pudo cargar el modelo spaCy: {e}")
        sys.exit(1)

    # Preparar lectura de datos
    try:
        pages = load_data(input_path)
    except (FileNotFoundError, csv.Error) as e:
        logging.error(e)
        sys.exit(1)

    start_time = time.time()
    page_count = 0
    total_lemmas = 0

    # Generador de resultados mientras procesamos, para ahorro de memoria
    def gen_results():
        nonlocal page_count, total_lemmas
        for page_id, page_counts in extract_lemmas(nlp, pages):
            page_count += 1
            total_lemmas += sum(page_counts.values())
            yield page_id, page_counts

    # Guardar CSV de resultados
    try:
        save_results(output_path, gen_results())
    except Exception as e:
        logging.error(f"Error al guardar resultados: {e}")
        sys.exit(1)

    elapsed = time.time() - start_time

    # Imprimir resumen
    print(f"Páginas procesadas: {page_count}")
    print(f"Total de lemas extraídos: {total_lemmas}")
    print(f"Tiempo de ejecución: {elapsed:.2f} segundos")


if __name__ == "__main__":
    main()

Claves técnicas

  • spaCy usa un lematizador basado en lookup y reglas morfo-sintácticas; el modelo español md logra ≈ 94 % de acierto en Universal Dependencies  .
  • A diferencia de Snowball, respeta género, número y contexto, reduciendo falsos positivos  .
  • Los lemas permiten empatar consultas de usuario con títulos internos sin depender de coincidencia exacta.

4. Alternativas, técnicas y costes

Herramienta / EnfoqueTipoComplejidadCoste aprox.*VentajasContras
PorterStemmer (NLTK)Algoritmo clásicoMuy bajaGratisRapidísimo, una línea de código Cortes agresivos, solo EN
SnowballStemmerHeurístico multi-idiomaBajaGratisEspañol incluido No usa contexto, raíces a veces artificiales
spaCy LemmatizerLookup + reglasMediaGratis (local) Alta precisión, vectores integrados+RAM, tuning por idioma
Stanza LemmaPipeline neuronalMediaGratis80 idiomas, contexto completo >CPU, modelos pesados
OpenAI EmbeddingsAPI cloudBaja0,00013 $/1 k tokensCaptura semántica profundaDatos en nube, coste variable
Google Cloud NL APIAPI cloudBaja1 $/1 k registrosIncluye syntax + lemmaLímite 1 000 chars, dependes de GCP
Lancaster Stemmer (NLTK)Heurístico severoMuy bajaGratisExtremadamente agresivoSobre-stemming; uso nicho
Hugging Face TransformersModel zooMedia-AltaDesde 0,06 $/1 k tokensModelos SOTA listos Latencia; gestión de tokens

*Precios consultados mayo 2025.

5. Resumiendo

  • El stemming con NLTK es perfecto para exploraciones rápidas y grandes volúmenes; sacrifica precisión pero reduce memoria al instante.
  • La lemmatización de spaCy equilibra velocidad y fidelidad: ideal para dashboards de canibalización o emparejar consultas long-tail.
  • Frameworks como Stanza elevan la calidad con redes neuronales, aunque su huella computacional es mayor.
  • Para proyectos serverless o sin infraestructura, las APIs cloud (OpenAI, Google) ofrecen resultados de última generación pagando solo por uso.

Con este arsenal de prompts, scripts y comparativa, estás a tres comandos de transformar tus textos en datos normalizados listos para insights. ¡Feliz análisis semántico!

Aprende más sobre SEO Semántico

Daniel Pajuelo
Daniel Pajuelo es ingeniero informático y SEO Senior, actualmente trabajando en Guruwalk. En su blog personal escribe sobre Inteligencia Artificial, SEO, Vibe Coding, Blockchain... Ver más

Continua leyendo

Leer más sobre: SEO, Programación