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.
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"
).
- Conversión del texto a minúsculas: homogeneiza el texto (p. ej.
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.
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.
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.
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.
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_id | film_year | film_duration | film_title | film_original_title | film_movie_types | film_country | film_director | film_screenwriter | film_cast | ... | film_genre | film_synospsis | film_average_rating | film_number_of_ratings | film_pro_reviews_positive | film_pro_reviews_neutral | film_pro_reviews_negative | film_where_to_watch_sus | film_where_to_watch_buy | film_where_to_watch_ren | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | film100072 | 1993 | 123 | ¡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.8 | 33659 | 9 | 5 | 2 | ['Netflix', 'Netflix Standard with Ads'] | ['Google Play Movies', 'Apple TV', 'Rakuten TV... | ['Google Play Movies', 'Apple TV', 'Rakuten TV... |
1 | film100083 | 2014 | 120 | El caso sk1 | L'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.9 | 2074 | 0 | 0 | 0 | ['Amazon Prime Video'] | ['Google Play Movies', 'Apple TV', 'Rakuten TV'] | ['Google Play Movies', 'Apple TV', 'Rakuten TV'] |
2 | film100152 | 2017 | 97 | Soni | Soni | [] | 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.1 | 137 | 3 | 1 | 1 | ['Netflix', 'Netflix Standard with Ads'] | [] | [] |
3 | film100160 | 2022 | 60 | Dr. Stone: Ryusui | Dr. 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.0 | 251 | 0 | 0 | 0 | ['Netflix', 'Netflix Standard with Ads', 'Crun... | [] | [] |
4 | film100213 | 2019 | 92 | El Príncipe | El 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.6 | 433 | 7 | 1 | 0 | ['Filmin'] | [] | ['Rakuten TV'] |
5 rows × 23 columns
film_info.tail(5)
film_id | film_year | film_duration | film_title | film_original_title | film_movie_types | film_country | film_director | film_screenwriter | film_cast | ... | film_genre | film_synospsis | film_average_rating | film_number_of_ratings | film_pro_reviews_positive | film_pro_reviews_neutral | film_pro_reviews_negative | film_where_to_watch_sus | film_where_to_watch_buy | film_where_to_watch_ren | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
24364 | film999921 | 2023 | 81 | Juicio al diablo | The 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.4 | 386 | 1 | 1 | 4 | ['Netflix', 'Netflix Standard with Ads'] | [] | [] |
24365 | film999922 | 2017 | 100 | En tiempos de luz menguante | In 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.8 | 361 | 3 | 3 | 0 | ['Movistar Plus+ Ficción Total ', 'Filmin', 'A... | ['Rakuten TV'] | ['Rakuten TV', 'Acontra Plus'] |
24366 | film999930 | 1933 | 76 | Baby 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.1 | 703 | 0 | 0 | 0 | ['Filmin'] | [] | [] |
24367 | film999964 | 2017 | 75 | Bernabéu | Bernabé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.8 | 550 | 0 | 0 | 0 | ['Movistar Plus+ Ficción Total ', 'Movistar Pl... | [] | [] |
24368 | film999985 | 2018 | 47 | Nuestro Planeta | One 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.8 | 784 | 5 | 0 | 2 | ['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()
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()
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_id | review_id | author_id | author_n_ratings | author_n_reviews | author_rating | author_review_date | author_review_title | author_users_useful | author_users_useful_total | author_review_desc | author_review_spoiler | author_review_date_day | author_review_date_month | author_review_date_year | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | film100072 | 59720312 | 893253 | 5473 | 620 | 8 | 3 de abril de 2007 | Parte del grupo | 88 | 103 | \n¡Viven! siempre me cautivó... es una películ... | \nQuizás, al meterte tanto en el papel, se ech... | 3 | 4 | 2007 |
1 | film100072 | 21140139 | 369212 | 3449 | 1160 | 7 | 23 de diciembre de 2008 | Sobreviven! | 68 | 72 | \nCorrecto filme de Frank Marshall. Consigue t... | <NA> | 23 | 12 | 2008 |
2 | film100072 | 58679171 | 286896 | 2041 | 2036 | 6 | 9 de abril de 2010 | Si los 45 hubiesen sobrevivido al accidente...... | 55 | 59 | \nEsta peli debería ser de obligada visión, ol... | \n1- Si cuento una peli de uruguayos, es peno... | 9 | 4 | 2010 |
3 | film100072 | 97802796 | 535921 | 513 | 458 | 6 | 24 de febrero de 2008 | Vengo de un avión que cayó en las montañas; so... | 44 | 49 | \nProbablemente la novela de Piers Paul Read (... | <NA> | 24 | 2 | 2008 |
4 | film100072 | 21267714 | 275036 | 1718 | 25 | 5 | 2 de octubre de 2007 | "Nadie tiene amor más grande que el que da la ... | 50 | 62 | \nLa película no está mal, pero por el simple ... | \nEl final... no es final. Parrado y Canessa s... | 2 | 10 | 2007 |
film_reviews.tail(5)
film_id | review_id | author_id | author_n_ratings | author_n_reviews | author_rating | author_review_date | author_review_title | author_users_useful | author_users_useful_total | author_review_desc | author_review_spoiler | author_review_date_day | author_review_date_month | author_review_date_year | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
419822 | film792587 | 12371062 | 1953424 | 4 | 2 | 2 | 10 de agosto de 2021 | Mal llevado | 6 | 9 | \nLa película parte corriendo desesperada de l... | <NA> | 10 | 8 | 2021 |
419823 | film792587 | 20023618 | 8983399 | 7695 | 2531 | 7 | 14 de diciembre de 2019 | Actualizando un clásico | 12 | 22 | \nLa actriz Greta Gerwig después del éxito hac... | <NA> | 14 | 12 | 2019 |
419824 | film792587 | 50750931 | 104683 | 7250 | 837 | 7 | 29 de diciembre de 2019 | Las cuatro hermanitas | 12 | 22 | \nGreta Gerwig da por sentado que todo el mund... | <NA> | 29 | 12 | 2019 |
419825 | film792587 | 12809529 | 8237083 | 2405 | 79 | 9 | 3 de enero de 2020 | El valor de hacer cine | 12 | 22 | \nGreta Gerwig nos trae la enésima adaptación ... | <NA> | 3 | 1 | 2020 |
419826 | film792587 | 56916543 | 9940717 | 190 | 24 | 8 | 5 de enero de 2020 | Regocijante | 7 | 12 | \nSiendo esta la primera adaptación de Mujerci... | <NA> | 5 | 1 | 2020 |
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()
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_id | author_review_desc | author_rating | film_title | film_original_title | film_country | film_average_rating | film_number_of_ratings | |
---|---|---|---|---|---|---|---|---|
228347 | film481172 | \nPelícula costumbrista que pretende reflejar ... | 8 | La piel quemada | La piel quemada | España | 6.9 | 932 |
129460 | film320617 | \n1. Cree que esto es un documental?\n\n\nR. N... | 8 | Escuchando al juez Garzón | Escuchando al juez Garzón | España | 4.9 | 540 |
280304 | film564110 | \nResulta entretenida esta cinta de acción y a... | 6 | Rompiendo las reglas | Never Back Down | Estados Unidos | 5.6 | 8845 |
415591 | film781442 | \nTiempos duros los de estar confinados por cu... | 4 | El puente de Cassandra | The Cassandra Crossingaka | Alemania del Oeste (RFA) | 5.9 | 1898 |
15450 | film128090 | \nEn muchas ocasiones, al puntuar o realizar u... | 5 | Los Goonies | The Gooniesaka | Estados Unidos | 7.3 | 75107 |
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_desc | clean_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”).
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()
📍 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()
📍 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_review | clean_review_stemming | |
---|---|---|
384461 | un detective obsesionado con la caza de un ase... | detectiv obsesion caz asesin seri inici carrer |
165829 | conmovedora impactante y especialmente sorpren... | conmovedor impact especial sorprendent dav lyn... |
261238 | trece 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()
📍 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_review | clean_review_lemmatization | |
---|---|---|
384461 | un detective obsesionado con la caza de un ase... | detective obsesionado caza asesino serie inici... |
165829 | conmovedora impactante y especialmente sorpren... | conmovedora impactante especialmente sorprende... |
261238 | trece 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()
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
- NLTK: https://www.nltk.org/
- spaCy: https://spacy.io/
- Expresiones Regulares (Regex): https://docs.python.org/3/library/re.html
- https://matiasbattocchia.github.io/datitos/Preprocesamiento-de-texto-para-NLP-parte-1.html
- https://medium.com/product-ai/text-preprocessing-in-python-steps-tools-and-examples-bf025f872908
- https://github.com/prasanthg3/cleantext