Preprocesamiento de texto para NLP

Por Jose R. Zapata

Ultima actualización: 15/Oct/2025

Limpieza y normalización de texto en español para NLP

El proceso de limpieza y normalización de texto es un paso crucial en el procesamiento del lenguaje natural (NLP). Este proceso implica varias técnicas para preparar el texto original para su análisis, mejorando la calidad y la relevancia de los datos. De esta manera, se transforma el texto en lenguaje natural a un formato entendible para modelos de aprendizaje automático y algoritmos de NLP.

A continuación, se describen algunas de las técnicas más comunes utilizadas en el preprocesamiento de texto en español.

  1. Normalización: Se realiza para homogeneizar el texto y reducir la variabilidad. Esto incluye:

    • Conversión del texto a minúsculas: homogeneiza el texto (p. ej. "Casa""casa").
    • Corrección de errores tipográficos: se busca corregir errores comunes de escritura.
    • Convertir números a una representación estándar o eliminarlos: por ejemplo, transformar "dos" en "2".
    • Eliminar signos de puntuación: se eliminan signos de puntuación que no aportan significado, como .,;:¡!¿?.
    • Eliminar o reemplazar acentos: es habitual reemplazar las vocales acentuadas (á, é, í, ó, ú) por su versión sin tilde (a, e, i, o, u). Esto unifica términos que solo difieren en acentuación (por ejemplo “información” → “informacion”) y reduce el tamaño del vocabulario.
    • Manejo de contracciones y abreviaturas: expandir contracciones comunes (p. ej. “del” → “de el”) y abreviaturas (p. ej. "Sr.""Señor").
    • Manejo de emojis y emoticonos: dependiendo del análisis, se pueden eliminar o convertir en tokens específicos.
    • Eliminar espacios en blanco: se eliminan espacios adicionales al inicio, final y entre palabras (p. ej. " hola ""hola").
  2. Eliminación de ruido: Esto incluye la eliminación de caracteres no deseados, como HTML, etiquetas XML, o cualquier otro tipo de ruido que no aporte valor al análisis.

  3. Tokenización: Es el proceso de dividir el texto en unidades más pequeñas, llamadas tokens. En español, esto puede ser un desafío debido a la riqueza morfológica del idioma.

  4. Lematización y stemming: Estas técnicas buscan reducir las palabras a su forma base o raíz. La lematización considera el contexto y la gramática, mientras que el stemming simplemente corta los sufijos.

  5. Eliminación de stopwords: Las stopwords son palabras comunes que no aportan significado relevante al análisis, como “y”, “el”, “de”, etc. Su eliminación puede ayudar a mejorar la calidad del modelo.

  6. Extracción de características: Finalmente, se pueden extraer características relevantes del texto, como n-gramas, que pueden ser útiles para tareas de clasificación o análisis de sentimientos.

La elección de las técnicas adecuadas dependerá del objetivo específico del proyecto y de las características del conjunto de datos utilizado.

Análisis de Reseñas de filmaffinity

1) Carga y Exploración del Dataset 🤓

Objetivo: Exploración del Dataset.

import ast

import matplotlib.pyplot as plt
import pandas as pd

Importar dataset https://huggingface.co/datasets/naim-prog/filmaffinity-reviews-info

film_info = pd.read_parquet("hf://datasets/naim-prog/filmaffinity-reviews-info/film_info.parquet")

film_reviews = pd.read_parquet(
    "hf://datasets/naim-prog/filmaffinity-reviews-info/film_reviews.parquet"
)

Dataset Film info

film_info.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 24369 entries, 0 to 24368
Data columns (total 23 columns):
 #   Column                     Non-Null Count  Dtype
---  ------                     --------------  -----
 0   film_id                    24369 non-null  object
 1   film_year                  24369 non-null  int64
 2   film_duration              24369 non-null  int64
 3   film_title                 24369 non-null  object
 4   film_original_title        24369 non-null  object
 5   film_movie_types           24369 non-null  object
 6   film_country               24369 non-null  object
 7   film_director              24369 non-null  object
 8   film_screenwriter          24369 non-null  object
 9   film_cast                  24369 non-null  object
 10  film_music                 24369 non-null  object
 11  film_cinematography        24369 non-null  object
 12  film_producer              24369 non-null  object
 13  film_genre                 24369 non-null  object
 14  film_synospsis             24298 non-null  object
 15  film_average_rating        24369 non-null  float64
 16  film_number_of_ratings     24369 non-null  int64
 17  film_pro_reviews_positive  24369 non-null  int64
 18  film_pro_reviews_neutral   24369 non-null  int64
 19  film_pro_reviews_negative  24369 non-null  int64
 20  film_where_to_watch_sus    24369 non-null  object
 21  film_where_to_watch_buy    24369 non-null  object
 22  film_where_to_watch_ren    24369 non-null  object
dtypes: float64(1), int64(6), object(16)
memory usage: 4.3+ MB

Visualizamos las primeras filas del dataset

film_info.head(5)

film_idfilm_yearfilm_durationfilm_titlefilm_original_titlefilm_movie_typesfilm_countryfilm_directorfilm_screenwriterfilm_cast...film_genrefilm_synospsisfilm_average_ratingfilm_number_of_ratingsfilm_pro_reviews_positivefilm_pro_reviews_neutralfilm_pro_reviews_negativefilm_where_to_watch_susfilm_where_to_watch_buyfilm_where_to_watch_ren
0film1000721993123¡Viven!Alive![]Estados Unidos['Frank Marshall']['John Patrick Shanley', 'Piers Paul Read']['Ethan Hawke', 'Josh Hamilton', 'Vincent Span......['Aventuras', 'Drama', 'Basado en hechos reale...Basada en una historia real. Narra la terrible...6.833659952['Netflix', 'Netflix Standard with Ads']['Google Play Movies', 'Apple TV', 'Rakuten TV...['Google Play Movies', 'Apple TV', 'Rakuten TV...
1film1000832014120El caso sk1L'affaire SK1[]Francia['Frédéric Tellier']['David Oelhoffen', 'Frédéric Tellier']['Raphaël Personnaz', 'Nathalie Baye', 'Olivie......['Thriller', 'Basado en hechos reales', 'Crime...París, 1991. Franck Magne es un joven inspecto...5.92074000['Amazon Prime Video']['Google Play Movies', 'Apple TV', 'Rakuten TV']['Google Play Movies', 'Apple TV', 'Rakuten TV']
2film100152201797SoniSoni[]India['Ivan Ayr']['Ivan Ayr', 'Kislay Kislay']['Kalpana Jha', 'Saloni Batra', 'Vikas Shukla'......['Drama']Soni, una joven policía en Delhi, y su superin...6.1137311['Netflix', 'Netflix Standard with Ads'][][]
3film100160202260Dr. Stone: RyusuiDr. Stone: Ryusui['Episodio', 'Animación']Japón['Shûhei Matsushita']['Yuichiro Kido', 'Riichirou Inagaki'][]...['Animación', 'Acción', 'Fantástico', 'Ciencia...Especial de 1 hora para la televisión de "Dr. ...7.0251000['Netflix', 'Netflix Standard with Ads', 'Crun...[][]
4film100213201992El PríncipeEl príncipeaka[]Chile['Sebastián Muñoz']['Luis Barrales', 'Sebastián Muñoz']['Alfredo Castro', 'Juan Carlos Maldonado', 'G......['Drama', 'Años 70', 'Homosexualidad', 'Drama ...San Bernardo, Chile, justo antes que Allende a...5.6433710['Filmin'][]['Rakuten TV']

5 rows × 23 columns

film_info.tail(5)

film_idfilm_yearfilm_durationfilm_titlefilm_original_titlefilm_movie_typesfilm_countryfilm_directorfilm_screenwriterfilm_cast...film_genrefilm_synospsisfilm_average_ratingfilm_number_of_ratingsfilm_pro_reviews_positivefilm_pro_reviews_neutralfilm_pro_reviews_negativefilm_where_to_watch_susfilm_where_to_watch_buyfilm_where_to_watch_ren
24364film999921202381Juicio al diabloThe Devil on Trial['Documental']Reino Unido['Chris Holt']['Chris Holt']['Foster Hamilton', 'Adam Hunt', 'Arne Cheyenn......['Documental', 'Terror', 'True Crime', 'Posesi...Mediante recreaciones y vídeos caseros, este o...5.4386114['Netflix', 'Netflix Standard with Ads'][][]
24365film9999222017100En tiempos de luz menguanteIn Zeiten des abnehmenden Lichts[]Alemania['Matti Geschonneck']['Wolfgang Kohlhaase', 'Eugen Ruge']['Bruno Ganz', 'Alexander Fehling', 'Sylvester......['Drama']Berlín Oriental, otoño de 1989. Wilhelm Powile...5.8361330['Movistar Plus+ Ficción Total ', 'Filmin', 'A...['Rakuten TV']['Rakuten TV', 'Acontra Plus']
24366film999930193376Baby Face (Carita de ángel)Baby Faceaka[]Estados Unidos['Alfred E. Green']['Gene Markey', 'Kathryn Scola', 'Darryl F. Za...['Barbara Stanwyck', 'George Brent', 'Donald C......['Drama', 'Melodrama', 'Trabajo/empleo', 'Pelí...En una pequeña ciudad industrial, Lilly Powers...7.1703000['Filmin'][][]
24367film999964201775BernabéuBernabéu['Documental']España['Ignacio Salazar-Simpson']['Joaquín Andújar'][]...['Documental', 'Documental deportivo', 'Fútbol...Documental que repasa los 82 años de la vida d...6.8550000['Movistar Plus+ Ficción Total ', 'Movistar Pl...[][]
24368film999985201847Nuestro PlanetaOne Strange Rock['Serie', 'Documental']Estados Unidos['Nick Shoolingin-Jordan', 'Graham Booth', 'Ch...['Christopher Riley', 'Nick Shoolingin-Jordan']['Will Smith', 'Chris Hadfield', 'Mae C. Jemis......['Serie de TV', 'Documental', 'Documental cien...10 episodios. Presenta la extraordinaria histo...7.8784502['Disney Plus'][][]

5 rows × 23 columns

Distribución de las puntuaciones

plt.figure(figsize=(8, 6))
film_info["film_average_rating"].hist(bins=40, color="skyblue", edgecolor="black")

plt.title("Distribución de Puntuaciones")
plt.show()

png

Distribución de los géneros

# Revision del formato de la columna film_genre
film_info["film_genre"].loc[:3]
0    ['Aventuras', 'Drama', 'Basado en hechos reale...
1    ['Thriller', 'Basado en hechos reales', 'Crime...
2                                            ['Drama']
3    ['Animación', 'Acción', 'Fantástico', 'Ciencia...
Name: film_genre, dtype: object
def extraer_genero_principal(value: str) -> str | None:
    """Análisis_de_Reseñas__parte_1.ipynb

    Extrae el género principal de una cadena que representa una lista de géneros.

    Args:
        value (str): Cadena que representa una lista de géneros.

    Returns:
        str | None: El género principal si se encuentra, de lo contrario None.

    Example:
        >>> extraer_genero_principal("['Drama', 'Romance']")
        'Drama'
        >>> extraer_genero_principal("[]")
        None

    """
    if isinstance(value, str):
        try:
            # Convertir un string a una lista usando ast.literal_eval
            # cuando el string tiene formato de lista explicitamente
            generos = ast.literal_eval(value)
            if generos:
                return generos[0]
        except (ValueError, SyntaxError):
            pass
    return None


film_info["genre_principal"] = film_info["film_genre"].apply(extraer_genero_principal)
film_info["genre_principal"].value_counts()
genre_principal
Drama              5296
Serie de TV        4278
Comedia            3266
Documental         2449
Animación          1501
Thriller           1443
Terror             1344
Acción             1014
Ciencia ficción     706
Intriga             615
Romance             598
Aventuras           532
Fantástico          369
Western             337
Bélico              230
Musical             190
Cine negro          161
Infantil             40
Name: count, dtype: int64
genre_counts = film_info["genre_principal"].value_counts()
plt.figure(figsize=(12, 6))
bars = plt.bar(genre_counts.index, genre_counts.values, color="steelblue")

# Añadir etiquetas encima de las barras
for index, value in enumerate(genre_counts.values):
    plt.text(
        index,
        value + max(genre_counts.values) * 0.01,
        str(value),
        ha="center",
        va="bottom",
        fontsize=9,
    )
plt.xticks(rotation=45, ha="right")
plt.xlabel("Género principal")
plt.ylabel("Cantidad")
plt.title("Distribución de Géneros Principales")
plt.tight_layout()

png

Dataset Film Reviews

film_reviews.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 419827 entries, 0 to 419826
Data columns (total 15 columns):
 #   Column                     Non-Null Count   Dtype
---  ------                     --------------   -----
 0   film_id                    419827 non-null  string
 1   review_id                  419827 non-null  int32
 2   author_id                  419827 non-null  int32
 3   author_n_ratings           419827 non-null  int32
 4   author_n_reviews           419827 non-null  int32
 5   author_rating              419827 non-null  int64
 6   author_review_date         419827 non-null  string
 7   author_review_title        419827 non-null  string
 8   author_users_useful        419827 non-null  int16
 9   author_users_useful_total  419827 non-null  int16
 10  author_review_desc         419827 non-null  string
 11  author_review_spoiler      83134 non-null   string
 12  author_review_date_day     419827 non-null  Int16
 13  author_review_date_month   419827 non-null  Int16
 14  author_review_date_year    419827 non-null  Int16
dtypes: Int16(3), int16(2), int32(4), int64(1), string(5)
memory usage: 30.8 MB
film_reviews.head(5)

film_idreview_idauthor_idauthor_n_ratingsauthor_n_reviewsauthor_ratingauthor_review_dateauthor_review_titleauthor_users_usefulauthor_users_useful_totalauthor_review_descauthor_review_spoilerauthor_review_date_dayauthor_review_date_monthauthor_review_date_year
0film10007259720312893253547362083 de abril de 2007Parte del grupo88103\n¡Viven! siempre me cautivó... es una películ...\nQuizás, al meterte tanto en el papel, se ech...342007
1film1000722114013936921234491160723 de diciembre de 2008Sobreviven!6872\nCorrecto filme de Frank Marshall. Consigue t...<NA>23122008
2film100072586791712868962041203669 de abril de 2010Si los 45 hubiesen sobrevivido al accidente......5559\nEsta peli debería ser de obligada visión, ol...\n1- Si cuento una peli de uruguayos, es peno...942010
3film10007297802796535921513458624 de febrero de 2008Vengo de un avión que cayó en las montañas; so...4449\nProbablemente la novela de Piers Paul Read (...<NA>2422008
4film1000722126771427503617182552 de octubre de 2007"Nadie tiene amor más grande que el que da la ...5062\nLa película no está mal, pero por el simple ...\nEl final... no es final. Parrado y Canessa s...2102007
film_reviews.tail(5)

film_idreview_idauthor_idauthor_n_ratingsauthor_n_reviewsauthor_ratingauthor_review_dateauthor_review_titleauthor_users_usefulauthor_users_useful_totalauthor_review_descauthor_review_spoilerauthor_review_date_dayauthor_review_date_monthauthor_review_date_year
419822film79258712371062195342442210 de agosto de 2021Mal llevado69\nLa película parte corriendo desesperada de l...<NA>1082021
419823film79258720023618898339976952531714 de diciembre de 2019Actualizando un clásico1222\nLa actriz Greta Gerwig después del éxito hac...<NA>14122019
419824film792587507509311046837250837729 de diciembre de 2019Las cuatro hermanitas1222\nGreta Gerwig da por sentado que todo el mund...<NA>29122019
419825film79258712809529823708324057993 de enero de 2020El valor de hacer cine1222\nGreta Gerwig nos trae la enésima adaptación ...<NA>312020
419826film7925875691654399407171902485 de enero de 2020Regocijante712\nSiendo esta la primera adaptación de Mujerci...<NA>512020
year_counts = film_reviews["author_review_date_year"].value_counts().sort_index()
plt.figure(figsize=(12, 6))
bars = plt.bar(year_counts.index.astype(str), year_counts.values, color="steelblue")
for index, value in enumerate(year_counts.values):
    # Añadir etiquetas encima de las barras
    plt.text(
        index,
        value + max(year_counts.values) * 0.01,
        str(value),
        ha="center",
        va="bottom",
        fontsize=9,
    )
plt.xticks(rotation=45, ha="right")
plt.xlabel("Año de la reseña")
plt.ylabel("Cantidad de reseñas")
plt.title("Cantidad de Reseñas por Año")
plt.tight_layout()

png

2) Union y Filtrado de datasets

Objetivo: Unir los dos datasets para hacer analisis de los reviews y eliminar las columnas que no necesitamos

dataset_final = pd.merge(
    film_reviews,
    film_info,
    on="film_id",
    how="left",
)[
    [
        "film_id",
        "author_review_desc",
        "author_rating",
        "film_title",
        "film_original_title",
        "film_country",
        "film_average_rating",
        "film_number_of_ratings",
    ]
]
dataset_final.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 419827 entries, 0 to 419826
Data columns (total 8 columns):
 #   Column                  Non-Null Count   Dtype
---  ------                  --------------   -----
 0   film_id                 419827 non-null  object
 1   author_review_desc      419827 non-null  string
 2   author_rating           419827 non-null  int64
 3   film_title              419827 non-null  object
 4   film_original_title     419827 non-null  object
 5   film_country            419827 non-null  object
 6   film_average_rating     419827 non-null  float64
 7   film_number_of_ratings  419827 non-null  int64
dtypes: float64(1), int64(2), object(4), string(1)
memory usage: 25.6+ MB

Borrar datasets que no se van a usar, para liberar memoria

del (film_info, film_reviews)

Para que el análisis sea más manejable, vamos a quedarnos con 50000 reviews, ya que el procesamiento de 419_827 términos requiere mucho tiempo y recursos computacionales.

# Tomar solo 50000 registros aleatorios con fines de demostración
# Si quiere procesar todo el dataset, no ejecutar esta línea
dataset_final = dataset_final.sample(n=50_000, random_state=42)

3) Preprocesamiento Inicial y Limpieza del Texto 👌

Objetivo: Realizar un preprocesamiento con Regex (Expresiones Regulares).

Regex: Es una secuencia de caracteres que define un patrón de búsqueda.

Se utiliza para describir, identificar y manipular cadenas de texto de manera eficiente.

Con expresiones regulares, puedes buscar coincidencias, extraer subcadenas, reemplazar partes de un texto o validar formatos de datos.

Nos vamos a quedar solo con la data de interés

dataset_final.sample(5)

film_idauthor_review_descauthor_ratingfilm_titlefilm_original_titlefilm_countryfilm_average_ratingfilm_number_of_ratings
228347film481172\nPelícula costumbrista que pretende reflejar ...8La piel quemadaLa piel quemadaEspaña6.9932
129460film320617\n1. Cree que esto es un documental?\n\n\nR. N...8Escuchando al juez GarzónEscuchando al juez GarzónEspaña4.9540
280304film564110\nResulta entretenida esta cinta de acción y a...6Rompiendo las reglasNever Back DownEstados Unidos5.68845
415591film781442\nTiempos duros los de estar confinados por cu...4El puente de CassandraThe Cassandra CrossingakaAlemania del Oeste (RFA)5.91898
15450film128090\nEn muchas ocasiones, al puntuar o realizar u...5Los GooniesThe GooniesakaEstados Unidos7.375107

Evaluar valores nulos

dataset_final.isnull().sum()
film_id                   0
author_review_desc        0
author_rating             0
film_title                0
film_original_title       0
film_country              0
film_average_rating       0
film_number_of_ratings    0
dtype: int64

Limpiar el texto

import re
import string
def limpiar_texto(text: str) -> str:
    """Preprocesa el texto realizando los siguientes pasos:
    1. Convertir a minúsculas.
    2. Eliminar acentos de vocales.
    3. Eliminar textos entre corchetes (ej.: etiquetas).
    4. Eliminar URLs.
    5. Eliminar etiquetas HTML.
    6. Eliminar signos de puntuación.
    7. Eliminar saltos de línea.
    8. Eliminar palabras que contienen números.
    9. Eliminar emojis y caracteres especiales (no ASCII), excepto la ñ.
    10. Eliminar espacios extras entre palabras.
    11. Eliminar espacios extras al inicio y final.

    Args:
        text (str): El texto a procesar.
    Returns:
        str: El texto preprocesado.

    Example:
        >>> text_preprocess("¡Hola! Visita https://example.com para más info.")
        'hola visita para mas info'


    """
    # Convertir a minúsculas
    text = str(text).lower()

    # Eliminar acentos de vocales
    MAP_VOCALES = {
        "á": "a",
        "é": "e",
        "í": "i",
        "ó": "o",
        "ú": "u",
        "ü": "u",
    }
    translate = str.maketrans(MAP_VOCALES)
    text = text.translate(translate)

    # Eliminar textos entre corchetes (ej.: etiquetas)
    text = re.sub(r"\[.*?\]", "", text)

    # Eliminar URLs
    text = re.sub(r"https?://\S+|www\.\S+", "", text)

    # Eliminar etiquetas HTML
    text = re.sub(r"<.*?>+", "", text)

    # Eliminar signos de puntuación
    text = re.sub(f"[{re.escape(string.punctuation)}]", "", text)

    # Eliminar saltos de línea
    text = re.sub(r"\n", " ", text)

    # Eliminar palabras que contienen números
    text = re.sub(r"\w*\d\w*", "", text)

    # Eliminar emojis y caracteres especiales (no ASCII), excepto la ñ
    text = re.sub(r"[^\x00-\x7Fñ]+", "", text)

    # Eliminar espacios extras entre palabras
    text = re.sub(r"\s+", " ", text)

    # Eliminar espacios extras al inicio y final
    text = text.strip()

    return text
texto = "¡Hola! Feliz año nuevo. Visita https://example.com para más info. é í ó ú ñ ü"

limpiar_texto(texto)
'hola feliz año nuevo visita para mas info e i o u ñ u'

Aplicar la función de limpieza a la columna ‘review_body’

dataset_final["clean_review"] = dataset_final["author_review_desc"].apply(limpiar_texto)

Ejemplo de reseña original vs. limpia:

dataset_final[["author_review_desc", "clean_review"]].head(3)

author_review_descclean_review
384461\nUn detective obsesionado con la caza de un a...un detective obsesionado con la caza de un ase...
165829\nConmovedora, impactante y especialmente sorp...conmovedora impactante y especialmente sorpren...
261238\nTrece son los años que han transcurrido desd...trece son los años que han transcurrido desde ...

4) Tokenización y Segmentación de Reseñas 📊

Objetivo: Realizar la tokenización y segmentación (división en palabras y oraciones) utilizando nltk y spaCy con modelos adaptados al español.

Tokenización: Es el proceso de dividir el texto en unidades más pequeñas, llamadas tokens (por ejemplo, palabras o signos de puntuación).

Esto es esencial para análisis posteriores como el conteo de palabras o la vectorización.

Segmentación en Oraciones: Permite dividir el texto en oraciones, lo cual es útil para el análisis sintáctico y para preservar el contexto de cada enunciado.

📍 Con NLTK

NLTK es una biblioteca de Python muy popular para el procesamiento del lenguaje natural (NLP). Nos permite trabajar con texto de manera automatizada, como analizar la gramática, extraer información, o incluso generar texto nuevo.

Antes de empezar a realizar cualquier tarea de NLP con NLTK, es necesario descargar los recursos necesarios. El paquete ‘popular’ es un buen punto de partida, ya que proporciona las herramientas más comunes y utilizadas en NLP para realizar una amplia gama de tareas, como:

  • Tokenización: Dividir texto en tokens (palabras, signos de puntuación, etc.).
  • Lematización: Reducir las palabras a su forma raíz.
  • Part-of-speech tagging: Asignar una etiqueta gramatical a cada palabra (sustantivo, verbo, adjetivo, etc.).
  • Parsing: Analizar la estructura gramatical de una oración.

El paquete ‘popular’ incluye varios corpus y modelos pre-entrenados que son útiles para estas y otras tareas.

import nltk
from nltk.tokenize import sent_tokenize, word_tokenize

Descargar recursos de tokenización para español

nltk.download("punkt_tab")
[nltk_data] Downloading package punkt_tab to /home/joser/nltk_data...
[nltk_data]   Package punkt_tab is already up-to-date!





True

Seleccionar un ejemplo de reseña limpia

sample_text = dataset_final["clean_review"].iloc[0]
print("Texto de ejemplo:", sample_text)
Texto de ejemplo: un detective obsesionado con la caza de un asesino en serie desde los inicios de su carrera

Tokenización y segmentación con nltk (para español)

tokens_nltk = word_tokenize(sample_text, language="spanish")
sentences_nltk = sent_tokenize(sample_text, language="spanish")
print("Tokens con nltk:", tokens_nltk)
print("Oraciones con nltk:", sentences_nltk)
Tokens con nltk: ['un', 'detective', 'obsesionado', 'con', 'la', 'caza', 'de', 'un', 'asesino', 'en', 'serie', 'desde', 'los', 'inicios', 'de', 'su', 'carrera']
Oraciones con nltk: ['un detective obsesionado con la caza de un asesino en serie desde los inicios de su carrera']

📍 Con spaCy

import spacy

instalar el modelo en español https://spacy.io/models/es#es_core_news_sm, se puede mediante de la ejecución de cualquiera de estos dos comandos en terminal:

  • uv add https://github.com/explosion/spacy-models/releases/download/es_core_news_sm-3.8.0/es_core_news_sm-3.8.0-py3-none-any.whl
  • python3 -m spacy download es_core_news_sm

Prefiero usar la primera opción, ya que así queda instalado en el entorno virtual del proyecto y ayuda a la reproducibilidad.

nlp_es = spacy.load("es_core_news_sm")
doc_es = nlp_es(sample_text)
tokens_spacy = [token.text for token in doc_es]
sentences_spacy = [sent.text for sent in doc_es.sents]
print("Tokens con spaCy:", tokens_spacy)
print("Oraciones con spaCy:", sentences_spacy)
Tokens con spaCy: ['un', 'detective', 'obsesionado', 'con', 'la', 'caza', 'de', 'un', 'asesino', 'en', 'serie', 'desde', 'los', 'inicios', 'de', 'su', 'carrera']
Oraciones con spaCy: ['un detective obsesionado con la caza de un asesino en serie desde los inicios de su carrera']

5) Eliminación de Stopwords: 🧹

Objetivo: Eliminar las palabras vacías (también conocidas como stop words). Estas son palabras comunes, que no aportan mucho significado a nuestro análisis.

Se utiliza para eliminar palabras muy comunes que no aportan información relevante (como “el”, “de”, “y”).

Al eliminar las palabras vacías, podemos enfocarnos en las palabras más relevantes y significativas de nuestro texto. Esto nos ayudará a obtener resultados más precisos y relevantes en nuestros análisis.

from nltk.corpus import stopwords

Descargar recursos necesarios en español

nltk.download("stopwords")
stopword_es = set(stopwords.words("spanish"))
[nltk_data] Downloading package stopwords to /home/joser/nltk_data...
[nltk_data]   Package stopwords is already up-to-date!
# Eliminar stopwords
tokens_no_stop = [word for word in tokens_nltk if word not in stopword_es]
print("Tokens originales:", tokens_nltk)
print("Tokens sin stopwords:", tokens_no_stop)
Tokens originales: ['un', 'detective', 'obsesionado', 'con', 'la', 'caza', 'de', 'un', 'asesino', 'en', 'serie', 'desde', 'los', 'inicios', 'de', 'su', 'carrera']
Tokens sin stopwords: ['detective', 'obsesionado', 'caza', 'asesino', 'serie', 'inicios', 'carrera']

6) Stemming y Lematización para Reseñas 📚

Objetivo: Refinar el preprocesamiento aplicando eliminación de stopwords, stemming y lematización para ver su efecto en las reseñas.

Stemming: Consiste en recortar las palabras para reducirlas a su raíz (por ejemplo, “comprando” a “compr”).

Sin embargo, este método puede producir resultados poco legibles y perder matices semánticos.

Lematización: Reduce las palabras a su forma canónica (o lema), preservando mejor el significado (por ejemplo, “comprando” se convierte en “comprar”).

Stemming vs Lematización

from nltk.stem import SnowballStemmer
# Seleccionar un ejemplo de reseña limpia
sample_text = dataset_final["clean_review"].iloc[0]
print("Texto de ejemplo:", sample_text)

# Tokenización con nltk
tokens = word_tokenize(sample_text, language="spanish")

print("Tokens:", tokens)
Texto de ejemplo: un detective obsesionado con la caza de un asesino en serie desde los inicios de su carrera
Tokens: ['un', 'detective', 'obsesionado', 'con', 'la', 'caza', 'de', 'un', 'asesino', 'en', 'serie', 'desde', 'los', 'inicios', 'de', 'su', 'carrera']
# Definir el stemmer para español
stemmer_es = SnowballStemmer("spanish")

# Aplicar stemming
stemmed_tokens = [stemmer_es.stem(token) for token in tokens_no_stop]
print("Tokens después de stemming:", stemmed_tokens)

# Aplicar lematización usando spaCy
doc_es = nlp_es(sample_text)
lemmatized_tokens = [token.lemma_ for token in doc_es if token.text.lower() not in stopword_es]
print("Tokens después de lematización (sin stopwords):", lemmatized_tokens)
Tokens después de stemming: ['detectiv', 'obsesion', 'caz', 'asesin', 'seri', 'inici', 'carrer']
Tokens después de lematización (sin stopwords): ['detective', 'obsesionado', 'caza', 'asesino', 'serie', 'inicio', 'carrera']

7) Visualización y Análisis Exploratorio del Texto 👀

Objetivo: Visualizar la frecuencia de términos mediante nubes de palabras.

import matplotlib.pyplot as plt
from wordcloud import WordCloud

📍 Reseñas Limpias

Contiene palabras comunes (stopwords) que no aportan valor que deberían ser eliminadas.

# Generar nube de palabras con las reseñas limpias
text = " ".join(review for review in dataset_final["clean_review"])
wordcloud = WordCloud(background_color="white", width=700, height=400).generate(text)

plt.figure(figsize=(15, 10))
plt.imshow(wordcloud, interpolation="bilinear")
plt.axis("off")
plt.title("Reseñas Limpias con todas las palabras")
plt.show()

png

📍 Reseñas Limpias sin Stopwords

# Generar nube de palabras con las reseñas limpias sin stopwords
text = " ".join(review for review in dataset_final["clean_review"])
wordcloud = WordCloud(
    stopwords=stopword_es, background_color="white", width=700, height=400
).generate(text)

plt.figure(figsize=(15, 10))
plt.imshow(wordcloud, interpolation="bilinear")
plt.axis("off")
plt.title("Reseñas Limpias sin Stopwords")
plt.show()

png

📍 Reseñas Limpias sin Stopwords + Stemming

def clean_stopwords_and_stemming(text: str) -> str:
    """Elimina stopwords y aplica stemming al texto dado.
    Args:
        text (str): El texto a procesar.
    Returns:
        str: El texto procesado sin stopwords y con stemming aplicado.
    Example:
        >>> clean_stopwords_and_stemming("Este es un ejemplo de texto para procesar.")
        'este ejempl text proces'
    """
    # Eliminar stopwords
    text = " ".join([word for word in text.split() if word not in stopword_es])
    # Aplicar stemming
    text = " ".join([stemmer_es.stem(word) for word in text.split()])
    # Eliminar espacios extras al inicio y final
    return text.strip()
clean_stopwords_and_stemming("Este es un ejemplo de texto para procesar.")
'este ejempl text procesar.'
dataset_final["clean_review_stemming"] = dataset_final["clean_review"].apply(
    clean_stopwords_and_stemming
)

# Mostrar ejemplos de texto limpio vs texto limpio avanzado
dataset_final[["clean_review", "clean_review_stemming"]].head(3)

clean_reviewclean_review_stemming
384461un detective obsesionado con la caza de un ase...detectiv obsesion caz asesin seri inici carrer
165829conmovedora impactante y especialmente sorpren...conmovedor impact especial sorprendent dav lyn...
261238trece son los años que han transcurrido desde ...trec años transcurr busc nem pelicul cult marc...
# Generar nube de palabras con las Reseñas Limpias sin Stopwords + Stemming
text = " ".join(review for review in dataset_final["clean_review_stemming"])
wordcloud = WordCloud(
    stopwords=stopword_es, background_color="white", width=700, height=400
).generate(text)

plt.figure(figsize=(15, 10))
plt.imshow(wordcloud, interpolation="bilinear")
plt.axis("off")
plt.title("Reseñas Limpias sin Stopwords + Stemming")
plt.show()

png

📍 Reseñas Limpias sin Stopwords + Lemmatization

def clean_stopwords_and_lemmatization(series: pd.Series) -> pd.Series:
    """
    Elimina stopwords y aplica lematización a una serie de pandas.

    Args:
        series (pd.Series): Serie de pandas con textos a procesar.
    Returns:
        pd.Series: Serie de pandas con textos procesados.
    Example:
        >>> import pandas as pd
        >>> sample_series = pd.Series(["Este es un ejemplo de texto para procesar."])
        >>> clean_stopwords_and_lemmatization(sample_series)
        0    ejemplo texto procesar
        dtype: object
    """

    docs = nlp_es.pipe(
        series.astype(str),
        disable=["parser", "ner"],
        batch_size=256,
        n_process=4,
    )
    cleaned = []
    for doc in docs:
        cleaned.append(
            " ".join(token.lemma_ for token in doc if token.text.lower() not in stopword_es).strip()
        )
    return pd.Series(cleaned, index=series.index)


dataset_final["clean_review_lemmatization"] = clean_stopwords_and_lemmatization(
    dataset_final["clean_review"]
)
# Mostrar ejemplos de texto limpio vs texto limpio con lematización
dataset_final[["clean_review", "clean_review_lemmatization"]].head(3)

clean_reviewclean_review_lemmatization
384461un detective obsesionado con la caza de un ase...detective obsesionado caza asesino serie inici...
165829conmovedora impactante y especialmente sorpren...conmovedora impactante especialmente sorprende...
261238trece son los años que han transcurrido desde ...trece año transcurrir buscar nemo pelicula cul...
# Generar nube de palabras con las Reseñas Limpias + Stopwords + Lemmatization
text = " ".join(review for review in dataset_final["clean_review_lemmatization"])
wordcloud = WordCloud(
    stopwords=stopword_es, background_color="white", width=800, height=400
).generate(text)

plt.figure(figsize=(15, 10))
plt.imshow(wordcloud, interpolation="bilinear")
plt.axis("off")
plt.title("Reseñas Limpias sin Stopwords + Lemmatization")
plt.show()

png

8) Guardar dataset limpio 💾

Objetivo: Este dataset nos va a servir en etapas posteriores.

dataset_final.to_parquet("filmaffinity_reviews_cleaned.parquet", index=False)

Librerías Necesarias

from watermark import watermark

print(watermark(python=True, iversions=True, globals_=globals()))
Python implementation: CPython
Python version       : 3.12.11
IPython version      : 9.5.0

watermark : 2.5.0
nltk      : 3.9.1
pandas    : 2.3.2
matplotlib: 3.10.6
spacy     : 3.8.7
wordcloud : 1.9.4
re        : 2.2.1

Referencias

Jose R. Zapata