
Python convierte la “sensación” de un artículo — su tono emocional, las emociones que evoca y los sub-temas que esconde — en datos accionables para SEO semántico. Con librerías como TextBlob, spaCyTextBlob y Gensim, podemos puntuar la positividad/negatividad de cada párrafo, detectar si la voz es neutral o promocional y, además, descubrir los tópicos latentes que reúnen (o nos faltan) en comparación con la competencia.
1. ¿Por qué medir sentimiento, tono y tópicos?
Google no asigna una “nota” emocional directa, pero los estudios sobre search intent muestran que los contenidos que reflejan la perspectiva dominante (informativa, cautelosa, comparativa…) tienden a rankear mejor porque satisfacen la expectativa del usuario .
Entender la polaridad de reseñas o comentarios revela pain points que podemos abordar en nuestra copy , y contrastar el tono global de los top 10 contra el nuestro ayuda a evitar disonancias (por ejemplo, un exceso de entusiasmo en un tema delicado de salud) .
Para vistas de alto nivel, el topic modeling de Gensim organiza cientos de artículos en sub-temas y descubre ángulos que faltan, técnica recomendada por la industria para SEO semántico .
2. Prompt #1 — Análisis de sentimiento con TextBlob / spaCyTextBlob
Prompt
Rol: Analista de reputación de productos.
Objetivo:
Generar un único script Python (`analisis_reputacion.py`) que:
1. **Entrada y salida**
- Acepte parámetros por línea de comandos (`--input`, `--output`) usando `argparse`.
- Por defecto `--input reviews.csv` y `--output resumen.csv`.
2. **Validación**
- Compruebe que el fichero de entrada existe y es legible.
- Verifique que contiene al menos las columnas `id` y `texto`; en caso contrario, muestre un error claro.
3. **Carga de datos**
- Use `pandas` para leer el CSV de reseñas.
4. **Análisis de sentimiento**
- Inicialice spaCy con el pipeline TextBlob (`spaCyTextBlob`).
- Recorra las filas (optimizando con `DataFrame.apply` o procesamiento por lotes) y calcule para cada `texto`:
- `polarity` en rango [-1, 1]
- `subjectivity` en rango [0, 1]
5. **Resumen por producto**
- Agrupe por `id` de producto y calcule la media de `polarity` y `subjectivity`.
- Nombre las columnas de salida: `id`, `avg_polarity`, `avg_subjectivity`.
6. **Salida**
- Exporte el DataFrame resultado a CSV (`--output`), con índice desactivado.
7. **Robustez y rendimiento**
- Añada manejo de excepciones para errores de lectura, análisis o escritura.
- Use la librería `logging` para informar de:
- Inicio y fin del proceso.
- Número de reseñas procesadas.
- Advertencias y errores.
- Incorpore una barra de progreso (`tqdm`) si el dataset es grande.
8. **Calidad de código**
- Documente cada función con docstrings (estilo NumPy o Google).
- Use **type hints** en todas las funciones.
- Cumpla con PEP8 (longitud de línea, nombres descriptivos).
- Incluya en la cabecera del script:
- Breve descripción.
- Autor y fecha.
- Requisitos y comando para generar un `requirements.txt` (`pip freeze` o `poetry export`).
Entrega:
Un único fichero `analisis_reputacion.py` que cumpla todos los puntos anteriores, listo para ejecutarse en un entorno Python 3.8+.
Script:
#!/usr/bin/env python3
"""
analisis_reputacion.py
Script para analizar la reputación de productos a partir de reseñas de texto.
Autor: Tu Nombre
Fecha: 2025-05-03
Requisitos:
- Python 3.8+
- pandas
- spacy
- spacy-textblob
- tqdm
Generar requirements.txt:
pip freeze > requirements.txt
"""
import argparse
import logging
import os
import sys
import pandas as pd
import spacy
from spacytextblob.spacytextblob import SpacyTextBlob
from tqdm import tqdm
def parse_args() -> argparse.Namespace:
"""
Parse command-line arguments.
Returns
-------
argparse.Namespace
Objeto con atributos 'input' y 'output'.
"""
parser = argparse.ArgumentParser(
description="Analiza reseñas y resume sentimiento por producto."
)
parser.add_argument(
"--input", "-i",
default="reviews.csv",
help="CSV de entrada con columnas 'id' y 'texto'."
)
parser.add_argument(
"--output", "-o",
default="resumen.csv",
help="CSV de salida para el resumen."
)
return parser.parse_args()
def setup_logging() -> None:
"""
Configura el logging para la aplicación.
"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
def validate_input_file(path: str) -> None:
"""
Verifica que el fichero exista, sea legible y contenga columnas mínimas.
Parameters
----------
path : str
Ruta al CSV de reseñas.
Raises
------
SystemExit
Si el fichero no existe, no es accesible o faltan columnas.
"""
if not os.path.isfile(path) or not os.access(path, os.R_OK):
logging.error("No se puede leer el fichero de entrada: %s", path)
sys.exit(1)
try:
df_sample = pd.read_csv(path, nrows=5)
except Exception as e:
logging.error("Error leyendo %s: %s", path, e)
sys.exit(1)
required = {"id", "texto"}
missing = required - set(df_sample.columns)
if missing:
logging.error("Faltan columnas requeridas en %s: %s", path, ", ".join(missing))
sys.exit(1)
def load_data(path: str) -> pd.DataFrame:
"""
Carga el CSV de reseñas en un DataFrame.
Parameters
----------
path : str
Ruta al CSV de reseñas.
Returns
-------
pd.DataFrame
DataFrame con todas las reseñas.
"""
return pd.read_csv(path)
def init_nlp() -> spacy.language.Language:
"""
Inicializa el pipeline de spaCy con TextBlob.
Returns
-------
spacy.language.Language
Objeto NLP con componente de análisis de sentimiento.
"""
nlp = spacy.load("en_core_web_sm", disable=["ner", "parser"])
nlp.add_pipe("spacytextblob")
return nlp
def analyze_sentiment(df: pd.DataFrame,
nlp: spacy.language.Language
) -> pd.DataFrame:
"""
Calcula polaridad y subjetividad para cada reseña.
Parameters
----------
df : pd.DataFrame
DataFrame con columna 'texto'.
nlp : spacy.language.Language
Pipeline de spaCy con TextBlob.
Returns
-------
pd.DataFrame
Mismo DataFrame con columnas 'polarity' y 'subjectivity'.
"""
tqdm.pandas(desc="Analizando sentimiento")
def _analyze(text: str) -> tuple[float, float]:
doc = nlp(text or "")
return doc._.polarity, doc._.subjectivity
sentiments = df["texto"].progress_apply(_analyze)
df["polarity"] = sentiments.map(lambda x: x[0])
df["subjectivity"] = sentiments.map(lambda x: x[1])
return df
def summarize_by_product(df: pd.DataFrame) -> pd.DataFrame:
"""
Agrupa por 'id' y calcula medias de sentimiento.
Parameters
----------
df : pd.DataFrame
DataFrame con columnas 'id', 'polarity', 'subjectivity'.
Returns
-------
pd.DataFrame
DataFrame con columnas 'id', 'avg_polarity', 'avg_subjectivity'.
"""
summary = (
df.groupby("id")[["polarity", "subjectivity"]]
.mean()
.reset_index()
.rename(
columns={
"polarity": "avg_polarity",
"subjectivity": "avg_subjectivity"
}
)
)
return summary
def save_summary(df: pd.DataFrame, path: str) -> None:
"""
Guarda el resumen en CSV.
Parameters
----------
df : pd.DataFrame
DataFrame de resumen.
path : str
Ruta de salida para el CSV.
"""
df.to_csv(path, index=False)
logging.info("Resumen guardado en %s", path)
def main() -> None:
"""
Función principal: orquesta validación, análisis y guardado.
"""
setup_logging()
args = parse_args()
logging.info("Inicio del análisis de reputación")
validate_input_file(args.input)
try:
df = load_data(args.input)
logging.info("Cargadas %d reseñas", len(df))
except Exception as e:
logging.error("Error cargando datos: %s", e)
sys.exit(1)
try:
nlp = init_nlp()
df = analyze_sentiment(df, nlp)
except Exception as e:
logging.error("Error en análisis de sentimiento: %s", e)
sys.exit(1)
try:
summary = summarize_by_product(df)
save_summary(summary, args.output)
except Exception as e:
logging.error("Error al generar o guardar resumen: %s", e)
sys.exit(1)
logging.info("Proceso completado correctamente")
if __name__ == "__main__":
main()
Explicación
- TextBlob calcula polaridad y subjetividad en una línea; los rangos son estándar .
- spaCyTextBlob expone esos valores como extensiones . _.blob sin sacrificar la velocidad de spaCy .
- Promediar por producto revela si una referencia acumula quejas (polaridad negativa) o si las opiniones son muy subjetivas (alto sesgo).
3. Prompt #2 — Descubrimiento de tópicos con Gensim LDA
Prompt:
Rol: Ingeniero de contenido y desarrollador Python.
Objetivo: Generar un **único fichero Python** (compatible con Python 3.8+) que:
1. **Reciba por línea de comandos**:
- Ruta al directorio de entrada (`docs/`) con los archivos de texto.
- Ruta al fichero de salida (`topics.csv`).
2. **Configure correctamente**:
- Dependencias: `gensim`, `nltk`, `pandas`, `logging`.
- Descargue o compruebe los recursos de NLTK (stopwords, punkt, WordNet).
3. **Estructure el código** en funciones bien documentadas:
- `load_corpus(path: str) → List[str]`
- `preprocess(texts: List[str]) → List[List[str]]`
- `train_lda(processed: List[List[str]], num_topics: int) → (LdaModel, Dictionary, Corpus)`
- `export_topics(model, dictionary, output_csv: str, topn: int)`
4. **Implemente un preprocesamiento robusto**:
- Tokenización, minúsculas, eliminación de puntuación.
- Eliminación de stopwords en español e inglés.
- Lematización usando WordNet (o spaCy, si se prefiere).
5. **Entrene un modelo LDA** con Gensim:
- Número de temas configurable (por defecto 8).
- Optimice la memoria usando corpus en streaming.
6. **Exporte a CSV**:
- Columnas: `topic_id`, `term`, `weight`.
- Incluya los 10 términos principales de cada tema.
7. **Incluya**:
- Un encabezado con metadatos (autor, fecha, permisos).
- Manejo de errores y logs detallados (`logging`).
- Mensajes de progreso en consola.
```bash
# Ejemplo de uso:
python lda_topics.py --input docs/ --output topics.csv --num-topics 8
Script:
#!/usr/bin/env python3
"""
Author: Ingeniero de contenido y desarrollador Python
Date: 2025-05-03
License: MIT
"""
import os
import sys
import argparse
import logging
import string
from typing import List, Tuple, Iterable
import gensim
from gensim import corpora, models
import nltk
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize
from nltk.stem import WordNetLemmatizer
import pandas as pd
def download_nltk_resources():
"""
Ensure required NLTK resources are downloaded: punkt, stopwords, wordnet, omw-1.4.
"""
resources = {
'punkt': 'tokenizers/punkt',
'stopwords':'corpora/stopwords',
'wordnet': 'corpora/wordnet',
'omw-1.4': 'corpora/omw-1.4'
}
for pkg, path in resources.items():
try:
nltk.data.find(path)
except LookupError:
logging.info(f"Downloading NLTK resource: {pkg}")
nltk.download(pkg, quiet=True)
def load_corpus(path: str) -> List[str]:
"""
Load all .txt files from a directory into a list of document strings.
Args:
path: Path to directory containing .txt files.
Returns:
List of raw text documents.
"""
if not os.path.isdir(path):
logging.error(f"Input path '{path}' is not a directory.")
raise NotADirectoryError(f"{path} is not a valid directory.")
texts = []
for root, _, files in os.walk(path):
for fname in files:
if fname.lower().endswith('.txt'):
full_path = os.path.join(root, fname)
try:
with open(full_path, 'r', encoding='utf-8') as f:
texts.append(f.read())
logging.info(f"Loaded '{full_path}'")
except Exception as e:
logging.warning(f"Skipping '{full_path}': {e}")
if not texts:
logging.warning(f"No .txt files found in '{path}'.")
return texts
def preprocess(texts: List[str]) -> List[List[str]]:
"""
Preprocess documents:
- Tokenize
- Lowercase
- Remove punctuation
- Remove English & Spanish stopwords
- Lemmatize (WordNet)
Args:
texts: List of raw document strings.
Returns:
List of token lists, one per document.
"""
# Initialize tools
lemmatizer = WordNetLemmatizer()
en_sw = set(stopwords.words('english'))
sp_sw = set(stopwords.words('spanish'))
all_stop = en_sw.union(sp_sw)
processed = []
for doc in texts:
# Tokenize & lowercase
tokens = word_tokenize(doc)
tokens = [t.lower() for t in tokens if t.isalpha()]
# Remove stopwords
tokens = [t for t in tokens if t not in all_stop]
# Lemmatize
tokens = [lemmatizer.lemmatize(t) for t in tokens]
processed.append(tokens)
return processed
def train_lda(processed: List[List[str]], num_topics: int) -> Tuple[models.LdaModel, corpora.Dictionary, Iterable]:
"""
Train an LDA model using Gensim with a streaming corpus.
Args:
processed: Preprocessed token lists.
num_topics: Number of topics to extract.
Returns:
lda_model: Trained LdaModel.
dictionary: Gensim Dictionary mapping.
corpus: Streaming corpus (iterator of BOWs).
"""
# Build dictionary and filter extremes
dictionary = corpora.Dictionary(processed)
dictionary.filter_extremes(no_below=5, no_above=0.5)
# Streaming corpus class
class StreamingCorpus:
def __init__(self, texts, dictionary):
self.texts = texts
self.dictionary = dictionary
def __iter__(self):
for doc in self.texts:
yield self.dictionary.doc2bow(doc)
corpus = StreamingCorpus(processed, dictionary)
# Train LDA
lda_model = models.LdaModel(
corpus=corpus,
id2word=dictionary,
num_topics=num_topics,
passes=10,
alpha='auto',
per_word_topics=True
)
return lda_model, dictionary, corpus
def export_topics(model: models.LdaModel, dictionary: corpora.Dictionary, output_csv: str, topn: int = 10):
"""
Export the top terms for each topic to a CSV file.
Args:
model: Trained LdaModel.
dictionary: Gensim Dictionary.
output_csv: Path to write CSV.
topn: Number of top terms per topic.
"""
records = []
for tid in range(model.num_topics):
for term, weight in model.show_topic(tid, topn=topn):
records.append({'topic_id': tid, 'term': term, 'weight': weight})
df = pd.DataFrame.from_records(records, columns=['topic_id', 'term', 'weight'])
df.to_csv(output_csv, index=False)
logging.info(f"Exported topics to '{output_csv}'")
def parse_args() -> argparse.Namespace:
"""
Parse command-line arguments.
Returns:
Parsed arguments with .input, .output, and .num_topics.
"""
parser = argparse.ArgumentParser(
description="Train LDA topics over a directory of .txt files."
)
parser.add_argument(
"--input", "-i",
required=True,
help="Path to input directory containing text files."
)
parser.add_argument(
"--output", "-o",
required=True,
help="Path to output CSV file for topics."
)
parser.add_argument(
"--num-topics", "-n",
type=int,
default=8,
help="Number of topics to extract (default: 8)."
)
return parser.parse_args()
def main():
args = parse_args()
logging.info("=== LDA Topic Modeling Script Started ===")
download_nltk_resources()
logging.info("NLTK resources are ready.")
texts = load_corpus(args.input)
logging.info(f"Loaded {len(texts)} documents from '{args.input}'.")
processed = preprocess(texts)
logging.info("Preprocessing completed.")
lda_model, dictionary, _ = train_lda(processed, args.num_topics)
logging.info(f"Trained LDA model with {args.num_topics} topics.")
export_topics(lda_model, dictionary, args.output, topn=10)
logging.info("=== Script Finished Successfully ===")
if __name__ == "__main__":
logging.basicConfig(
format="%(asctime)s %(levelname)s: %(message)s",
level=logging.INFO
)
try:
main()
except Exception:
logging.exception("An unexpected error occurred.")
sys.exit(1)
Explicación
- Gensim es la implementación de referencia para LDA en Python .
- Limpiar stop-words antes de entrenar mejora la coherencia temática .
- Con bibliotecas como pyLDAvis podemos visualizar los tópicos en burbujas interactivas .
4. Visualización opcional: nube de palabras por polaridad
from wordcloud import WordCloud
import matplotlib.pyplot as plt
wc = WordCloud(width=800, height=400, background_color="white")
wc.generate(" ".join(df[df.polarity>0]["texto"]))
plt.imshow(wc); plt.axis("off"); plt.show()
La librería WordCloud facilita ver de un vistazo los términos más citados en opiniones positivas / negativas .
5. Tabla comparativa de alternativas
Herramienta / API | Tipo | Complejidad | Coste aprox.* | Ventajas | Limitaciones |
---|---|---|---|---|---|
TextBlob | OSS | Muy baja | Gratis | Polaridad + subjetividad en 1 línea | Inglés nativo, ES vía traducción |
spaCyTextBlob | OSS | Baja | Gratis | Integra TextBlob en pipeline spaCy | Modelos ES aún beta |
VADER (NLTK) | OSS | Baja | Gratis | Optimizado para redes sociales | Inglés solo |
Transformers (BERTweet-Emotion) | OSS / SaaS | Media | 0,06 $ / 1k tok Hugging Face | Emociones finas (joy, anger…) | Requiere GPU/latencia |
Google Cloud NL API | SaaS | Baja | 1 $ / 1k chars | Sentiment + entity en 76 idiomas | Límite 1 000 chars / call |
AWS Comprehend | SaaS | Baja | 0,5 $ / 1k chars | Soporte médico, sentimientos | Coste extra por dominios |
Gensim LDA | OSS | Media | Gratis | Tópicos reproducibles, corpora grandes | Requiere tuning k-topics |
BERTopic | OSS | Media | Gratis | Temas basados en embeddings | >RAM, torch/UMAP |
pyLDAvis | OSS | Media | Gratis | Visualización interactiva de LDA | Solo post-model |
*Precios verificados mayo 2025; sujetos a cambios.
6. El sentimiento aporta información SEO relevante
El tono importa: aunque Google no use la polaridad como factor directo, alinear el sentimiento con la intención de búsqueda mejora la pertinencia y reduce la tasa de rebote.
El sentimiento aporta insights: analizar reseñas o comentarios detecta fricciones reales que tu copy puede resolver.
Los tópicos revelan vacíos: LDA (o BERTopic) muestra sub-temas faltantes y evita la canibalización interna.
Con los prompts, scripts y comparativa anteriores puedes pasar de HTML a un dashboard de tono, emoción y temática en menos de una hora. ¡Que tus contenidos hablen el mismo idioma —y con la misma actitud— que tu audiencia!