Extracción de términos relevantes
En este artículo se abordará la extracción del significado de un documento desde un punto de vista estadístico. Y se hará uso de librerías de python como BeautifulSoup, nltk, spacy, collections y pandas. El programa completo puede visualizarse aquí
No todas las palabras de un texto son igualmente relevantes para determinar su significado general ¿es suficiente determinar la frecuencia de un término para determinar su relevancia? No, la frecuencia no puede ser el criterio primario ya que existen palabras en la lengua denominadas palabras vacías o stopwords, de las que forman parte las gramaticales y las preposiciones, que son las más frecuentes mientras que las palabras de la categoría léxica son las menos frecuentes en la lengua. Hace falta pues otro parámetro: interesan las que sean más específicas a ese texto.
Definiremos pues la relevancia de un término como el producto de su frecuencia en un texto por su especificidad. Las palabras vacías tienden a una especificidad cero, con lo que su relevancia es nula sin importar cuantas veces aparezcan en un texto. Pero, ¿cómo definimos la especificidad? Depende: si estamos considerando un texto aislado, consideraremos más específicos los términos menos frecuentes de la lengua para lo cual disponemos de tablas de frecuencias; si, por otra parte, manejamos un documento dentro de una colección y queremos caracterizarlo respecto a los demás, serán más específicos los términos que aparezcan menos en el resto de documentos (para más información léase el epígrafe tf·idf más adelante).
Extracción de artículos de la wikipedia (con BeautifulSoup)
La siguiente sección del programa va a extraer información de varios artículos de la web de Wikipedia (átomo, célula, cerebro, CPU, ciudad y Sol). El algoritmo escogerá los párrafos dentro del contenido de la página. Se proporcionan dos funciones: una extrae todo el artículo y otra sólo la introducción (identificada como la parte previa a la tabla de contenido)
import requests
from bs4 import BeautifulSoup
import re
def extract_wikipedia_article(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, features='lxml')
content_text = soup.find(id='mw-content-text')
children = content_text.find_all('p')
text = '\n'.join(_.get_text() for _ in children)
text = re.sub(r'\[\d+\]|', '', text)
return text
def extract_wikipedia_intro(url):
page = requests.get(url)
soup = BeautifulSoup(page.content, features='lxml')
toc = soup.find(id='toc')
if toc:
children = []
node = toc
while node.find_previous_sibling('p'):
node = node.find_previous_sibling('p')
children.append(node)
children = children[::-1]
else: # no toc found -> extract all paragraphs
children = soup.findChildren("p", recursive=False)
text = '\n'.join(_.get_text() for _ in children)
text = re.sub(r'\[\d+\]|| ', '', text)
return text
documents_urls = [
'https://es.wikipedia.org/wiki/%C3%81tomo',
'https://es.wikipedia.org/wiki/C%C3%A9lula',
'https://es.wikipedia.org/wiki/Cerebro',
'https://es.wikipedia.org/wiki/Unidad_central_de_procesamiento',
'https://es.wikipedia.org/wiki/Ciudad',
'https://es.wikipedia.org/wiki/Sol',
]
documents_text = [extract_wikipedia_article(_) for _ in documents_urls]
print(documents_text)
El resultado es:
Tokenización y lematización (con Spacy)
Segmentar o tokenizar es el acto por el cuál segmentamos el texto en unidades menores (tokens). Generalmente se basa en el análisis de la gramática tradicional que considera a la palabra como unidad lingüística.
Este torpe análisis hace que no se analicen palabras constituidas por varios morfemas (del=de el, o cantaba=cant aba, verte=ver te) .
La lematización consiste en asociar una forma de la lengua a una clase o lema prescindiendo de la morfología flexiva presente en las clases de palabras variables (determinantes, nombres, adjetivos, verbos). El lema se hace manifiesto mediante un representante, que es una de sus formas, denominado forma de cita, que es el término definido que vemos en los diccionarios. La forma de cita en los nombres es la forma singular (casa <- casas), y en los verbos en español es el infinitivo (cantar <- cantaba) aunque en otras lenguas como el vasco es el participio y en árabe es la tercera persona masculina singular del aspecto perfecto (por ser la forma más sencilla de la conjugación árabe).
import spacy
def get_lemmas(text):
"""
Removes punctuation and function affixes; lexemes and derivational affixes remain
:param text: text to tokenize
:return: tokens
"""
doc = nlp(text)
# import explacy
# explacy.print_parse_info(nlp, text)
return [token.lemma_ for token in doc if token.pos_ not in ['PUNCT', 'SPACE']]
def get_lemmatized_sentences(text):
"""
Removes punctuation and function affixes; lexemes and derivational affixes remain
:param text: text to tokenize
:return: tokenized sentences
"""
doc = nlp(text)
lemmatized_sentences = [[]]
for token in doc:
if token.pos_ == 'SPACE': # aparte
lemmatized_sentences.append([]) # new sentence
elif token.pos_ != 'PUNCT':
lemma = token.lemma_
lemmatized_sentences[-1].append(lemma)
return lemmatized_sentences
# NOTA: existen diversos modelos para el NLP aparte del utilizado a continuación
nlp = spacy.load("es_dep_news_trf")
documents_lemmas = [get_lemmas(_) for _ in documents_text]
print(documents_lemmas)
El resultado es:
Descarte de palabras poco específicas
Las palabras más frecuentes de la lengua pueden consultarse, por ejemplo, en wiktionary. Pueden presentarse como listas de formas o lemas. Al ser obtenidas de corpus con un gran número de palabras son muy similares al menos en los términos maś frecuentes.
Sin embargo existen diferentes listas de palabras vacías o stopwords. La lista de nltk puede extraerse en python de la siguiente manera:
from nltk.corpus import stopwords
stopwords = stopwords.words('spanish')
print(stopwords)
obtenemos la siguiente lista:
['de', 'la', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'este', 'sí', 'porque', 'esta', 'entre', 'cuando', 'muy', 'sin', 'sobre', 'también', 'me', 'hasta', 'hay', 'donde', 'quien', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'ese', 'eso', 'ante', 'ellos', 'e', 'esto', 'mí', 'antes', 'algunos', 'qué', 'unos', 'yo', 'otro', 'otras', 'otra', 'él', 'tanto', 'esa', 'estos', 'mucho', 'quienes', 'nada', 'muchos', 'cual', 'poco', 'ella', 'estar', 'estas', 'algunas', 'algo', 'nosotros', 'mi', 'mis', 'tú', 'te', 'ti', 'tu', 'tus', 'ellas', 'nosotras', 'vosotros', 'vosotras', 'os', 'mío', 'mía', 'míos', 'mías', 'tuyo', 'tuya', 'tuyos', 'tuyas', 'suyo', 'suya', 'suyos', 'suyas', 'nuestro', 'nuestra', 'nuestros', 'nuestras', 'vuestro', 'vuestra', 'vuestros', 'vuestras', 'esos', 'esas', 'estoy', 'estás', 'está', 'estamos', 'estáis', 'están', 'esté', 'estés', 'estemos', 'estéis', 'estén', 'estaré', 'estarás', 'estará', 'estaremos', 'estaréis', 'estarán', 'estaría', 'estarías', 'estaríamos', 'estaríais', 'estarían', 'estaba', 'estabas', 'estábamos', 'estabais', 'estaban', 'estuve', 'estuviste', 'estuvo', 'estuvimos', 'estuvisteis', 'estuvieron', 'estuviera', 'estuvieras', 'estuviéramos', 'estuvierais', 'estuvieran', 'estuviese', 'estuvieses', 'estuviésemos', 'estuvieseis', 'estuviesen', 'estando', 'estado', 'estada', 'estados', 'estadas', 'estad', 'he', 'has', 'ha', 'hemos', 'habéis', 'han', 'haya', 'hayas', 'hayamos', 'hayáis', 'hayan', 'habré', 'habrás', 'habrá', 'habremos', 'habréis', 'habrán', 'habría', 'habrías', 'habríamos', 'habríais', 'habrían', 'había', 'habías', 'habíamos', 'habíais', 'habían', 'hube', 'hubiste', 'hubo', 'hubimos', 'hubisteis', 'hubieron', 'hubiera', 'hubieras', 'hubiéramos', 'hubierais', 'hubieran', 'hubiese', 'hubieses', 'hubiésemos', 'hubieseis', 'hubiesen', 'habiendo', 'habido', 'habida', 'habidos', 'habidas', 'soy', 'eres', 'es', 'somos', 'sois', 'son', 'sea', 'seas', 'seamos', 'seáis', 'sean', 'seré', 'serás', 'será', 'seremos', 'seréis', 'serán', 'sería', 'serías', 'seríamos', 'seríais', 'serían', 'era', 'eras', 'éramos', 'erais', 'eran', 'fui', 'fuiste', 'fue', 'fuimos', 'fuisteis', 'fueron', 'fuera', 'fueras', 'fuéramos', 'fuerais', 'fueran', 'fuese', 'fueses', 'fuésemos', 'fueseis', 'fuesen', 'sintiendo', 'sentido', 'sentida', 'sentidos', 'sentidas', 'siente', 'sentid', 'tengo', 'tienes', 'tiene', 'tenemos', 'tenéis', 'tienen', 'tenga', 'tengas', 'tengamos', 'tengáis', 'tengan', 'tendré', 'tendrás', 'tendrá', 'tendremos', 'tendréis', 'tendrán', 'tendría', 'tendrías', 'tendríamos', 'tendríais', 'tendrían', 'tenía', 'tenías', 'teníamos', 'teníais', 'tenían', 'tuve', 'tuviste', 'tuvo', 'tuvimos', 'tuvisteis', 'tuvieron', 'tuviera', 'tuvieras', 'tuviéramos', 'tuvierais', 'tuvieran', 'tuviese', 'tuvieses', 'tuviésemos', 'tuvieseis', 'tuviesen', 'teniendo', 'tenido', 'tenida', 'tenidos', 'tenidas', 'tened']
Como puede observarse la lista contiene:
- formas de palabras de la categoría gramatical como conjunciones, determinantes (los pronombres se incluyen aquí pues también son determinates) y la flexión oracional independiente (haber, estar y ser).
- formas de palabras de la categoría léxica como preposiciones (aunque algunas preposiciones, las que son índices funcionales son gramaticales) y verbos como tener o sentir.
Utilicemos pues esta lista para eliminar las palabras vacías:
documents_filtered = [[token for token in document if not token.lower() in stopwords] for document in documents_lemmas]
print(documents_filtered)
El resultado es:
Relevancia tf·idf (frecuencia de término por inversa de documento)
Para hallar los términos más específicos de una colección de documentos utilizamos el cálculo tf·idf (frecuencia de término – frecuencia inversa de documento). tf es la frecuencia del término y tiene la potencialidad de dirigir al tópico del texto al multiplicarse con idf, que es la especificidad, que se calcula como la inversa de la presencia del término nt en el conjunto de documentos.
Si el término está presente en pocos será más específica; si lo está en muchos será poco específica. Si tenemos N=60 documentos y ese término se halla en los 60, es decir, nt=60 luego idf será cero, si sólo aparece en uno, nt=1, será máxima. La función puede visualizarse a continuación, donde el eje de abscisas representa el porcentaje de documentos en los que aparece el término (avanza hacia la menor especificidad)
Para empezar determinemos la frecuencia de los términos de cada documento:
import collections
import pandas as pd
documents_frequencies = [collections.Counter(_) for _ in documents_filtered]
Los diccionarios de frecuencias de lemas así creados pueden ser exportados convenientemente con pandas a un dataframe, en el que las claves son las columnas.
df = pd.DataFrame(documents_frequencies)
El resto consiste en operar mediante pandas con el objeto dataframe para realizar los cálculos. Lo empaquetamos en una función:
import math
import collections
import pandas as pd
def tfidf(documents_tokens, max_results=1000, threshold=0):
documents_frequencies = [collections.Counter(_) for _ in documents_tokens]
df = pd.DataFrame(documents_frequencies)
length = len(df)
for col in df:
counts = df[col].count()
df[col] = df[col] * math.log(length / counts)
tfidf_weights = [row_object[row_object.values > threshold].sort_values(ascending=False).head(max_results).to_dict()
for row, row_object in df.iterrows()]
[print(index, len(_), _) for index, _ in enumerate(tfidf_weights)]
return df, tfidf_weights
Podemos asimismo exportar el proceso de cálculo a una hoja de cálculo completando la función anterior:
"""
pip install xlsxwriter
pip install xlwt
"""
import math
import collections
import pandas as pd
def get_tfidf(documents_tokens, max_results=1000, threshold=0, save_as=None):
documents_frequencies = [collections.Counter(_) for _ in documents_tokens]
df = pd.DataFrame(documents_frequencies)
length = len(df)
rs = pd.DataFrame(df.count(axis=0)).transpose()
export_df1 = df.append(rs, ignore_index=True).transpose()
for col in rs:
rs[col] = math.log(length / rs[col])
for col in df:
counts = df[col].count()
df[col] = df[col] * math.log(length / counts)
export_df2 = df.append(rs, ignore_index=True).transpose()
tfidf_weights = [row_object[row_object.values > threshold].sort_values(ascending=False).head(max_results).to_dict()
for row, row_object in df.iterrows()]
[print(index, len(_), _) for index, _ in enumerate(tfidf_weights)]
highest_keys = df.apply(lambda s, n: pd.Series(s.nlargest(n).index), axis=1, n=max_results)
if save_as:
writer = pd.ExcelWriter(save_as, engine='xlsxwriter')
# write each DataFrame to a specific sheet
export_df1.to_excel(writer, sheet_name='frequencies')
export_df2.to_excel(writer, sheet_name='tf·idf')
highest_keys.to_excel(writer, sheet_name='highest_keys')
writer.save()
return df, tfidf_weights
df, tfidf_weights = get_tfidf(documents_filtered, max_results=1000, threshold=0, save_as='relevant_terms_extraction.xlsx')
print(df)
print(tfidf_weights)
Resultados
Los resultados son ordenados por su relevancia y son también exportados a una hoja de cálculo de la que se presenta un extracto a continuación:
0 | 1 | 2 | 3 | 4 | 5 | 6 | |
---|---|---|---|---|---|---|---|
doc0 | átomo | atómico | electrón | protón | neutrón | Rutherford | partícula |
doc1 | célula | membrana | bacteria | celular | genético | arquea | orgánulo |
doc2 | cerebro | cerebral | nervioso | neurona | encéfalo | hemisferio | lóbulo |
doc3 | CPU | instrucción | diseño | reloj | procesador | ejecución | programa |
doc4 | ciudad | habitante | urbano | villa | población | Buenos | Aires |
doc5 | Sol | solar | fotosfera | estrella | helio | Tierra | mancha |
No por casualidad el término más relevante de cada artículo es el tópico del artículo de wikipedia y es seguido por otros cuya coherencia con este reconocemos.
El programa completo puede visualizarse aquí