Análisis de texto en R
Todas las presentaciones - o casi todas - sobre text mining o análisis de texto en R incluyen un ejemplo usando libros de Jane Austen, tweets de figuras públicas y, en general, hechas en inglés. Aunque no quiero criticar el estilo pedagógico de otros análisis, esto suele dejarnos muy lejos de los tipos de datos y de las herramientas que tenemos para analizarlos cuando pensamos en algún problema de política pública, y particularmente en español.
En esta nota de clase nos introduciremos al mundo del análisis de texto o text mining mediante el análisis de un conjunto de noticias de uno de los medios de comunicación más grandes de Argentina. Con este insumo vamos a poder explorar distintas herramientas muy interesantes de este tipo de datos. Al igual que los datos espaciales, temporales o grafos, esta clase de datos requiere un análisis particular y herramientas especiales para su manipulación, representación y estudio.
Con este objetivo en mente, vamos a usar tidytext, un paquete que intenta replicar el concepto de tidy. Básicamente, funciones con nombres que intentan ser fáciles de interpretar y el uso del famoso %>%. Con cargar las dos librerías, tidytext y tidyverse, ya podemos arrancar con este tema
Estructura de datos de texto en tidytext
Existen distintas maneras de estructurar texto para su análisis en text mining, y muchas veces parte del trabajo consiste en ir pasando de una estructura a otra. Veamos cómo es que tidytext organiza nuestros datos. Yo sé que prometí trabajar con text en español, pero para estas pequeñas demostraciones déjenme trabajar con un texto en inglés, algunas de las mejores líneas de la serie The Office (US). Vamos a crear un tibble con el nombre de tres personajes (Michael Scott, MS; Dwight Schrute, DS; Creed Bratton, CB) y algunas de sus mejores frases
officeQuotes <- tibble(personaje=c("MS","DS","CB"),
texto=c("If I had a gun with two bullets and I was in a room with Hitler, Bin Laden and Toby, I would shoot Toby twice",
"People underestimate the power of nostalgia. Nostalgia is truly one of the greatest human weaknesses, second only to the neck","I already won The lottery, I was born in the United States Of America, baby"))
Este formato no es el mejor para analizar texto. Lo mejor es pasar esto a “formato largo” en una unidad básica: tokens. Los tokens no son otra cosa que unidades más pequeñas de nuestro texto, generalmente palabras, que son la unidad sobre la que vamos a hacer varios de nuestros análisis. La función unnest_token hace este trabajo por nosotros. Nos pedirá dónde guardar las palabras, y automáticamente generará una columna donde mantendrá al personaje. Piensen en unnest_tokens() como si fuera una forma de pasar texto “ancho” a texto “largo”
## # A tibble: 6 x 2
## personaje word
## <chr> <chr>
## 1 MS if
## 2 MS i
## 3 MS had
## 4 MS a
## 5 MS gun
## 6 MS with
Sin embargo, unnest_tokens() también hace otras cosas a nuestros datos. Por ejemplo ¿Dónde quedó la puntuación? La eliminó, lo cual muchas veces es muy útil. Otra cosa que hizo: convertir todo a minúsculas. Eso también suele ser útil. Pero no siempre queremos hacer estas cosas. En el caso de las minúsculas, esto es fácil de solucionar con el argumento to_lower = FALSE, y listo. Cada palabra mantendrá sus mayúsculas y minúsculas. El objetivo de unnest_tokens() es poder aplicar muchas de las herramientas de tidyverse para analizar a nuestros datos. Recuerden: en el modelo tidytext, cada fila es un token
Si bien aclaré que en general nuestros tokens son palabras, siempre podemos elegir otra unidad de análisis que nos resulte más conveniente. Por ejemplo, podríamos querer quedarnos con los n-gramas, es decir, cantidad de palabras consecutivas. Quedemonos, por ejemplo, con los bigramas, es decir las dos palabras contiguas para cada uno de los textos
officeBigramas <- officeQuotes %>%
unnest_tokens(output = word,input = texto,token = "ngrams", n=2)
head(officeBigramas)
## # A tibble: 6 x 2
## personaje word
## <chr> <chr>
## 1 CB i already
## 2 CB already won
## 3 CB won the
## 4 CB the lottery
## 5 CB lottery i
## 6 CB i was
Limpiando los textos
Los textos en general vienen con mucho ruido, o con palabras muy usadas que no tienen un rol relevante en nuestro análisis. En muchas ocasiones, queremos eliminar este ruido y quedarnos con palabras que sean “significativas” de nuestro texto. Por supuesto, esto siempre tiene una alta dósis de arbitrariedad y siempre es recomendable evaluar la robustez de lo que análizamos cambiando esta etapa de limpieza. Podemos usar los diccionarios que nos ofrece el paquete stopwords, aunque es tan solo una de las múltiples opciones
## [1] "i" "me" "my" "myself" "we"
## [6] "our" "ours" "ourselves" "you" "your"
## [11] "yours" "yourself" "yourselves" "he" "him"
## [16] "his" "himself" "she" "her" "hers"
## [21] "herself" "it" "its" "itself" "they"
## [26] "them" "their" "theirs" "themselves" "what"
## [31] "which" "who" "whom" "this" "that"
## [36] "these" "those" "am" "is" "are"
## [41] "was" "were" "be" "been" "being"
## [46] "have" "has" "had" "having" "do"
## [51] "does" "did" "doing" "would" "should"
## [56] "could" "ought" "i'm" "you're" "he's"
## [61] "she's" "it's" "we're" "they're" "i've"
## [66] "you've" "we've" "they've" "i'd" "you'd"
## [71] "he'd" "she'd" "we'd" "they'd" "i'll"
## [76] "you'll" "he'll" "she'll" "we'll" "they'll"
## [81] "isn't" "aren't" "wasn't" "weren't" "hasn't"
## [86] "haven't" "hadn't" "doesn't" "don't" "didn't"
## [91] "won't" "wouldn't" "shan't" "shouldn't" "can't"
## [96] "cannot" "couldn't" "mustn't" "let's" "that's"
## [101] "who's" "what's" "here's" "there's" "when's"
## [106] "where's" "why's" "how's" "a" "an"
## [111] "the" "and" "but" "if" "or"
## [116] "because" "as" "until" "while" "of"
## [121] "at" "by" "for" "with" "about"
## [126] "against" "between" "into" "through" "during"
## [131] "before" "after" "above" "below" "to"
## [136] "from" "up" "down" "in" "out"
## [141] "on" "off" "over" "under" "again"
## [146] "further" "then" "once" "here" "there"
## [151] "when" "where" "why" "how" "all"
## [156] "any" "both" "each" "few" "more"
## [161] "most" "other" "some" "such" "no"
## [166] "nor" "not" "only" "own" "same"
## [171] "so" "than" "too" "very" "will"
Esas son las palabras consideradas como muy cómunes y que probablmente no nos den mucha información sobre nuestro texto. Palabras como “i”, “as”, “up”. Podemos cambiar el lenguaje y mostrar qué palabras tiene para el español
## [1] "de" "la" "que" "el" "en"
## [6] "y" "a" "los" "del" "se"
## [11] "las" "por" "un" "para" "con"
## [16] "no" "una" "su" "al" "lo"
## [21] "como" "más" "pero" "sus" "le"
## [26] "ya" "o" "este" "sí" "porque"
## [31] "esta" "entre" "cuando" "muy" "sin"
## [36] "sobre" "también" "me" "hasta" "hay"
## [41] "donde" "quien" "desde" "todo" "nos"
## [46] "durante" "todos" "uno" "les" "ni"
## [51] "contra" "otros" "ese" "eso" "ante"
## [56] "ellos" "e" "esto" "mí" "antes"
## [61] "algunos" "qué" "unos" "yo" "otro"
## [66] "otras" "otra" "él" "tanto" "esa"
## [71] "estos" "mucho" "quienes" "nada" "muchos"
## [76] "cual" "poco" "ella" "estar" "estas"
## [81] "algunas" "algo" "nosotros" "mi" "mis"
## [86] "tú" "te" "ti" "tu" "tus"
## [91] "ellas" "nosotras" "vosotros" "vosotras" "os"
## [96] "mío" "mía" "míos" "mías" "tuyo"
## [101] "tuya" "tuyos" "tuyas" "suyo" "suya"
## [106] "suyos" "suyas" "nuestro" "nuestra" "nuestros"
## [111] "nuestras" "vuestro" "vuestra" "vuestros" "vuestras"
## [116] "esos" "esas" "estoy" "estás" "está"
## [121] "estamos" "estáis" "están" "esté" "estés"
## [126] "estemos" "estéis" "estén" "estaré" "estarás"
## [131] "estará" "estaremos" "estaréis" "estarán" "estaría"
## [136] "estarías" "estaríamos" "estaríais" "estarían" "estaba"
## [141] "estabas" "estábamos" "estabais" "estaban" "estuve"
## [146] "estuviste" "estuvo" "estuvimos" "estuvisteis" "estuvieron"
## [151] "estuviera" "estuvieras" "estuviéramos" "estuvierais" "estuvieran"
## [156] "estuviese" "estuvieses" "estuviésemos" "estuvieseis" "estuviesen"
## [161] "estando" "estado" "estada" "estados" "estadas"
## [166] "estad" "he" "has" "ha" "hemos"
## [171] "habéis" "han" "haya" "hayas" "hayamos"
## [176] "hayáis" "hayan" "habré" "habrás" "habrá"
## [181] "habremos" "habréis" "habrán" "habría" "habrías"
## [186] "habríamos" "habríais" "habrían" "había" "habías"
## [191] "habíamos" "habíais" "habían" "hube" "hubiste"
## [196] "hubo" "hubimos" "hubisteis" "hubieron" "hubiera"
## [201] "hubieras" "hubiéramos" "hubierais" "hubieran" "hubiese"
## [206] "hubieses" "hubiésemos" "hubieseis" "hubiesen" "habiendo"
## [211] "habido" "habida" "habidos" "habidas" "soy"
## [216] "eres" "es" "somos" "sois" "son"
## [221] "sea" "seas" "seamos" "seáis" "sean"
## [226] "seré" "serás" "será" "seremos" "seréis"
## [231] "serán" "sería" "serías" "seríamos" "seríais"
## [236] "serían" "era" "eras" "éramos" "erais"
## [241] "eran" "fui" "fuiste" "fue" "fuimos"
## [246] "fuisteis" "fueron" "fuera" "fueras" "fuéramos"
## [251] "fuerais" "fueran" "fuese" "fueses" "fuésemos"
## [256] "fueseis" "fuesen" "siendo" "sido" "tengo"
## [261] "tienes" "tiene" "tenemos" "tenéis" "tienen"
## [266] "tenga" "tengas" "tengamos" "tengáis" "tengan"
## [271] "tendré" "tendrás" "tendrá" "tendremos" "tendréis"
## [276] "tendrán" "tendría" "tendrías" "tendríamos" "tendríais"
## [281] "tendrían" "tenía" "tenías" "teníamos" "teníais"
## [286] "tenían" "tuve" "tuviste" "tuvo" "tuvimos"
## [291] "tuvisteis" "tuvieron" "tuviera" "tuvieras" "tuviéramos"
## [296] "tuvierais" "tuvieran" "tuviese" "tuvieses" "tuviésemos"
## [301] "tuvieseis" "tuviesen" "teniendo" "tenido" "tenida"
## [306] "tenidos" "tenidas" "tened"
Independientemente del diccionario que usemos, podemos eliminar fácilmente estas palabras simplemente usando filter() y eliminando las filas en las cuales la columa word tiene alguna de las palabras que usamos como stopword.
Vamos a terminar esta primera y muy breve sección con algo que será muy importante de acá en adelante: contar la frecuencia de las palabras. En este caso vamos a agregar la cantidad de veces que aparece cada palabras para cada personaje:
officeTokens <- officeTokens %>%
group_by(personaje,word) %>%
summarise(conteo=n()) %>%
arrange(desc(conteo))
## `summarise()` regrouping output by 'personaje' (override with `.groups` argument)
Con estas mismas herramientas podríamos calcular también la cantidad de veces que aparecen las palabras en todo el dataset ¿Se animan a hacerlo? Más adelante nos va a servir para nuestros análisis.
Análisis de sentimiento
Sin lugar a dudas uno de los ejercicios más interesantes cuando se introduce a text mining es el análisis de sentimiento o sentiment analysis. Este análisis requiere de tomar muchas decisones. Para empezar, existen al menos dos maneras clásicas de lidiar con este problema. El primero pertenece al dominio del aprendizaje automático con el objetivo de clasificar textos en base a una etiqueta que ya conocemos. Imaginemos que nos dan un conjunto de tweets y alguien ya los valoró como “positivos” o “negativos”, podriamos entrenar un modelo que aprenda qué características se asocian con la valoración ya existente.
Por otro lado, podemos usar un conjunto de palabras ya existentes, clasificadas en lo que se conoce como lexicon. Estos diccionarios de léxico suelen tener categorizadas las palabras, y un sentimiento e intensidad asociadas. No todos los diccionarios coinciden en las palabras, su sentimiento y, específicamente, su intensidad.
Ambas metodologías tienen ventajas y desventajas. Por un lado, trabajar con herramientas de aprendizaje automático puede ser muy útil ya que no tenemos restricciones de idioma, diferencias entre diccionarios de léxicos y usos del lenguaje (como la ironía) o algo tan simple como la negación. Sin embargo, esta forma de clasificación de texto requiere tener a los documentos ya clasificados - y correctamente - algo que no siempre suele ser fácil y barato.
Podemos acceder a estos diccionarios o léxicos con get_sentiments(). Pueden usar la función de ayuda de R para entender bien cuáles son los parámetros que puede tomar. Existen cuatro lexicon cargados en tidytext: bing, afinn, loughran y nrc. Cada uno de ellos tiene su particularidades, vamos a usar bing y afinn para este pequeño ejercicio. afinn tiene a las palabras clasificadas con un valor asignado entre -5 y 5, siendo -5 las valoraciones más negativas y 5 las más positivas.
## # A tibble: 6 x 2
## word value
## <chr> <dbl>
## 1 abandon -2
## 2 abandoned -2
## 3 abandons -2
## 4 abducted -2
## 5 abduction -2
## 6 abductions -2
Con la función inner_join() podemos agregar esta información muy fácilmente. Es una función que también une datasets en basea una o más columnas con el mismo nombre, pero a diferencia de left_join() nos devolverá un dataset solo con aquellos valores que se encuentran en ambos datasets, descartando al resto de las observaciones.
## Joining, by = "word"
## # A tibble: 6 x 4
## # Groups: personaje [3]
## personaje word conteo value
## <chr> <chr> <int> <dbl>
## 1 CB united 1 1
## 2 CB won 1 3
## 3 DS greatest 1 3
## 4 DS underestimate 1 -1
## 5 MS gun 1 -1
## 6 MS shoot 1 -1
En este sencillo ejemplo encontró 6 palabras con una valoración determinada. Saquemos el promedio por frase y hagamos un gráfico:
officeAfinn <- officeTokens %>%
inner_join(diccionarioAfinn) %>%
group_by(personaje) %>%
summarise(sentimiento=mean(value)) %>%
mutate(feeling=ifelse(sentimiento>0,"Positivo","Negativo"))
ggplot(officeAfinn) +
geom_col(aes(x=sentimiento,y=personaje, fill=feeling)) +
scale_fill_brewer(palette = "Set1") +
guides(fill=FALSE) +
theme_minimal()
Lo que nos dice este método es que la más negativa de las frases es la de Michael Scott, dando a entender que mataría a Toby si tuviera un arma y un tiro para hacer… mientras que la más positiva es de Creed diciendo que el ya ganó la lotería porque nació en EE.UU., lo cual tiene mucho sentido para mí. La frase de Dwight, que a mi entender es neutra - pero por sobre todo, graciosa - aparece como levemente positiva. Prueben replicar esta metodología con el lexicon de bing ¿Encuentran un patrón distinto?
¿Para qué conformarmos con un análisis de tres frases si tenemos a nuestra disposición todos los textos que algún personaje haya dicho alguna vez en The Office? Hay que instalar el paquete schrute y ya podemos replicar nuestro dataset en algo más interesante y que, además, se adapta a problemas con los que pueden llegar a encontrarse
## Rows: 55,130
## Columns: 12
## $ index <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,...
## $ season <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,...
## $ episode <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,...
## $ episode_name <chr> "Pilot", "Pilot", "Pilot", "Pilot", "Pilot", "Pilo...
## $ director <chr> "Ken Kwapis", "Ken Kwapis", "Ken Kwapis", "Ken Kwa...
## $ writer <chr> "Ricky Gervais;Stephen Merchant;Greg Daniels", "Ri...
## $ character <chr> "Michael", "Jim", "Michael", "Jim", "Michael", "Mi...
## $ text <chr> "All right Jim. Your quarterlies look very good. H...
## $ text_w_direction <chr> "All right Jim. Your quarterlies look very good. H...
## $ imdb_rating <dbl> 7.6, 7.6, 7.6, 7.6, 7.6, 7.6, 7.6, 7.6, 7.6, 7.6, ...
## $ total_votes <int> 3706, 3706, 3706, 3706, 3706, 3706, 3706, 3706, 37...
## $ air_date <fct> 2005-03-24, 2005-03-24, 2005-03-24, 2005-03-24, 20...
Vamos a hacer algo que podría parecer difícil, pero tidytext lo hace muy simple para nosotros. Vamos a medir qué tan positivos estaba cada uno de los personajes principales para cada una de las temporadas. Lo primero que tenemos que hacer es pasar esto tokens, filtrando antes a los personajes
officeCompleteTokens <- completeScript %>%
select(season,character,text) %>%
filter(character %in% c("Michael","Jim","Pam","Dwight","Jan","Phyllis","Stanley","Oscar","Angela","Kevin","Ryan","Andy")) %>%
unnest_tokens(output = word, input = text)
# Vemos las primeras palabras de la serie
head(officeCompleteTokens)
## # A tibble: 6 x 3
## season character word
## <int> <chr> <chr>
## 1 1 Michael all
## 2 1 Michael right
## 3 1 Michael jim
## 4 1 Michael your
## 5 1 Michael quarterlies
## 6 1 Michael look
Obviamente tenemos muchas “stopwords” o palabras vacías. Vamos a eliminarlas también como ya sabemos hacerlo con la ayuda del paquete stopwords
officeCompleteTokens <- officeCompleteTokens %>%
filter(!word %in% stopwords(language = "en",source = "snowball"))
head(officeCompleteTokens)
## # A tibble: 6 x 3
## season character word
## <int> <chr> <chr>
## 1 1 Michael right
## 2 1 Michael jim
## 3 1 Michael quarterlies
## 4 1 Michael look
## 5 1 Michael good
## 6 1 Michael things
Excelente, ahora lo que vamos a hacer es sencillamente usar afinn para que asocie las palabras con su correspondiente valor en esta escala.
## Joining, by = "word"
Finalmente, agrupamos por personaje y temporada y calculamos el promedio de estos valores para sus diálogos
officeCompleteTokens<- officeCompleteTokens %>%
group_by(season,character) %>%
summarise(sentimientoPromedio=mean(value),
palabras=n()) %>%
filter(palabras>50)
## `summarise()` regrouping output by 'season' (override with `.groups` argument)
## # A tibble: 6 x 4
## # Groups: season [2]
## season character sentimientoPromedio palabras
## <int> <chr> <dbl> <int>
## 1 1 Dwight 0.55 180
## 2 1 Jim 1.23 177
## 3 1 Michael 0.929 588
## 4 1 Pam 0.99 100
## 5 2 Angela 0.585 65
## 6 2 Dwight 0.755 548
Ya podemos hacer nuestro gráfico para ver la evolución de estos personajes en el tiempo. Aunque existe mucho ruido en estos datos - prueben con otros léxicos a ver si obtienen un resultado diferente - algo interesante es que dos de los personajes más negativos - Angela y Stanley - tienen los dos valores más bajos para este indicador. Para el resto de los casos, vemos más bien resultados por el lado “positivo”.
ggplot(officeCompleteTokens) +
geom_line(aes(y=sentimientoPromedio,x=season)) +
facet_wrap(~character) +
theme_minimal() +
scale_x_continuous(breaks=c(1:9))
Probablemente se están preguntando cómo podemos aplicar este análisis con textos en otros idiomas. Esta es una de las principales barreras de esta metodología. En R está el paquete syuzhet, que le agrega a la función get_sentiment() un diccionario con sentimientos en español. Pero tengan cuidado y lean con atención cómo se crean estos léxicos, ya que muchas veces suelen ser traducciones de diccionarios en inglés mediante herramientas de traducción automática, que puede o no estar bien.
En lo que nos queda de esta clase vamos aprender a clasificar textos según una clase ya establecida, que es la segunda de las opciones para clasificar textos como positivo/negativo, pero en realidad es mucho más general ya que podemos clasificar cualquier categoría.
Midiendo el conflicto laboral usando text mining y aprendizaje automático
Hasta ahora vimos cómo podemos contar palabras, clasificarlas según diccionarios ya existentes y analizar, por ejemplo, el sentimiento negativo o positivo de los textos. Pero imaginemos otra situación en la cual tenemos un conjunto de documentos y los tenemos clasificados según si hablan de un tema u otro. En esta situación podemos aprovechar el hecho de que podemos convertir a las palabras - o transformaciones de ellas - en variables y simplemente aplicar modelos de aprendizaje automático para clasificar nuevos documentos.
Esta parte de la clase asume que tienen un conocimiento de cómo funcionan los modelos de aprendizaje automático, en particular el algoritmo random forest. Si no es el caso, pueden revisar este capítulo de aprendizaje automático que explica cómo funcionan los árboles de decisión y esta nota de clase que profundiza algunos conceptos y también explica cómo funciona random forest.
El orden típico de nustros datos que pide tidytext es muy útil para muchas tareas típicas de text mining, pero no es la única forma de manejar nuestros datos no estructurados de texto. Una forma alternativa de trabajar con nuestros documentos es lo que se conoce como Bag Of Words (BoW), o document term matrix (dtm). BoW consiste simplemente en colocar como columnas a las palabras y como filas a los documentos. Habrá tantas columnas como palabras únicas en todos los documentos, y la celda palabra-documento nos indica la cantidad de veces que aparece esa palabra - o token - en ese documento específico. Como pueden estar imaginando, esta matriz es MUY ancha… pero no se preocupen, en su gran mayoría los valores son 0s, ya que muchas palabras aparecen solo en un subconjunto más bien pequeño de documentos.
Aunque tidytext no nos da una forma directa de hacer esto, con la ayuda de tidyverse es relativamente fácil de hacer. Apliquemos algunos de los pasos que ya aprendimos anteriormente a unos datos de noticias. Primero, carguemos estos datos. Están subidos a un .RData a internet
load(url("https://github.com/martinmontane/martinmontane.github.io/raw/master/noticiasLaNacion.RData"))
Deberían contar con un data.frame que se llama noticias. En él tienen 6.007 noticias completas extraídas del diario La Nación. Unsemos glimpse() para ver que variables existen
## Rows: 6,007
## Columns: 9
## $ urls <chr> "politica/vidal-convoca-a-padres-para-no-quedar-...
## $ categoria <chr> "Política", "Política", "Política", "Política", ...
## $ fechaActualizacion <date> 2019-07-01, 2019-07-01, 2019-07-01, 2019-07-01,...
## $ tags <list> [<"María Eugenia Vidal", "Conflicto docente", "...
## $ autores <list> ["Daniel Santa Cruz ", [], "María Paula Etchebe...
## $ titulo <chr> "Vidal convoca a padres para no quedar atada a l...
## $ epigrafe <chr> "El gobierno de María Eugenia Vidal quiere incor...
## $ textoNota <chr> "\"Queremos que los padres puedan exigir mejor e...
## $ clase <chr> "ConflictoLaboral", "ConflictoLaboral", "Conflic...
Podemos ver que está la dirección de la noticia, la categoría (sección) del diario donde apareció publicada, una fecha de publicación - está agrupada por mes - una lista con etiquetas según el propio diario, autor/autora de la nota, título epígrafe, texto completo y una última columna que se llama clase. Por supuesto, este dataset da para muchos análisis diferentes, pero lo que vamos a hacer es generar una BoW para poder entrenar un modelo de aprendizaje automático para clasificar noticias que NO tienen la clase para saber si la noticia habla o no de un conflicto laboral. Esta metodología fue aplicada en investigaciones para medir el nivel de conflicto laboral en base a la noticia de los medios.
Primero, veamos la distribución de estas clases. Parece que tenemos más o menos 50% y 50%, lo cual es bastante positivo para nuestro ejercicio de predicciones.
##
## ConflictoLaboral OtrasNoticias
## 3007 3000
Vamos a aplicar lo que vimos antes, pero con algunos cambios. Lo que vamos a agregar es el uso de expresiones regulares (regex) para poder limpiar un poco más a a nuestros datos. Vamos a sacar todo lo que sea números (lo hacemos con [[:digit:]] y reemplazandolo por texto vacío) y algunas palabras claves de políticos y sindicalistas que harían demasiado fácil nuestra tarea de predicción - y también muy específico a los tiempos en los cuales estos personajes fueron relevantes. Además, vamos a agregar un ID de noticia, siempre es bueno hacerlo por si encontramos algún error, y usamos unnest_tokens() para los títulos.
noticias <- noticias %>%
select(titulo,clase) %>%
mutate(titulo = gsub("[[:digit:]]","",titulo),
titulo = gsub("macri|vidal|scioli|daniel|kirchner|hugo|cristina|caló|pablo|barrionuevo|moyano|next|'","",titulo),
noticiaID=row_number()) %>%
unnest_tokens(word,titulo)
Bien, ahora lo que necesitamos es eliminar stopwords en español. No va a ser perfecto, pero el paquete stopwords nos da una lista de palabras vacías o sin significado en español, como ya lo explicamos anteriormente en esta nota de clase. Hagamos exactamente igual que antes, seleccionando las filas que contienen tokens que efectivamente no contienen a estas palabras:
library(stopwords)
noticias <- noticias %>% filter(!word %in% stopwords(language="es")) %>% filter(!word %in% stopwords(language="en"))
Para generar una BoW tenemos que agrupar el conteo de las palabras según cada una de las noticias. Así que vamos a agrupar por noticiaID, clase y word y generar la suma:
## `summarise()` regrouping output by 'clase', 'noticiaID' (override with `.groups` argument)
##
## 1 2
## 32516 50
Dado que son títulos, y sin stopwords, tiene sentido que no muchos tokens aparezcan más de 1 vez. Ahora viene lo diferente: vamos a pasar del formato tidy que proponen quiene desarrollaron tidytext, vamos a convertirlo a un data.frame que replique lo que sería una BoW usando pivot_wider(). Tenemos que usar names_repair=“unique” porque una de las palabras que va a terminar como columna es “clase” y esa columna ya existe en nuestro dataset. Lo que hace este argumento es cambiarle el nombre para que no haya problemas
noticias <- pivot_wider(noticias,names_from = word,values_from = count,names_repair="unique",values_fill = 0)
## New names:
## * clase -> clase...1
## * clase -> clase...883
Si quieren pueden ver lo que tenemos con View() o head(). Cada documento es una fila y cada columna es una palabra, mientras que el valor de la celda nos indica la cantidad de veces que aparece en ese documento (en este caso, títutlo de nota de diario). Finalmente eliminamos noticiaID, ya que al final no tuvimos que hacer uso de ella. También vamos a cambiar el nombre feo que quedo para la columna clase
noticias <- noticias %>% ungroup() %>% select(-noticiaID) %>%
rename(clasificacion=clase...1) %>%
rename(clase=clase...883) %>%
mutate(clasificacion=factor(clasificacion)) %>%
select(-"NA")
Como se adelantó anteriormente, no vamos a hacer toda la metodología para optimizar los parámetros de nuestro algoritmo, simplemente vamos a mostrar que es posible estimarse. Además, vamos a extraer las palabras - variables - que más sirvieron para mejorar la clasificación para ver si podemos encontrar algún patrón. El algoritmo que vamos a usar es random forest, implementado por el paquete ranger. Pueden ver una explicación completa del funcionamiento y la optimización de los parámetros acá
## Warning: package 'ranger' was built under R version 4.0.2
modelo <- ranger(data = noticias,
formula = clasificacion ~ .,
classification = TRUE,
importance = "impurity")
## Growing trees.. Progress: 26%. Estimated remaining time: 1 minute, 28 seconds.
## Growing trees.. Progress: 53%. Estimated remaining time: 55 seconds.
## Growing trees.. Progress: 81%. Estimated remaining time: 21 seconds.
## Growing trees.. Progress: 99%. Estimated remaining time: 0 seconds.
Perfecto, ya entrenamos nuestro modelo ! Veamos cuáles son las 10 palabras más importantes para clasificar a las noticias entre conflicto laboral u otros temas, con la ayuda del paquete vip
##
## Attaching package: 'vip'
## The following object is masked from 'package:utils':
##
## vi
Evaluando la relevancia de las palabras con una heurística: tf-idf
El modelo de aprendizaje automático anterior parece haber asociado al conteo de palabras con la etiqueta de correspondiente de noticias sin tener que hacer ninguna transformación en ese valor. Pero tenemos buenas razones para no confiar en el conteo de palabras para caracerizar un texto.
Por un lado, existen textos que son más largos que otros. Esto puede llevarnos a una “inflación de palabras”, es decir que todo lo demás constante, los textos más largos van a tener un conteo más largo en todas las palabras. Esto es fácilmente solucionable midiendo la cantidad de veces que aparece cada palabra versus la cantidad total de palabras que tiene un texto.
Por otro lado, hay palabras que pueden ser comunes tanto en ese texto como en el resto de todos los documentos. Por supuesto, esto sucede con las stopwords o palabras vacías que vimos anteriormente, pero también puede pasar con otro conjunto de palabras que sean muy comunes en un cuerpo de textos, como palabras típicas propias de un escritor/a o de diversos tópicos comunes al cuerpo de documentos.
Una transformación que suele hacerse a los conteos de palabras por documento es la que se conoce como tf-idf, por las iniciales de Term Frequency - Inverse Document Frequency. El tf-idf de una palabra cualquiera en un determinado texto es tan simple la siguiente fórmula:
\[ tfidf(palabra) = \frac{\text{conteo de la palabra}}{\text{total de palabras en texto}}*ln(\frac{n_{textos}}{n_{\text{textos que tienen la palabra}}}) \]
donde la primera fracción es simplemente frecuencia de un determinado término en un texto (tf) y la segunda parte es el logaritmo natural de la inversa de la frecuencia de esa misma palabra en el total de los textos. Si el término aparece en todos los documentos, entonces esta última fracción tendrá valor 1, y el logaritmo de 1 es 0, por lo cual el tfidf de la palabra será cero, independientemente de la frecuencia del termino en el texto en particular. Valores de menor frecuencia en el total de los documentos ponderarán diferentemente a la frecuencia de palabras en cada uno de los documentos.
Al ser una transformación tan común, tidytext tine una función que, dado un formato tidy con unnest_token(), agrega la información sobre el td, el idf y la tfidf para cada uno de los términos de todos los documentos. Apliquemos esta medida de importancia de los términos a los datos de The Office. Carguemos nuevamente los datos desde el paquete schrute
library(schrute)
completeScript <- theoffice
officeCompleteTokens <- completeScript %>%
select(season,character,text) %>%
filter(character %in% c("Michael","Jim","Pam","Dwight","Jan","Phyllis","Stanley","Oscar","Angela","Kevin","Ryan","Andy")) %>%
unnest_tokens(output = word, input = text) %>%
group_by(character,word) %>%
summarise(count=n())
## `summarise()` regrouping output by 'character' (override with `.groups` argument)
Ahora usemos la transformación td-idf. Podemos agregarla simplemente usando la función bind_tf_idf()
officeCompleteTokens <- officeCompleteTokens %>%
bind_tf_idf(term = word,document = character,n = count) %>%
arrange(desc(tf_idf))
head(officeCompleteTokens)
## # A tibble: 6 x 6
## # Groups: character [4]
## character word count tf idf tf_idf
## <chr> <chr> <int> <dbl> <dbl> <dbl>
## 1 Angela pum 27 0.00201 1.79 0.00361
## 2 Angela sprinkles 14 0.00104 1.79 0.00187
## 3 Stanley wallpaper 3 0.000530 2.48 0.00132
## 4 Andy tuna 65 0.00148 0.875 0.00130
## 5 Angela parum 7 0.000522 2.48 0.00130
## 6 Jan spell 4 0.000512 2.48 0.00127
Como adelanté, la función bind_tf_idf() nos devuelve tres en nuestro dataset: tf, idf y tf_idf. Grafiquemos las palabras más importantes según esta última medida para cada uno de los personajes. Atención: reorder_within() es una función que se usa en el paquete tidytext para ordenar a las variables según su orden dentro de alguna unidad (en este caso, palabras según el orden tf_idf para cada personaje). Es solo específico para este caso
first15 <- officeCompleteTokens %>%
arrange(desc(tf_idf)) %>%
mutate(word=reorder_within(x=word,by = tf_idf,within = character)) %>%
group_by(character) %>%
slice_max(order_by = tf_idf, n= 15, with_ties=FALSE) %>%
ungroup()
Si vieron las series, van a poder ver que definitivamente levantó información muy específica de cada personalje. Por ejemplo, 4300 de Oscar es exactamente un capítulo en el cual sobran USD 4300 del presupuesto de un año y es el quien habla de eso todo el capítulo con Michael. Por otro lado, wuphf en Ryan hace referencia a un capítulo en el cual funda una empresa (wuphf.com). El resto de las palabras son más obvias, pero también específicas a cada personaje.
Es posible utilizar esta misma medida para entrenar modelos de clasificación o de predicción. El ejercico plantea un desafío en esta línea.
Extensiones
Esta clase es intensiva pero introductoria. Quedan conceptos importantes por profundizar. Recomiendo enfáticamente el libro de Julia Silge y David Robinson Text Mining With R para quienes quieran profundizar en estos temas. https://www.tidytextmining.com/
Ejercicio
Realicen una transformación td-idf a los datos de las noticias de La Nación ¿Cuáles son las palabras más comunes en los títulos de las noticias de conflicto laboral versus el resto de las noticias? Por otro lado, reentrenen el modelo ranger, pero esta vez usando el tf-idf de las palabras, en lugar del conteo de las palabras ¿Cuáles son las nuevas variables más importantes para predecir según el modelo?