Entrada
O Processamento de linguagem natural (PNL) é uma área popular e importante do aprendizado de máquina. Neste hub, descreverei meu primeiro projeto relacionado à análise da coloração emocional de resenhas de filmes escritas em Python. A tarefa da análise sentimental é bastante comum entre aqueles que desejam dominar os conceitos básicos da PNL e pode se tornar um análogo do 'Hello world' nesta área.
Neste artigo, abordaremos todas as principais etapas do processo de ciência de dados: desde a criação de seu próprio conjunto de dados, processamento e extração de recursos usando a biblioteca NLTK e finalmente aprendendo e ajustando o modelo usando o scikit-learn. A tarefa em si é classificar as revisões em três classes: negativa, neutra e positiva.
Formação de Corpus de Dados
Para resolver esse problema, pode-se usar um corpo de dados já anotado e com análises do IMDB, dos quais existem muitos no GitHub. Mas foi decidido criar o seu próprio com comentários em russo, retirados do Kinopoisk. Para não copiá-los manualmente, escreveremos um analisador da web.
Usarei a biblioteca de
solicitações para enviar
solicitações HTTP e o
BeautifulSoup para processar arquivos html. Primeiro, vamos definir uma função que terá um link para as críticas de filmes e as recuperará. Para que o Kinopoisk não reconheça o bot em nós, você precisa especificar o argumento
headers na função orders.get, que simulará o navegador. É necessário passar um dicionário para ele com as chaves User-Agent, Accept-language e Accept, cujos valores podem ser encontrados nas ferramentas do desenvolvedor do navegador. Em seguida, um analisador é criado e as revisões são armazenadas na página, que são armazenadas na classe de marcação _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)
Nós nos livramos da marcação html, no entanto, nossos comentários ainda são objetos
BeautifulSoup , mas precisamos convertê-los em strings. A função de
conversão faz exatamente isso. Também escreveremos uma função que recupera o nome do filme, que mais tarde será usada para salvar críticas.
def convert(reviews):
A última função do analisador terá um link para a página principal do filme, uma classe de crítica e uma maneira de salvar críticas. A função também define
atrasos entre solicitações necessárias para evitar uma proibição. A função contém um loop que recupera e armazena revisões a partir da primeira página, até encontrar uma página inexistente da qual a função
load_data extrairá uma lista vazia e o loop será interrompido.
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))
Em seguida, usando o ciclo a seguir, você pode extrair resenhas de filmes que estão na lista de
URLs . Uma lista de filmes precisará ser criada manualmente. Seria possível, por exemplo, obter uma lista de links para filmes escrevendo uma função que os extraísse dos 250 principais filmes de busca de filmes, para não fazer manualmente, mas 15 a 20 filmes seriam suficientes para formar um pequeno conjunto de dados de mil críticas para cada classe. Além disso, se você receber uma proibição, o programa exibirá em qual filme e classe o analisador parou para continuar do mesmo local após a aprovação.
path =
Pré-tratamento
Depois de escrever um analisador, relembrando filmes aleatórios para ele e várias proibições de uma pesquisa de filmes, misturei as resenhas em pastas e selecionei 900 resenhas de cada classe para treinamento e o restante para o grupo de controle. Agora é necessário pré-processar o alojamento, ou seja, tokenizar e normalizá-lo. Tokenizar significa dividir o texto em componentes, neste caso em palavras, pois usaremos a representação de um conjunto de palavras. E a normalização consiste em converter palavras em minúsculas, remover palavras de parada e excesso de ruído, golpes e outros truques que ajudam a reduzir o espaço dos sinais.
Importamos as bibliotecas necessárias.
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
Começamos definindo algumas pequenas funções para o pré-processamento de texto. O primeiro, chamado
lower_pos_tag, fará uma lista com palavras, as converterá em minúsculas e salvará cada token em uma tupla com sua parte do discurso. A operação de adição de parte do discurso a uma palavra é chamada de marcação Parte do discurso (POS) e é frequentemente usada na PNL para extrair entidades. No nosso caso, usaremos partes do discurso na função a seguir para filtrar palavras.
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
Os textos contêm um grande número de palavras que muitas vezes são úteis para o modelo (as chamadas palavras de parada). Basicamente, são preposições, conjunções, pronomes pelos quais é impossível determinar a que lembrança de classe se refere. A função
clean deixa apenas substantivos, adjetivos, verbos e advérbios. Observe que ele remove partes do discurso, pois não são necessárias para o modelo em si. Você também pode perceber que essa função usa stamming, cuja essência é eliminar sufixos e prefixos de palavras. Isso permite reduzir a dimensão dos sinais, pois as palavras com diferentes gêneros e casos serão reduzidas para o mesmo token. Existe um análogo mais poderoso de stamming - lematization, que permite restaurar a forma inicial da palavra. No entanto, funciona mais devagar do que stamming e, além disso, o NLTK não possui um lematizador russo.
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
Em seguida, escrevemos a função final que receberá o rótulo da classe e recuperará todas as revisões com essa classe. Para ler o caso, usaremos o método
bruto do objeto
PlaintextCorpusReader , que permite extrair texto do arquivo especificado. Em seguida, a tokenização é usada RegexpTokenizer, trabalhando com base em uma expressão regular. Além das palavras individuais, adicionei ao modelo os bigrams, que são combinações de todas as palavras vizinhas. Essa função também usa o objeto
FreqDist , que retorna a frequência de ocorrência de palavras. É usado aqui para remover palavras que aparecem em todas as revisões de uma classe específica apenas uma vez (elas também são chamadas de hapaks). Assim, a função retornará um dicionário contendo documentos apresentados como um conjunto de palavras e uma lista de todas as palavras para uma classe específica.
corpus_root =
O estágio de pré-processamento é o mais longo, por isso faz sentido paralelizar o processamento do nosso caso. Isso pode ser feito usando o módulo de
multiprocessamento . Na próxima parte do código do programa, inicio três processos que processarão simultaneamente três pastas com classes diferentes. Em seguida, os resultados serão coletados em um dicionário. Esse pré-processamento está concluído.
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()
Vetorização
Depois de pré-processarmos o caso, temos um dicionário em que, para cada rótulo de classe, há uma lista com revisões que simbolizamos, normalizamos e enriquecemos com bigrams, além de uma lista de palavras de todas as revisões desta classe. Como o modelo não pode perceber a linguagem natural como nós, a tarefa agora é apresentar nossas revisões em forma numérica. Para fazer isso, criaremos um vocabulário comum, composto por tokens exclusivos, e com ele vetorizaremos cada revisão.
Para começar, criamos uma lista que contém revisões de todas as classes, juntamente com seus rótulos. Em seguida, criamos um vocabulário comum, tirando de cada classe 10.000 das palavras mais comuns usando o método
most_common do mesmo
FreqDist . Como resultado, obtive um vocabulário composto por cerca de 17.000 palavras.
Existem várias maneiras de vetorizar o texto. O mais popular deles: TF-IDF, codificação direta e de frequência. Usei a codificação de frequência, cuja essência é apresentar cada revisão como um vetor, cujos elementos são o número de ocorrências de cada palavra do vocabulário.
O NLTK possui seus próprios classificadores, você pode usá-los, mas eles funcionam mais lentamente que os colegas do
scikit-learn e têm menos configurações. Abaixo está o código para codificação do
NLTK . No entanto, usarei o modelo Naive Bayes do
scikit-learn e codificarei as revisões, armazenando os atributos em uma matriz esparsa do
SciPy e os rótulos da classe em uma matriz
NumPy separada.
Como no conjunto de dados, as revisões com determinadas tags passam uma após a outra, ou seja, primeiro todas neutras, depois todas negativas e assim por diante, é necessário misturá-las. Para fazer isso, você pode usar a função
aleatória no
scikit-learn . É adequado apenas para situações em que sinais e rótulos de classe estão em matrizes diferentes, porque permite misturar duas matrizes em uníssono.
Modelo de treinamento
Agora resta treinar o modelo e verificar sua precisão no grupo de controle. Como modelo, usaremos o modelo do classificador Naive Bayes.
O Scikit-learn possui três modelos do Naive Bayes, dependendo da distribuição dos dados: binário, discreto e contínuo. Como a distribuição de nossos recursos é discreta, escolhemos
MultinomialNB .
O classificador bayesiano possui o hiper
parâmetro alfa , responsável por suavizar o modelo. Naive Bayes calcula as probabilidades de cada revisão pertencente a todas as classes, multiplicando as probabilidades condicionais da aparência de todas as palavras de revisão, desde que elas pertençam a uma classe específica. Porém, se alguma palavra de revisão não foi encontrada no conjunto de dados de treinamento, sua probabilidade condicional é igual a zero, o que anula a probabilidade de a revisão pertencer a qualquer classe. Para evitar isso, por padrão, uma unidade é adicionada a todas as probabilidades de palavras condicionais, ou seja,
alfa é igual a uma. No entanto, esse valor pode não ser o ideal. Você pode tentar selecionar
alfa usando a pesquisa em grade e a validação 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_
No meu caso, o coração da grade fornece o valor ideal do hiperparâmetro igual a 0 com uma precisão de 0,965. No entanto, esse valor obviamente não será o ideal para o conjunto de dados de controle, pois haverá um grande número de palavras que não foram encontradas anteriormente no conjunto de treinamento. Para um conjunto de dados de referência, este modelo tem uma precisão de 0,598. No entanto, se você aumentar
alfa para 0,1, a precisão nos dados de treinamento cairá para 0,82, e nos dados de controle, aumentará para 0,62. Provavelmente, em um conjunto de dados maior, a diferença será mais significativa.
model = MultinomialNB(0.1) model.fit(X, Y)
Conclusão
Supõe-se que o modelo deva ser usado para prever revisões cujas palavras não foram usadas para formar um vocabulário. Portanto, a qualidade do modelo pode ser avaliada por sua precisão na parte de controle dos dados, que é 0,62. Isso é quase duas vezes melhor do que adivinhar, mas a precisão ainda é bastante baixa.
De acordo com o relatório de classificação, é claro que o modelo apresenta o pior desempenho com avaliações de cor neutra (precisão 0,47 versus 0,68 para positivo e 0,76 para negativo). De fato, as revisões neutras contêm palavras que são características das críticas positivas e negativas. Provavelmente, a precisão do modelo pode ser aprimorada aumentando o volume do conjunto de dados, já que o conjunto de três milésimos de dados é bastante modesto. Além disso, seria possível reduzir o problema a uma classificação binária de análises em positiva e negativa, o que também aumentaria a precisão.
Obrigado pela leitura.
PS Se você quiser praticar, meu conjunto de dados pode ser baixado abaixo do link.
Link para o conjunto de dados