Tokenizar y contar N-Grams con Python

image 2

Python nos permite transformar un puñado de artículos en una radiografía cuantitativa de su vocabulario: basta tokenizar, eliminar “palabras vacías” y contar qué unigrama, bigrama o trigrama domina cada texto. Con unas pocas líneas podemos incluso contrastar esos conteos con los de la competencia y pintar nubes de palabras que revelan vacíos temáticos invisibles a simple vista.

1. Contexto y flujo de trabajo

Tokenizar y contar n-gramas es un clásico del NLP: los bigramas “marketing digital” o “machine learning”, al aparecer juntos con frecuencia, señalan conceptos que una sola palabra no capta .

Para SEO resulta útil cotejar la frecuencia de esos términos compuestos entre nuestro sitio y el de un competidor para descubrir brechas de contenido.

En Python, collections.Counter ofrece un conteo O(n) sobre cualquier lista de tokens  ; NLTK incluye utilidades para generar n-gramas y collocations  ; scikit-learn vectoriza textos con bigramas/trigramas en matrices dispersas que escalan a miles de documentos.

Para visualizar, la librería WordCloud genera nubes donde el tamaño refleja frecuencia y se integra con matplotlib en una línea.

2. Prompt #1 — Conteo rápido con NLTK + Counter

Prompt:

Eres un Analista SEO y vas a crear un **script Python** de un solo fichero, con la siguiente funcionalidad y calidad:

1. **Requisitos de entorno**  
   - Python 3.8+  
   - Usar únicamente librerías estándar o, en su defecto, especificar al inicio con un `requirements.txt`: `requests`, `beautifulsoup4`, `nltk`, `pandas`, `tqdm`.

2. **Interfaz de línea de comandos**  
   - Manejar argumentos con `argparse` para:  
     - Ruta al archivo de entrada (`.txt` o `.csv`) con listado de URLs.  
     - Ruta al archivo de salida CSV.  
     - Idioma de stop-words (por defecto `'spanish'`).  
     - Nivel de verbosidad/log (`--verbose`).

3. **Estructura del código**  
   - Una única función `main()` que orquesta el flujo.  
   - Funciones auxiliares bien definidas, por ejemplo:  
     - `load_urls(path: str) -> List[str]`  
     - `fetch_html(url: str, timeout: float = 10) -> str`  
     - `extract_paragraphs(html: str) -> List[str]`  
     - `clean_and_tokenize(text: str, stopwords: Set[str]) -> List[str]`  
     - `compute_ngrams(tokens: List[str], n: int, top_k: int) -> List[Tuple[str, int]]`  
     - `save_to_csv(data: Dict[str, List[Tuple[str, int]]], output_path: str) -> None`

4. **Eficiencia y robustez**  
   - Uso de `requests.Session()` para conexiones persistentes.  
   - Manejo de excepciones (`requests.exceptions`, `UnicodeDecodeError`, etc.) con reintentos limitados (por ejemplo, 3 intentos).  
   - Barra de progreso con `tqdm` al procesar URLs.  
   - Carga de stop-words desde `nltk.corpus`, con descarga automática si no existen.  
   - Uso de `collections.Counter` para contar n-gramas.

5. **Documentación y estilo**  
   - Docstrings en formato Google o NumPy para todas las funciones.  
   - Comentarios claros en secciones críticas.  
   - Uso de type hints en todas las funciones.  
   - Estructura de logging con el módulo `logging` (niveles INFO y DEBUG).

6. **Salida**  
   - Un único CSV con columnas:  
     `ngram_type` (`unigrama`/`bigram`/`trigrama`),  
     `ngram`,  
     `frequency`.  
   - Opcional: separar en hojas de Excel o múltiples CSV si se desea.

**Objetivo final**: genera un script Python autocontenido (único fichero) que cumpla todos los puntos anteriores y pueda ejecutarse directamente tras instalar dependencias.```

Script:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script para extraer y analizar n-gramas (uni-, bi- y trigrama) de una lista de URLs.

Uso:
    python seo_ngrams.py \
      --input urls.txt \
      --output resultados.csv \
      [--lang spanish] \
      [--verbose]

Requisitos:
    - Python 3.8+
    - Instalar con `pip install -r requirements.txt`
"""

import argparse
import csv
import logging
import sys
from collections import Counter
from typing import Dict, List, Set, Tuple

import nltk
import pandas as pd
import requests
from bs4 import BeautifulSoup
from requests.exceptions import RequestException
from tqdm import tqdm


def load_urls(path: str) -> List[str]:
    """Carga URLs desde un archivo de texto (.txt) o CSV (.csv).

    Args:
        path (str): Ruta al archivo de entrada.

    Returns:
        List[str]: Lista de URLs limpias.
    """
    urls: List[str] = []
    if path.lower().endswith('.txt'):
        with open(path, 'r', encoding='utf-8') as f:
            for line in f:
                url = line.strip()
                if url:
                    urls.append(url)
    elif path.lower().endswith('.csv'):
        df = pd.read_csv(path, dtype=str, header=None)
        # se asume que la primera columna contiene las URLs
        urls = df.iloc[:, 0].dropna().astype(str).str.strip().tolist()
    else:
        logging.error("Formato de archivo no soportado: %s", path)
        sys.exit(1)
    return urls


def fetch_html(session: requests.Session, url: str, timeout: float = 10.0, retries: int = 3) -> str:
    """Descarga el HTML de una URL, con reintentos ante errores.

    Args:
        session (requests.Session): Sesión de requests para conexiones persistentes.
        url (str): URL a consultar.
        timeout (float): Tiempo máximo de espera por respuesta.
        retries (int): Número de reintentos ante fallo.

    Returns:
        str: Contenido HTML o cadena vacía si falla.
    """
    for attempt in range(1, retries + 1):
        try:
            logging.debug("Intentando descargar (%d/%d): %s", attempt, retries, url)
            resp = session.get(url, timeout=timeout)
            resp.raise_for_status()
            resp.encoding = resp.apparent_encoding
            return resp.text
        except (RequestException, UnicodeDecodeError) as e:
            logging.warning("Error al descargar %s: %s", url, e)
    logging.error("No se pudo descargar tras %d intentos: %s", retries, url)
    return ""


def extract_paragraphs(html: str) -> List[str]:
    """Extrae el texto de todos los párrafos <p> de un HTML.

    Args:
        html (str): Código HTML de la página.

    Returns:
        List[str]: Lista de cadenas de texto de cada párrafo.
    """
    soup = BeautifulSoup(html, 'html.parser')
    return [p.get_text(separator=' ', strip=True) for p in soup.find_all('p')]


def clean_and_tokenize(text: str, stopwords: Set[str]) -> List[str]:
    """Limpia, tokeniza y filtra tokens inútiles de un texto.

    - Pasa a minúsculas.
    - Tokeniza con NLTK.
    - Elimina tokens no alfabéticos o presentes en stopwords.

    Args:
        text (str): Texto a procesar.
        stopwords (Set[str]): Conjunto de palabras vacías a eliminar.

    Returns:
        List[str]: Tokens limpios.
    """
    text = text.lower()
    tokens = nltk.word_tokenize(text)
    return [t for t in tokens if t.isalpha() and t not in stopwords]


def compute_ngrams(tokens: List[str], n: int, top_k: int) -> List[Tuple[str, int]]:
    """Calcula los n-gramas más frecuentes.

    Args:
        tokens (List[str]): Lista de tokens.
        n (int): Tamaño del n-grama (1, 2 o 3).
        top_k (int): Cuántos n-gramas devolver.

    Returns:
        List[Tuple[str, int]]: Lista de tuplas (ngrama, frecuencia).
    """
    ngrams = [' '.join(tokens[i:i+n]) for i in range(len(tokens)-n+1)]
    counter = Counter(ngrams)
    return counter.most_common(top_k)


def save_to_csv(data: Dict[str, List[Tuple[str, int]]], output_path: str) -> None:
    """Guarda los resultados en un CSV con columnas: ngram_type, ngram, frequency.

    Args:
        data (Dict[str, List[Tuple[str, int]]]): Diccionario mapeando
            'unigrama'/'bigram'/'trigrama' a listas de (ngrama, freq).
        output_path (str): Ruta al CSV de salida.
    """
    rows = []
    for ngram_type, items in data.items():
        for ngram, freq in items:
            rows.append({'ngram_type': ngram_type, 'ngram': ngram, 'frequency': freq})
    df = pd.DataFrame(rows)
    df.to_csv(output_path, index=False, encoding='utf-8')
    logging.info("Resultados guardados en %s", output_path)


def main() -> None:
    """Función principal que orquesta todo el flujo."""
    parser = argparse.ArgumentParser(description="Análisis de n-gramas SEO desde URLs")
    parser.add_argument('-i', '--input',   required=True,  help="Archivo de entrada (.txt o .csv) con URLs")
    parser.add_argument('-o', '--output',  required=True,  help="Archivo de salida CSV")
    parser.add_argument('--lang',          default='spanish', help="Idioma de stopwords (por defecto 'spanish')")
    parser.add_argument('-v', '--verbose', action='store_true',   help="Nivel de detalle en logs")
    args = parser.parse_args()

    # Configuración de logging
    level = logging.DEBUG if args.verbose else logging.INFO
    logging.basicConfig(format='[%(levelname)s] %(message)s', level=level)

    # Preparar stopwords y tokenizador
    try:
        nltk.data.find('tokenizers/punkt')
    except LookupError:
        logging.info("Descargando 'punkt' de NLTK...")
        nltk.download('punkt')
    try:
        stop = set(nltk.corpus.stopwords.words(args.lang))
    except LookupError:
        logging.info("Descargando 'stopwords' de NLTK para %s...", args.lang)
        nltk.download('stopwords')
        stop = set(nltk.corpus.stopwords.words(args.lang))

    # Cargar URLs
    urls = load_urls(args.input)
    logging.info("Se cargarán %d URLs", len(urls))

    session = requests.Session()
    all_tokens: List[str] = []

    # Procesar cada URL con barra de progreso
    for url in tqdm(urls, desc="Procesando URLs", unit="url"):
        html = fetch_html(session, url)
        if not html:
            continue
        paragraphs = extract_paragraphs(html)
        text = ' '.join(paragraphs)
        tokens = clean_and_tokenize(text, stop)
        all_tokens.extend(tokens)

    if not all_tokens:
        logging.error("No se obtuvieron tokens de ninguna URL. Saliendo.")
        sys.exit(1)

    # Calcular top K de n-gramas
    TOP_K = 30
    results: Dict[str, List[Tuple[str, int]]] = {
        'unigrama':   compute_ngrams(all_tokens, 1, TOP_K),
        'bigram':     compute_ngrams(all_tokens, 2, TOP_K),
        'trigrama':   compute_ngrams(all_tokens, 3, TOP_K),
    }

    # Guardar CSV
    save_to_csv(results, args.output)


if __name__ == '__main__':
    main()

Por qué funciona

  • Counter es parte de la biblioteca estándar y evita dependencias extra  .
  • NLTK provee tokenizador multilingüe y lista de stop-words en español  .
  • Con zip(*[lst[i:]…]) generamos n-gramas sin bibliotecas externas, útil para scripts rápidos  .

3. Prompt #2 — Análisis a escala con scikit-learn CountVectorizer

Prompt:

Eres un ingeniero de datos experto en Python. Tu tarea es generar un único script Python llamado `top_ngrams.py` que cumpla estos requisitos:

1. **Objetivo**  
   - Leer un fichero CSV `docs.csv` con columnas (`id`, `texto`).
   - Construir una matriz de características que incluya bigramas y trigramas usando `CountVectorizer` de scikit-learn.
   - Calcular, para cada documento, las 30 frases (n-gramas) más frecuentes.
   - Exportar el resultado a `top_ngrams.csv` con columnas (`id`, `ngram`, `frecuencia`).

2. **Estructura y estilo**  
   - Debe ser un único fichero ejecutable: `top_ngrams.py`.  
   - Incluir bloque `if __name__ == "__main__":` para permitir su importación.  
   - Usar funciones con firma clara (por ejemplo, `def load_data(path: str) -> pd.DataFrame:`).  
   - Documentar cada función con docstrings en formato NumPy o Google.  
   - Incluir comentarios que expliquen los pasos clave.

3. **Robustez y control de errores**  
   - Comprobar la existencia y legibilidad de `docs.csv`; informar de manera clara si falta o está mal formateado.  
   - Validar que las columnas esperadas existen.  
   - Capturar y manejar excepciones comunes (I/O, parsing, memoria insuficiente).

4. **Eficiencia**  
   - Definir parámetros de `CountVectorizer` apropiados (`ngram_range=(2,3)`, `min_df`, `max_df`).  
   - Posibilidad de procesar en streaming o por lotes si el fichero es muy grande.  
   - Evitar cálculos redundantes y liberar memoria cuando sea posible.

5. **Dependencias y ejecución**  
   - Incluir al principio un bloque `requirements.txt` comentado con las librerías necesarias (e.g. `pandas`, `scikit-learn`).  
   - Mostrar en un comentario cómo ejecutar el script:  
     ```bash
     python top_ngrams.py --input docs.csv --output top_ngrams.csv
     ```
   - Usar `argparse` para manejar rutas de entrada y salida.

6. **Salida**  
   - `top_ngrams.csv` debe tener encabezados y estar ordenado por `id` y luego por frecuencia descendente.  
   - Imprimir un breve resumen al finalizar (número de documentos procesados, tiempo total).

Genera únicamente el contenido de `top_ngrams.py`, listo para usar. Asegúrate de que sea claro, mantenible y cumpla con las mejores prácticas de ingeniería de datos en Python.

Script:

#!/usr/bin/env python3
# requirements.txt
# pandas
# scikit-learn

import os
import sys
import argparse
import time
import logging

import pandas as pd
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer

def load_data(path: str, chunksize: int = None):
    """
    Carga datos desde un archivo CSV.

    Parámetros
    ----------
    path : str
        Ruta al fichero CSV.
    chunksize : int, opcional
        Número de filas por lote. Si es None, carga todo en memoria.

    Retorna
    -------
    pd.DataFrame o TextFileReader
        DataFrame si chunksize es None, en caso contrario un iterador de DataFrames.
    """
    if chunksize:
        return pd.read_csv(path, usecols=['id', 'texto'], chunksize=chunksize)
    else:
        return pd.read_csv(path, usecols=['id', 'texto'])

def validate_input_file(path: str):
    """
    Verifica que el archivo exista y sea legible, y que tenga las columnas necesarias.

    Parámetros
    ----------
    path : str
        Ruta al fichero CSV de entrada.

    Lanza
    -----
    FileNotFoundError
        Si el archivo no existe.
    PermissionError
        Si el archivo no es legible.
    ValueError
        Si faltan columnas 'id' o 'texto'.
    """
    if not os.path.isfile(path):
        raise FileNotFoundError(f"El fichero '{path}' no existe.")
    if not os.access(path, os.R_OK):
        raise PermissionError(f"No se puede leer el fichero '{path}'.")
    # Comprobar columnas
    header = pd.read_csv(path, nrows=0)
    required = {'id', 'texto'}
    if not required.issubset(header.columns):
        missing = required - set(header.columns)
        raise ValueError(f"Faltan columnas obligatorias: {', '.join(missing)}")

def extract_top_ngrams(X, feature_names, ids, top_n):
    """
    Extrae los top n n-gramas por documento a partir de la matriz de recuentos.

    Parámetros
    ----------
    X : scipy.sparse matrix
        Matriz de recuentos (documentos x n-gramas).
    feature_names : array-like
        Nombres de las características (n-gramas).
    ids : list
        Lista de identificadores de documento, alineada con las filas de X.
    top_n : int
        Número de n-gramas a extraer por documento.

    Retorna
    -------
    pd.DataFrame
        DataFrame con columnas ['id', 'ngram', 'frecuencia'].
    """
    results = []
    for i, doc_id in enumerate(ids):
        row = X.getrow(i).toarray().flatten()
        # ordenar índices por frecuencia descendente
        sorted_idxs = np.argsort(row)[::-1]
        count = 0
        for idx in sorted_idxs:
            freq = int(row[idx])
            if freq <= 0:
                break
            results.append((doc_id, feature_names[idx], freq))
            count += 1
            if count >= top_n:
                break
    return pd.DataFrame(results, columns=['id', 'ngram', 'frecuencia'])

def compute_top_ngrams_full(df: pd.DataFrame, min_df: float, max_df: float, top_n: int):
    """
    Calcula los top n n-gramas para cada documento cargado en memoria.

    Parámetros
    ----------
    df : pd.DataFrame
        DataFrame con columnas 'id' y 'texto'.
    min_df : float
        Frecuencia mínima de documento (absoluta o proporción).
    max_df : float
        Frecuencia máxima de documento (proporción, entre 0.0 y 1.0).
    top_n : int
        Número de n-gramas a extraer por documento.

    Retorna
    -------
    pd.DataFrame
        DataFrame con columnas ['id', 'ngram', 'frecuencia'].
    """
    # Vectorizador global
    vectorizer = CountVectorizer(ngram_range=(2, 3), min_df=min_df, max_df=max_df)
    X = vectorizer.fit_transform(df['texto'])
    features = vectorizer.get_feature_names_out()
    ids = df['id'].tolist()
    return extract_top_ngrams(X, features, ids, top_n)

def compute_top_ngrams_chunk(df: pd.DataFrame, min_df: float, max_df: float, top_n: int):
    """
    Calcula los top n n-gramas para cada documento de un lote.

    Parámetros
    ----------
    df : pd.DataFrame
        Lote con columnas 'id' y 'texto'.
    min_df : float
        Frecuencia mínima de documento (absoluta o proporción).
    max_df : float
        Frecuencia máxima de documento (proporción, entre 0.0 y 1.0).
    top_n : int
        Número de n-gramas a extraer por documento.

    Retorna
    -------
    pd.DataFrame
        DataFrame con columnas ['id', 'ngram', 'frecuencia'].
    """
    vectorizer = CountVectorizer(ngram_range=(2, 3), min_df=min_df, max_df=max_df)
    X = vectorizer.fit_transform(df['texto'])
    features = vectorizer.get_feature_names_out()
    ids = df['id'].tolist()
    return extract_top_ngrams(X, features, ids, top_n)

def main():
    parser = argparse.ArgumentParser(description="Extrae los 30 n-gramas más frecuentes por documento")
    parser.add_argument('--input', '-i', required=True, help="Archivo CSV de entrada (docs.csv)")
    parser.add_argument('--output', '-o', required=True, help="Archivo CSV de salida (top_ngrams.csv)")
    parser.add_argument('--min_df', type=float, default=1,
                        help="Frecuencia mínima de documento (int absoluto o float proporción)")
    parser.add_argument('--max_df', type=float, default=1.0,
                        help="Frecuencia máxima de documento (float proporción, <=1.0)")
    parser.add_argument('--batch-size', type=int, default=None,
                        help="Tamaño de lote para procesamiento en streaming (opcional)")
    parser.add_argument('--top-n', type=int, default=30,
                        help="Número de n-gramas a extraer por documento")
    args = parser.parse_args()

    # Validar archivo de entrada
    validate_input_file(args.input)

    start_time = time.time()
    processed_docs = 0

    # Procesamiento en streaming por lotes
    if args.batch_size:
        results = []
        for chunk in load_data(args.input, chunksize=args.batch_size):
            processed_docs += len(chunk)
            results.append(compute_top_ngrams_chunk(chunk, args.min_df, args.max_df, args.top_n))
        if results:
            result_df = pd.concat(results, ignore_index=True)
        else:
            result_df = pd.DataFrame(columns=['id', 'ngram', 'frecuencia'])
    else:
        # Carga completa en memoria
        df = load_data(args.input)
        processed_docs = len(df)
        result_df = compute_top_ngrams_full(df, args.min_df, args.max_df, args.top_n)

    # Ordenar y exportar
    result_df.sort_values(by=['id', 'frecuencia'], ascending=[True, False], inplace=True)
    result_df.to_csv(args.output, index=False)

    elapsed = time.time() - start_time
    print(f"Procesados {processed_docs} documentos en {elapsed:.2f} segundos.")

if __name__ == "__main__":
    try:
        main()
    except (FileNotFoundError, PermissionError) as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    except pd.errors.EmptyDataError:
        print("Error: El fichero está vacío o mal formateado.", file=sys.stderr)
        sys.exit(1)
    except pd.errors.ParserError:
        print("Error: No se pudo parsear el CSV. Comprueba el formato.", file=sys.stderr)
        sys.exit(1)
    except MemoryError:
        print("Error: Memoria insuficiente para procesar los datos.", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Error inesperado: {e}", file=sys.stderr)
        sys.exit(1)

# Cómo ejecutar:
# python top_ngrams.py --input docs.csv --output top_ngrams.csv [--min_df 2] [--max_df 0.9] [--batch-size 1000] [--top-n 30]

Ventajas y uso

  • CountVectorizer incorpora tokenización, stop-words y n-gramas en un único paso  .
  • La matriz dispersa escala a decenas de miles de documentos con RAM contenida  .
  • Normalizar (l1) permite comparar la importancia relativa de cada n-grama entre textos de distinto tamaño.

4. Visualización rápida con WordCloud

Una vez obtenidas las frecuencias, generar una nube de palabras es tan sencillo como:

from wordcloud import WordCloud

import matplotlib.pyplot as plt

wc = WordCloud(width=800, height=400, background_color="white")

wc.generate_from_frequencies(dict(bigramas))

plt.imshow(wc); plt.axis("off"); plt.show()

La librería soporta máscaras personalizadas y se integra con matplotlib sin esfuerzo  .

5. Más aproximaciones

Enfoque / LibreríaTipoComplejidadCosteProsContras
collections.Counter + NLTKOSSMuy bajaGratis0 deps fuera de NLTK; ideal POCLento >50 k docs
scikit-learn CountVectorizerOSSMediaGratisMatrices sparsas, TF–IDFRequiere memoria si vocab grande
spaCy PhraseMatcherOSSMediaGratisFiltra n-gramas con reglas y contextoRAM >500 MB modelos md/lg
Gensim PhrasesOSSMediaGratisDetecta frases nuevas con PMIEntrenamiento previo
TextBlob n-gramsOSSMuy bajaGratisAPI minimalNo stop-words ES por defecto
HashingVectorizerOSSMediaGratisMemoria constante, streamingVocabulario no interpretable
BrightEdge / SemrushSaaSBaja150 € +UI amigable, datasets listosPago recurrente, caja negra
GCP BigQuery + SQL n-gramSaaSMedia5 $/TB¡Serverless!, terabytesNecesita GCP, SQL avanzado

6. Conclusiones

  • Counter + NLTK basta para auditorías ligeras y educativas; en minutos revelas qué frases dominas y cuáles faltan.
  • CountVectorizer es el caballo de batalla para lotes grandes: combina tokenización, stop-words y n-gramas en una sola línea y produce matrices comparables.
  • Complementa el análisis con WordCloud para una lectura visual inmediata y con técnicas de normalización para no sesgar por longitud.
  • Herramientas como spaCy PhraseMatcher o Gensim Phrases suman detección contextual y son recomendables cuando necesites identificar frases inéditas o variantes semánticas.
  • Elige la ruta según volumen, presupuesto y finalidad: lo importante es que tu contenido refleje de forma explícita las combinaciones de términos que tu audiencia (y los motores) esperan encontrar. ¡A contar n-gramas y a cubrir esas brechas!

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