Carga y procesamiento de textos

📦Preparando los paquetes

Para seguir este tutorial por primera vez, deberás instalar una serie de paquetes1 que emplearemos: quanteda, readtext, dplyr y stringr. Esto se hace a través del comando: install.packages():

install.packages(c("quanteda", "readtext", "dplyr", "stringi", "quanteda.textplots", "ggplot2", "stringr", "tm", "topicmodels"))

¿Para qué sirve cada uno?

Paquete Descripción
quanteda Paquete de análisis de textos, incluyendo tokenización, conteo y limpieza de textos
readtext Permite importar archivos de texto en varios formatos, facilitando la carga de datos
dplyr Herramienta para manipulación y transformación de datos, útil para filtrar y organizar datos
stringi Conjunto de funciones para trabajar con texto, especialmente útil para limpieza y manejo de expresiones regulares2
quanteda.textplots Extensión de quanteda para crear visualizaciones como nubes de palabras y gráficos de dispersión léxica.
ggplot2 Paquete de visualización de datos que permite crear gráficos personalizados y atractivos.
stringr Facilita la manipulación de cadenas de texto con funciones simples y potentes, parte del tidyverse.
tm Paquete clásico para el análisis de texto y minería de textos, incluye herramientas para el procesamiento de textos y la creación de matrices de documentos.
topicmodels Se utiliza para el modelado de temas latentes, permitiendo ajustar modelos como LDA para identificar patrones temáticos.

¿Sabías qué…? ️🤓☝

La tokenización es el proceso de dividir un texto en unidades más pequeñas llamdas tokens. Estas unidades pueden ser palabras, símbolos, frases o incluso caracteres dependiendo del tipo de análisis que se vaya a realizar. En quanteda consideramos la palabra como la unidad mínima de trabajo. Imagínate que tienes la siguiente oración:

“Hola, ¿cómo estás?”

La tokenización de esta frase podría dar como resultado los siguientes tokens: “Hola”, “¿”, “cómo”, “estás”, “?”

Esto es especialmente útil cuando estamos trabajando con estudios relacionados con frecuencias de palabras.

📄➡️🖥️Importación de datos de texto

Vamos a vincular el archivo de texto en la aplicación de RStudios. Para ello vamos a cargar los paquetes que hemos instalado anteriormente con el comando library("name")

library(quanteda)
Package version: 4.1.0
Unicode version: 15.1
ICU version: 74.1
Parallel computing: 8 of 8 threads used.
See https://quanteda.io for tutorials and examples.
library(readtext)

Adjuntando el paquete: 'readtext'
The following object is masked from 'package:quanteda':

    texts
library(dplyr)

Adjuntando el paquete: 'dplyr'
The following objects are masked from 'package:stats':

    filter, lag
The following objects are masked from 'package:base':

    intersect, setdiff, setequal, union
library(stringi)

Es importante resaltar que, si no llamamos antes el paquete, los comandos que introduzcamos después no funcionarán o nos darán error. Asegúrate de cargar siempre la librería antes de empezar a trabajar.

Una vez cargados, el programa estará listo para leer nuestro archivo de texto. La formula que vamos a escribir para decirle a quanteda que archivo analizar será el siguiente:

navalny_raw <- as.character(readtext("NAVALNY.txt"))

❗ATENCIÓN: Si por alguna razón hiciesemos algún cambio en el contenido del archivo, deberemos de aplicar el paso anterior de nuevo. Cuando cargamos un archivo en R, se guarda una copia y cualquier cambio en el original no se refleja automáticamente.

🤔¿Qué hemos hecho?

Este comando carga el archivo NAVALNY.txt en el objeto navalny_raw, el cual contiene el contenido del texto. Vamos a desgranar este prompt para que pueda entenderse más facil:

  • navalny_raw es un objeto en R que almacena el texto como una cadena de caracteres (character vector). En R hablamos de objetos para referirnos a los contenedores donde almacenamos datos e información. En el caso anterior, el objeto data_char_navalny almacena el texto plano que vamos a utilizar. Existen distintos objetos con diferentes datos almacenados: matrices, números, listas jerarquizadas, así como un sin fin de combinaciones. A lo largo de este caso práctico trabajaremos con ellos para gestionar más facilmente el análisis cuantitativo.

  • <- emula a una flecha y básicamente indica la dirección de la acción. Al objeto navalny_raw estamos aplicándole una función

  • as.character(): Se trata de la función que estamos aplicando. Esta función convierte todo lo que se contiene dentro de ella en carácter. En R toda función viene seguida por unos paréntesis dentro de los cuáles se incluyen los parámetros de dicha función. Si no hay contenido, se aplican los parámetros que la función trae por defecto.

  • readtext: Aquí estamos empleando una función expecífica del paquete readtext. Si no hubiésemos cargado el paquete anteriormente, podríamos invocarlo específicamente para activar esta función con la notación paquete::función. Aquí, lo que le estamos diciendo a RStudio es que, del paquete readtext, aplique específicamente la función lectura que casualmente también se llama readtext . Dentro indicamos entrecomillada la ruta del archivo a importar. Un problema muy común que puede surgir a la hora de introducir la URL de la ubicación del archivo es expresarlo con barras laterales izquierdas ” \ “, tal y como viene en la barra de dirección del explorador de archivos de Windows, en vez de la derecha” / “. Si tienes problemas leer tu .txt ¡prueba con hacer este cambio!

  • names(navalny_raw) <- "navalny": Asigna un nombre al objeto que contiene el texto, facilitando su identificación en futuros análisis.

🔍 Comprobaciones

En el análisis cuantitativo toda precaución es poca. Vamos a verificar que el texto ha sido leído por el programa. Usaremos el paquete stringi para ver los primeros 75 caracteres de nuestro archivo y confirmar que los datos se cargaron bien.

# Comprobar los primeros 75 caracteres del texto
stri_sub(navalny_raw, 1, 75)
[1] "2022\nJanuary 17th\nExactly one year ago today I came home, to Russia.\nI didn"

Si hemos hecho los pasos bien, tendréis que haber recibir este texto de vuelta:

[1] "2022\nJanuary 17th\nExactly one year ago today I came home, to Russia.\nI didn"

🗃️Acotando el texto a analizar

Nuestro siguiente objetivo es seleccionar qué partes del texto vamos aplicar el análisis cuantitativo. Puede que nuestro foco de interés sea algun apartado concreto de nuestro material, por lo que vamos a crear un objeto que albergue un rango determinado dentro de nuestro fichero txt . Con esto, nos quitaremos toda la información innecesaria que puede ensuciar nuestros resultados.

El proceso que vamos a realizar a continuación es muy útil cuando los archivos que manejamos tienen ligados metadatos. Normalmente, esta metadata suele ser más un dolor de cabeza que otra cosa y es recomendable realizar una limpieza previa para que esos datos no se mezclen con el contenido de nuestro análisis. En este apartado seguiremos trabajando con el paquete stringi.

Si el texto contiene secciones que no necesitamos para el análisis, podemos filtrarlas o limpiarlas en esta etapa.

PASO 1: Identificación de comienzo y fin del texto

Para crear el objeto que albergue el rango de texto a analizar deberemos empezar indicando donde empieza y termina nuestra selección. Imaginemos que sólo nos interesa analizar los últimos años de vida de Navalny. Para ello, crearemos dos valores de posición: start_v y end_v, donde start_ será: “2023, January 12th” y end_v “Alexei Navalny died”.

Localizado el rango que queremos, la forma de expresarlo en el programa sería el siguiente:

(start_v <- stri_locate_first_fixed(navalny_raw, "2023\nJanuary 12th")[1])
[1] 23654
(end_v <- stri_locate_last_fixed(navalny_raw, "Alexei Navalny died")[1])
[1] 44099

Si lo hemos aplicado bien, la función debería de devolver los siguientes resultados

  • Para start_value

    [1] 23654
  • Para end_value

    [1] 44099

🤔¿Qué hemos hecho?

  • Tanto start_v como end_v son nombres que hemos asignado a la posición específicas del texto. En sí, no significan nada. Solo decimos, a través de “<-” que dichos nombres albergan una función de posicionamiento.

  • Las funciones del paquete stringi: stri_locate_first_fixed y stri_locate_last_fixed buscan y encuentran la primera coincidencia del valor entrecomillado que precede a nuestro objeto data_char_NALVANY

  • El [1] es un indicador que le estamos dando a la función para que escoja la primera posición donde aparezca el texto que hayamos escogido.

  • Así, cuando vemos devuelto las respuestas [1] 23653 y [1] 44141 quiere decir que para start_v y end_v está asignado el primer valor donde aparece dichas expresiones , localizadas por primera vez en la posición 23653 y 44141 de nuestro texto.

PASO 2: Nuevo objeto

Creado nuestro punto de inicio y final de nuestra zona de trabajo, haremos un objeto que alberge dicho rango. Lo llamaremos: navalny_fix

navalny_fix <- stri_sub(navalny_raw, start_v, end_v)
length(navalny_fix)
[1] 1

Al iniciar el código el valor que os ha tenido que recuperar, además de almacenar el objeto en la pestaña Environment de RStuido, es:

[1] 1

🤔¿Qué hemos hecho?

  • navalny_fix es el nombre del objeto que almacena la función que ha sido asignada. En este caso, a través de stri_sub, estamos extrayendo una parte del texto navalny_fix . A diferencia del caso anterior, aquí le estamos pidiendo que, en vez de que recuper un número detemrinado de caracteres, escoja todos los que hay comprendidos entre las posiciones que hemos dado a start_v y a end_v. Con esto nos aseguramos que el objeto navalny_fix siempre trabaje en los rangos que nos interesa analizar.

  • length(navalny_fix) es una expresión que usamos para comprobar cuandos valores existen en nuestro objeto. Es una forma de asegurarnos de que nuestro objeto solo tiene un vector y no es un conjunto de fragmentos de texto. Por eso, al introducirlo, el programa nos devuelve el valor 1 porque solo hay 1 valor dentro de nuestro objeto.

🗑️ Limpiando datos

El fichero de texto con el que estamos trabajando ya está libre de ruido, por lo que continuaremos el resto del ejemplo trabajando con el objeto navalny_raw.

Ya tenemos nuestro set de datos, nos toca empezar a limpiar antes de tokenizar y empezar a analizar.

PASO 1: Comprobar la estructura del texto

Vamos a abrir un momento nuestro archivo para ver lo que contiene. Aquí te enseño las primeras líneas:

2022
January 17th
Exactly one year ago today I came home, to Russia.
I didn’t manage to take a single step on the soil of my country as a free man: I
was arrested even before border control.

The hero of one of my favorite books,“Resurrection,” by Leo Tolstoy, says,“Yes,
the only suitable place for an honest man in Russia at the present time is
prison.”

It sounds fine, but it was wrong then, and it’s even more wrong now.
There are a lot of honest people in Russia—tens of millions. There are far more
than is commonly believed.

The authorities, however, who were repugnant then and are even more so now,

Aquí queda más clara la estructura del texto:

  • Cada entrada del diario empieza con el año en la primera línea

  • Dentro de cada año, se dividen por días las entradas

  • Los párrafos están separados por saltos de línea

  • Las entradas están separadas entre sí también por una linea en blanco.

A continuación lo que vamos a hacer es crear un objeto lista3, en la que cada elemento será una entrada del diario.

PASO 2: Conversión del texto en vectores

Ahora que comprendemos la estructura, vamos a separar el texto en entradas diarias. Primero dividimos el texto en líneas, donde cada línea se convierte en un elemento de un vector. Esto nos permite identificar las líneas que contienen fechas y separar las entradas.

# Convertir el texto en un vector de líneas
lines <- unlist(strsplit(navalny_raw, '\n')) # \n indica un salto de línea

🤔¿Qué hemos hecho?

  • strsplit() divide el texto por saltos de línea ("\n"), creando un vector en el que cada línea es un elemento independiente.

  • Usamos unlist() para simplificar la estructura y trabajar con un vector plano.

head(lines) # Veamos las primeras seis líneas de nuestro objeto
                                                                      NAVALNY.txt1 
                                                                            "2022" 
                                                                      NAVALNY.txt2 
                                                                    "January 17th" 
                                                                      NAVALNY.txt3 
                              "Exactly one year ago today I came home, to Russia." 
                                                                      NAVALNY.txt4 
"I didn’t manage to take a single step on the soil of my country as a free man: I" 
                                                                      NAVALNY.txt5 
                                        "was arrested even before border control." 
                                                                      NAVALNY.txt6 
                                                                                "" 

PASO 3: Creación de índices para identificar entradas

En este paso, vamos a crear los índices que nos permitirán identificar las líneas en el texto que corresponden a cada año y a cada día. Esto nos ayudará a estructurar las entradas en el próximo paso.

  1. Utilizando expresiones regulares, vamos a identificar las líneas que contengan sólo el año. Estas líneas marcan el inicio de cada conjunto de entradas anuales
year_indices <- grep("^\\d{4}$", lines)

print(year_indices) # Muestra las líneas que contienen el año
[1]   1 420 714
  1. Hemos identificado tres líneas que incluyen el año. Ahora haremos lo mismo para las líneas que encuentren el mes y el día
day_indices <- grep("^(January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2}(st|nd|rd|th)?$", lines)

length(day_indices) # Número de entradas identificadas
[1] 16

🤔¿Qué hemos hecho?

  • year_indices contiene los índices de las líneas con los años, es decir, las posiciones donde comienza cada año en el texto. La expresión regular ^\\d{4} busca cuatro dígitos al inicio de la línea.

  • month_day_indices contiene los índices de las líneas con fechas diarias, indicando el inicio de cada día dentro de los años.

    • (January|February|...): Busca un mes escrito en inglés.

    • \\s+: Representa uno o más espacios.

    • \\d{1,2}(st|nd|rd|th)?$: Busca el día, que tendrá uno o dos dígitos y que puede ir seguido de “st”, “nd”, “rd”, o “th” al final de la línea.

PASO 4: Creación de entradas del diario

Ahora que tenemos los índices para los años y días, podemos organizar el texto en entradas anidadas: cada año será un grupo principal, y dentro de cada año, cada día será una entrada individual.

El siguiente código es un poco tocho, te intento explicar:

  1. Creamos las entradas por año. Para esto utilizamos el objeto year_indices que construimos antes.
  2. Creamos sublistas dentro de cada año con nuestro objeto month_day_indices.
# Dividimos el texto en entradas anidadas (por año y día)
yearly_entries <- lapply(
  seq_along(year_indices), 
  function(i) {
    start_year <- year_indices[i]
    end_year <- if (i < length(year_indices))
      year_indices[i + 1] - 1
    else
      length(lines)
    
    # Extraemos las líneas correspondientes al año actual
    year_lines <- lines[start_year:end_year]
    
    # Encontrar las entradas diarias dentro del año actual
    day_indices <- grep(
      "^(January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2}(st|nd|rd|th)?$",
      year_lines
      )
    
    # Crear una sublista para cada día dentro del año
    entries <- lapply(seq_along(day_indices), function(j) {
      start_day <- day_indices[j]
      end_day <- if (j < length(day_indices))
        day_indices[j + 1] - 1
      else
        length(year_lines)
      year_lines[start_day:end_day]
      })
  
  # Devolver una lista con el año y sus entradas diarias
  list(year = year_lines[1], entries = entries)
})

🤔¿Qué hemos hecho?

  • Cada elemento en yearly_entries es un año completo

    print(yearly_entries[[1]]$year) # primer año
    NAVALNY.txt1 
          "2022" 
  • Dentro de cada año, entries contiene las entradas diarias como sublistas, donde cada día es un vector de párrafos

    length(yearly_entries[[1]]$entries)
    [1] 5

Para ello, hemos combinado diferentes funciones en un sólo script:

  • lapply() sirve para aplicar una función a cada elemento de un vector o lista. En nuestro caso, itera sobre cada índice de year_indices, procesando el texto correspondiente a cada ño. Genera una lista yearly_indicesdonde cada elemento representa un año y sus entradas diarias. La segunda vez que la empleo es para crear sublistas para cada día dentro del año, aplicándola sobre day_indices.

  • seq_along() genera una secuencia de números que corresponden a la longitud de un vector o lista. Lo utilizo para generar una secuencia de índices a iterar sobre los años y sobre los días dentro de cada año.

  • grep() busca patrones específicos dentro de un vector, tal y como hicimos antes.

  • ifdentro de lapply()define los límites de inicio y fin de cada año.

  • list() crea la lista que almacena todas las entradas.

🗿 ¡A la tokenización!

Ya tenemos nuestro texto bien organizado y estructurado. Toca dividir aún más y limpiar. Para ello vamos a hacer lo siguiente:

  1. Convertimos el texto en minúsculas y eliminamos los signos de puntuación
  2. Tokenizamos el texto dividiéndolo en palabras. De manera que nuestra unidad de análisis será la palabra4.
  3. Eliminamos todas las palabras vacías5 para quedarnos sólo con aquellas relevantes para nuestro análisis.

PASO 1: Texto en minúscula y puntuación fuera

Para asegurarnos que palabras idénticas no se traten como diferentes por su formato, converitmos todo el texto a minúsculas y eliminamos puntuación.

# Convertir cada entrada diaria a minúsculas y eliminar signos de puntuación
yearly_entries <- lapply(yearly_entries, function(year) {
  year$entries <- lapply(year$entries, function(entry) {
    # Convertir el texto a minúsculas y eliminar puntuación
    entry <- char_tolower(entry)
    entry <- gsub("[[:punct:]]", "", entry)
    entry
  })
  year  # Devolver la lista de año modificada
})

🤔¿Qué hemos hecho?

  • La función char_tolower() convierte el texto en minúsculas.

  • La función gsub() sustituye un patrón de texto por otro. En nuestro caso, le hemos pedido que busque cualquier signo de puntuación empleando la expresión regular [[:punct:]] y la reemplace por nada.

  • Después hemos pedido que incluya estos cambios nuevamente en nuestro objeto yearly_entries.

Si observas las primeras líneas de una entrada, verás que todo ha funcionado tal y como esperábamos:

                                                                    NAVALNY.txt2 
                                                                  "january 17th" 
                                                                    NAVALNY.txt3 
                              "exactly one year ago today i came home to russia" 
                                                                    NAVALNY.txt4 
"i didnt manage to take a single step on the soil of my country as a free man i" 
                                                                    NAVALNY.txt5 
                                       "was arrested even before border control" 
                                                                    NAVALNY.txt6 
                                                                              "" 

PASO 2: Tokenización

Para que el programa pueda analizar y realizar manipulaciones sobre las palabras de forma individualizada, vamos a convertir a cada una de ellas en pequeños valores que llamamos tokens.

# Tokenizar cada entrada diaria dentro de cada año
yearly_entries <- lapply(yearly_entries, function(year) {
  year$entries <- lapply(year$entries, function(entry) {
    tokens(entry, what = "word")
  })
  year  # Devolver la lista de año modificada
})

🤔¿Qué hemos hecho?

  • Siguiendo la misma estructura de la vez anterior, hemos incorporado la función tokens() y lo hemos aplicado al objeto entry

  • Además, hemos utilizado el parámetro what= en el que indicamos el nivel. En nuestro caso tokenizamos por palabras. Otras opciones son por caracteres (character) y frases (sentence).

Ahora en lugar de contener un listado de filas por entrada, lo que tengo es una bolsa de palabras:

Tokens consisting of 3 documents.
NAVALNY.txt2 :
[1] "january" "17th"   

NAVALNY.txt3 :
 [1] "exactly" "one"     "year"    "ago"     "today"   "i"       "came"   
 [8] "home"    "to"      "russia" 

NAVALNY.txt4 :
 [1] "i"      "didnt"  "manage" "to"     "take"   "a"      "single" "step"  
 [9] "on"     "the"    "soil"   "of"    
[ ... and 7 more ]

PASO 3: Eliminación de palabras vacías

Para centrarnos en las palabras significativas, eliminamos las palabras vacías (stopwords), que suelen ser términos comunes y poco informativos, como “el”, “de”, “y”. Esto permite que el análisis se centre en términos con más contenido semántico.

# Eliminar palabras vacías en inglés en cada entrada diaria
yearly_entries <- lapply(yearly_entries, function(year) {
  year$entries <- lapply(year$entries, function(entry) {
    tokens_remove(entry, pattern = stopwords("en")) 
  })
  year  # Devolver la lista de año modificada
})

🤔¿Qué hemos hecho?

  • Aquí empleamos la función tokens_remove() otra vez aplicada al objeto entry, en este caso empleamos el parámetro pattern = para indicar que eliminaremos los tokens que representen palabras vacías, en paréntesis incluimos la lengua a través de su código ISO, en nuestro caso el inglés. Aquí tienes el listado completo de idiomas. El paquete stopwords permite asimismo crear y/o añadir tus propias palabras vacías.

Fíjate cómo, en comparación con el fragmento anterior, se han eliminado palabras como “in”, “my” u “only”.

Tokens consisting of 3 documents.
NAVALNY.txt2 :
[1] "january" "17th"   

NAVALNY.txt3 :
[1] "exactly" "one"     "year"    "ago"     "today"   "came"    "home"   
[8] "russia" 

NAVALNY.txt4 :
[1] "didnt"   "manage"  "take"    "single"  "step"    "soil"    "country"
[8] "free"    "man"    

Como habrás notado, es posible juntar todos estos pasos en uno sólo. Aquí lo mostramos por trozos para que vayas comprendiendo el proceso, pero podríamos hacer todo esto de una vez.

¿Sabías qué…? ️🤓☝

En inglés, es posible que el texto venga acompañado de apóstrofes como en los casos de don't y he's. Aquí, quanteda no tomará las 't ni las 's como elementos aislados, sino que lo mantendrá unida a la palabra para respetar el significado original.

Footnotes

  1. R es un lenguaje de programación abierto y colaborativo que sigue una estructura totalmente descentralizada. Cuando instalamos R por primera vez, sólo instalamos sus funcionalidades básicas. Todas aquellas funcionalidades adicionales llevadas a cabo por terceras personas deben instalarse en lo que se denominan paquetes o libraries en inglés.

    Cada vez que vayas a emplear un paquete, debes cargarlo, por defecto, cada vez que abres R estos paquetes no están cargados.

    ↩︎
  2. Una expresión regular es un patrón de búsqueda utilizado para manipular texto específico en una cadena. Facilita tareas como eliminar caracteres no deseados o extraer información específica (e.g., fechas o números). Es especialmente útil en la limpieza y preprocesamiento de datos textuales.

    ↩︎
  3. Una lista es una estructura de datos que puede contener elementos de diferentes tipos (numérico, caractéres, vectores o incluso otras listas) en un solo objeto. Cada elemento en una lista se puede acceder de forma individual usando índices. Esto se hace utilizando corchetes dobles. Por ejemplo si queremos ver el segundo elemento de la lista lista, lo indicaremos así: lista[[2]].

    ↩︎
  4. Aquí es importante diferenciar entre análisis textual y un análisis semántico. En el análisis de textos examinamos cuestiones como la frecuencia de las palabras, patrones o estructura, sin considerar el significado de cada palabra. Se trataría de un paso previo al análisis semántico donde nos centramos en el significado y el contexto de las palabras.

    ↩︎
  5. Las palabras vacías son términos comunes (como “el”, “de”, “y”) que suelen aparecer con mucha frecuencia en el texto, pero aportan poco significado o valor informativo al análisis. Estas palabras se eliminan generalmente para centrar el análisis en los términos más relevantes.

    ↩︎