Análisis del color emocional de las reseñas de Kinopoisk

Entrada


El procesamiento del lenguaje natural (PNL) es un área popular e importante del aprendizaje automático. En este centro, describiré mi primer proyecto relacionado con el análisis del color emocional de las críticas de películas escritas en Python. La tarea del análisis sentimental es bastante común entre aquellos que quieren dominar los conceptos básicos de PNL, y puede convertirse en un análogo del 'Hola mundo' en esta área.

En este artículo, veremos todas las etapas principales del proceso de Data Science: desde crear su propio conjunto de datos, procesarlo y extraer características usando la biblioteca NLTK, y finalmente aprender y ajustar el modelo usando scikit-learn. La tarea en sí misma es clasificar las revisiones en tres clases: negativa, neutral y positiva.

Formación de corpus de datos


Para resolver este problema, uno podría usar un cuerpo de datos anotado y listo con reseñas de IMDB, de los cuales hay muchos en GitHub. Pero se decidió crear la suya propia con reseñas en ruso tomadas de Kinopoisk. Para no copiarlos manualmente, escribiremos un analizador web. Usaré la biblioteca de solicitudes para enviar solicitudes http y BeautifulSoup para procesar archivos html. Primero, definamos una función que tomará un enlace a las reseñas de películas y las recuperará. Para que Kinopoisk no reconozca el bot en nosotros, debe especificar el argumento de encabezado en la función request.get, que simulará el navegador. Es necesario pasarle un diccionario con las claves User-Agent, Accept-language y Accept, cuyos valores se pueden encontrar en las herramientas de desarrollo del navegador. A continuación, se crea un analizador y las revisiones se recuperan de la página, que se almacenan en la clase de marcado _reachbanner_ html.

import requests from bs4 import BeautifulSoup import numpy as np import time import os def load_data(url): r = requests.get(url, headers = headers) #  http  soup = BeautifulSoup(r.text, 'html.parser')#  html  reviews = soup.find_all(class_='_reachbanner_')#    reviews_clean = [] for review in reviews:#    html  reviews_clean.append(review.find_all(text=True)) return reviews_clean 

Nos deshicimos del marcado html, sin embargo, nuestras revisiones siguen siendo objetos BeautifulSoup , pero necesitamos convertirlos en cadenas. La función de conversión hace exactamente eso. También escribiremos una función que recupere el nombre de la película, que luego se utilizará para guardar reseñas.

 def convert(reviews): #     review_converted = [] for review in reviews: for i in review: map(str, i) review = ''.join(review) review_converted.append(review) return review_converted def get_name(url): #    r = requests.get(url, headers = headers) soup = BeautifulSoup(r.text, 'html.parser') name = soup.find(class_='alternativeHeadline') name_clean = name.find_all(text = True) #   , . .     return str(name_clean[0]) 

La última función del analizador tomará un enlace a la página principal de la película, una clase de crítica y una forma de guardar reseñas. La función también define los retrasos entre las solicitudes que son necesarios para evitar una prohibición. La función contiene un bucle que recupera y almacena revisiones desde la primera página, hasta que encuentra una página inexistente de la cual la función load_data extraerá una lista vacía y el bucle se romperá.

 def parsing(url, status, path): page = 1 delays = [11, 12, 13, 11.5, 12.5, 13.5, 11.2, 12.3, 11.8] name = get_name(url) time.sleep(np.random.choice(delays)) #    while True: loaded_data = load_data(url + 'reviews/ord/date/status/{}/perpage/200/page/{}/'.format(status, page)) if loaded_data == []: break else: # E     ,    if not os.path.exists(path + r'\{}'.format(status)): os.makedirs(path + r'\{}'.format(status)) converted_data = convert(loaded_data) #   for i, review in enumerate(converted_data): with open(path + r'\{}\{}_{}_{}.txt'.format(status, name, page, i), 'w', encoding = 'utf-8') as output: output.write(review) page += 1 time.sleep(np.random.choice(delays)) 

Luego, utilizando el siguiente ciclo, puede extraer reseñas de películas que están en la lista de urles . Será necesario crear una lista de películas manualmente. Sería posible, por ejemplo, obtener una lista de enlaces a películas escribiendo una función que las extrajera de las 250 mejores películas de búsqueda de películas para no hacerlo manualmente, pero 15-20 películas serían suficientes para formar un pequeño conjunto de datos de mil revisiones para cada clase. Además, si obtiene una prohibición, el programa mostrará en qué película y clase se detuvo el analizador para continuar desde el mismo lugar después de aprobar la prohibición.

 path = #    urles = #    statuses = ['good', 'bad', 'neutral'] delays = [15, 20, 13, 18, 12.5, 13.5, 25, 12.3, 23] for url in urles: for status in statuses: try: parsing(url = url, status = status, path=path) print('one category done') time.sleep(np.random.choice(delays)) #       AttributeError except AttributeError: print(' : {}, {}'.format(url, status)) break #  else  ,      #    ,     else: print('one url done') continue break 

Pretratamiento


Después de escribir el analizador, recordando películas aleatorias para él y varias prohibiciones de la búsqueda de películas, mezclé las reseñas en carpetas y seleccioné 900 reseñas de cada clase para capacitación y el resto para el grupo de control. Ahora es necesario preprocesar la carcasa, es decir, tokenizarla y normalizarla. Tokenizar significa dividir el texto en componentes, en este caso en palabras, ya que usaremos la representación de una bolsa de palabras. Y la normalización consiste en convertir palabras en minúsculas, eliminar palabras de detención y ruido excesivo, tartamudear y cualquier otro truco que ayude a reducir el espacio de los signos.

Importamos las bibliotecas necesarias.

Texto oculto
 from nltk.corpus import PlaintextCorpusReader from nltk.stem.snowball import SnowballStemmer from nltk.probability import FreqDist from nltk.tokenize import RegexpTokenizer from nltk import bigrams from nltk import pos_tag from collections import OrderedDict from sklearn.metrics import classification_report, accuracy_score from sklearn.naive_bayes import MultinomialNB from sklearn.model_selection import GridSearchCV from sklearn.utils import shuffle from multiprocessing import Pool import numpy as np from scipy.sparse import csr_matrix 


Comenzamos definiendo algunas funciones pequeñas para el preprocesamiento de texto. El primero, llamado lower_pos_tag, tomará una lista con palabras, las convertirá a minúsculas y guardará cada token en una tupla con su parte del discurso. La operación de agregar parte del discurso a una palabra se denomina etiquetado Parte del discurso (POS) y a menudo se usa en PNL para extraer entidades. En nuestro caso, utilizaremos partes del discurso en la siguiente función para filtrar palabras.

 def lower_pos_tag(words): lower_words = [] for i in words: lower_words.append(i.lower()) pos_words = pos_tag(lower_words, lang='rus') return pos_words 

Los textos contienen una gran cantidad de palabras que con demasiada frecuencia resultan útiles para el modelo (las llamadas palabras de detención). Básicamente, estas son preposiciones, conjunciones, pronombres por los cuales es imposible determinar a qué clase se refiere el recuerdo. La función clean solo deja sustantivos, adjetivos, verbos y adverbios. Tenga en cuenta que elimina partes del discurso, ya que no son necesarias para el modelo en sí. También puede notar que esta función utiliza el tartamudeo, cuya esencia es eliminar los sufijos y prefijos de las palabras. Esto le permite reducir la dimensión de los signos, ya que las palabras con diferentes géneros y casos se reducirán al mismo token. Existe un análogo más poderoso de la tartamudez: la lematización, le permite restaurar la forma inicial de la palabra. Sin embargo, funciona más lento que el tartamudeo y, además, NLTK no tiene un lematizador ruso.

 def clean(words): stemmer = SnowballStemmer("russian") cleaned_words = [] for i in words: if i[1] in ['S', 'A', 'V', 'ADV']: cleaned_words.append(stemmer.stem(i[0])) return cleaned_words 

A continuación, escribimos la función final que tomará la etiqueta de clase y recuperará todas las revisiones con esta clase. Para leer el caso, utilizaremos el método sin procesar del objeto PlaintextCorpusReader , que le permite extraer texto del archivo especificado. A continuación, se usa la tokenización RegexpTokenizer, que funciona sobre la base de una expresión regular. Además de palabras individuales, agregué al modelo bigrams, que son combinaciones de todas las palabras vecinas. Esta función también utiliza el objeto FreqDist , que devuelve la frecuencia de aparición de palabras. Se usa aquí para eliminar palabras que aparecen en todas las revisiones de una clase en particular solo una vez (también se llaman hapaks). Por lo tanto, la función devolverá un diccionario que contiene documentos presentados como una bolsa de palabras y una lista de todas las palabras para una clase en particular.

 corpus_root = #    def process(label): # Wordmatrix -     # All words -    data = {'Word_matrix': [], 'All_words': []} #      templist_allwords = [] #        corpus = PlaintextCorpusReader(corpus_root + '\\' + label, '.*', encoding='utf-8') #       names = corpus.fileids() #   tokenizer = RegexpTokenizer(r'\w+|[^\w\s]+') for i in range(len(names)): #   bag_words = tokenizer.tokenize(corpus.raw(names[i])) lower_words = lower_pos_tag(bag_words) cleaned_words = clean(lower_words) finalist = list(bigrams(cleaned_words)) + cleaned_words data['Word_matrix'].append(final_words) templist_allwords.extend(cleaned_words) #   templistfreq = FreqDist(templist_allwords) hapaxes = templistfreq.hapaxes() #    for word in templist_allwords: if word not in hapaxes: data['All_words'].append(word) return {label: data} 

La etapa de preprocesamiento es la más larga, por lo que tiene sentido paralelizar el procesamiento de nuestro caso. Esto se puede hacer usando el módulo de multiprocesamiento . En el siguiente fragmento de código de programa, comienzo tres procesos que procesarán simultáneamente tres carpetas con diferentes clases. A continuación, los resultados se recopilarán en un diccionario. Este preprocesamiento se ha completado.

 if __name__ == '__main__': data = {} labels = ['neutral', 'bad', 'good'] p = Pool(3) result = p.map(process, labels) for i in result: data.update(i) p.close() 

Vectorización


Después de preprocesar el caso, tenemos un diccionario donde para cada etiqueta de clase contiene una lista con reseñas que tokenizamos, normalizamos y enriquecimos con bigrams, así como una lista de palabras de todas las revisiones de esta clase. Dado que el modelo no puede percibir el lenguaje natural como lo hacemos nosotros, la tarea ahora es presentar nuestras revisiones en forma numérica. Para hacer esto, crearemos un vocabulario común, que consta de tokens únicos, y con él vectorizaremos cada revisión.

Para empezar, creamos una lista que contiene revisiones de todas las clases junto con sus etiquetas. A continuación, creamos un vocabulario común, tomando de cada clase 10,000 de las palabras más comunes usando el método most_common del mismo FreqDist . Como resultado, obtuve un vocabulario que consta de aproximadamente 17,000 palabras.

 #     : # [([  ], _)] labels = ['neutral', 'bad', 'good'] labeled_data = [] for label in labels: for document in data[label]['Word_matrix']: labeled_data.append((document, label)) #      all_words = [] for label in labels: frequency = FreqDist(data[label]['All_words'] common_words = frequency.most_common(10000) words = [i[0] for i in common_words] all_words.extend(words) #    unique_words = list(OrderedDict.fromkeys(all_words)) 

Hay varias formas de vectorizar texto. El más popular de ellos: TF-IDF, codificación directa y de frecuencia. Utilicé la codificación de frecuencia, cuya esencia es presentar cada revisión como un vector, cuyos elementos son el número de apariciones de cada palabra del vocabulario. NLTK tiene sus propios clasificadores, puede usarlos, pero funcionan más lentamente que sus contrapartes de scikit-learn y tienen menos configuraciones. A continuación se muestra el código de codificación para NLTK . Sin embargo, usaré el modelo Naive Bayes de scikit-learn y codificaré las revisiones, almacenando los atributos en una matriz dispersa de SciPy y las etiquetas de clase en una matriz NumPy separada.

 #     nltk  : # # [({ : -   },  )] prepared_data = [] for x in labeled_data: d = defaultdict(int) for word in unique_words: if word in x[0]: d[word] += 1 if word not in x[0]: d[word] = 0 prepared_data.append((d, x[1])) #     scikit-learn #     matrix_vec = csr_matrix((len(labeled_data), len(unique_words)), dtype=np.int8).toarray() #     target = np.zeros(len(labeled_data), 'str') for index_doc, document in enumerate(labeled_data): for index_word, word in enumerate(unique_words): #  -     matrix_vec[index_doc, index_word] = document[0].count(word) target[index_doc] = document[1] #   X, Y = shuffle(matrix_vec, target) 

Como en el conjunto de datos las revisiones con ciertas etiquetas van una tras otra, es decir, primero todas neutrales, luego todas negativas, etc., debe mezclarlas. Para hacer esto, puede usar la función aleatoria de scikit-learn . Es adecuado para situaciones en las que los signos y las etiquetas de clase se encuentran en matrices diferentes, ya que le permite mezclar dos matrices al unísono.

Entrenamiento modelo


Ahora queda entrenar el modelo y verificar su precisión en el grupo de control. Como modelo, utilizaremos el modelo del clasificador Naive Bayes. Scikit-learn tiene tres modelos Naive Bayes dependiendo de la distribución de datos: binario, discreto y continuo. Como la distribución de nuestras funciones es discreta, elegimos MultinomialNB .

El clasificador bayesiano tiene el hiperparámetro alfa , que es responsable de suavizar el modelo. Naive Bayes calcula las probabilidades de que cada revisión pertenezca a todas las clases, para esto multiplica las probabilidades condicionales de la aparición de todas las palabras de revisión, siempre que pertenezcan a una clase en particular. Pero si no se encontró alguna palabra de revisión en el conjunto de datos de entrenamiento, entonces su probabilidad condicional es igual a cero, lo que anula la probabilidad de que la revisión pertenezca a cualquier clase. Para evitar esto, por defecto, se agrega una unidad a todas las probabilidades de palabras condicionales, es decir, alfa es igual a uno. Sin embargo, este valor puede no ser óptimo. Puede intentar seleccionar alfa utilizando la búsqueda de cuadrícula y la validación cruzada.

 parameter = [1, 0, 0.1, 0.01, 0.001, 0.0001] param_grid = {'alpha': parameter} grid_search = GridSearchCV(MultinomialNB(), param_grid, cv=5) grid_search.fit(X, Y) Alpha, best_score = grid_search.best_params_, grid_search.best_score_ 

En mi caso, el hogar de la cuadrícula da el valor óptimo del hiperparámetro igual a 0 con una precisión de 0.965. Sin embargo, este valor obviamente no será óptimo para el conjunto de datos de control, ya que habrá una gran cantidad de palabras que no se han encontrado previamente en el conjunto de entrenamiento. Para un conjunto de datos de referencia, este modelo tiene una precisión de 0.598. Sin embargo, si aumenta alfa a 0.1, la precisión en los datos de entrenamiento se reducirá a 0.82, y en los datos de control aumentará a 0.62. Lo más probable es que, en un conjunto de datos más grande, la diferencia sea más significativa.

 model = MultinomialNB(0.1) model.fit(X, Y) # X_control, Y_control   ,   X  Y #        predicted = model.predict(X_control) #     score_test = accuracy_score(Y_control, predicted) #   report = classification_report(Y_control, predicted) 


Conclusión


Se supone que el modelo debe usarse para predecir revisiones cuyas palabras no se usaron para formar un vocabulario. Por lo tanto, la calidad del modelo puede evaluarse por su precisión en la parte de control de los datos, que es 0.62. Esto es casi dos veces mejor que solo adivinar, pero la precisión sigue siendo bastante baja.

Según el informe de clasificación, está claro que el modelo funciona peor con revisiones que tienen un color neutro (precisión 0.47 versus 0.68 para positivo y 0.76 para negativo). De hecho, las revisiones neutrales contienen palabras que son características de las críticas positivas y negativas. Probablemente, la precisión del modelo puede mejorarse aumentando el volumen del conjunto de datos, ya que el conjunto de datos número tres mil es bastante modesto. Además, sería posible reducir el problema a una clasificación binaria de revisiones en positivas y negativas, lo que también aumentaría la precisión.

Gracias por leer

PD: si quieres practicar tú mismo, mi conjunto de datos se puede descargar debajo del enlace.

Enlace al conjunto de datos

Source: https://habr.com/ru/post/467081/


All Articles