quarta-feira, 19 de julho de 2017

Usando o parser de Stanford com Python

Na última postagem, mostrei como treinar e usar o parser de Stanford com textos em português. Isso foi bastante útil para muita gente que precisa de análise sintática, mas outro ponto relevante é que muita coisa em PLN é feita em Python, enquanto o parser de Stanford é uma ferramente em Java.

Isso é bem inconveniente se você já tem ou pretende desenvolver um sistema em Python e só queria incluir o parser. Uma possível solução é fazer chamadas ao Java para rodar o parser para um determinado texto, o que até funciona. O problema é que cada chamada precisa carregar o parser em memória, o que leva um certo tempo. Esse tempo de leitura pode ser menos de um minuto dependendo do hardware, mas ao rodar com vários textos diferentes, torna-se um grande gargalo.



O ideal, naturalmente, é poder carregar o parser na memória uma única vez e aproveitar o modelo para anotar textos conforme forem enviados. E é aí que entra a ideia de um servidor CoreNLP (lembrando que CoreNLP é toda a suíte de ferramentas de PLN de Stanford), que recebe e responde requisições HTTP. Esse processo pode rodar localmente, no computador do desenvolvedor que está fazendo experimentos, ou em um servidor, o que é útil para aplicações web.

Então, o que precisamos para fazer isso funcionar? Basicamente, duas partes: o servidor e o cliente. 


Configurando o servidor


Boa notícia: o servidor já existe como parte do próprio CoreNLP. Mal começamos e já está 50% pronto! 

Se você for rodá-lo localmente no seu computador, é só entrar no diretório do CoreNLP e fazer uma chamada Java:

java -mx4g -cp "*" edu.stanford.nlp.pipeline.StanfordCoreNLPServer

A chamada solicita um máximo de 4 giga de memória, inclui os arquivos do diretório local para o classpath do Java e executa a classe StanfordCoreNLPServer. 

Por padrão, o processo vai escutar a porta 9000, e você pode selecionar uma outra acrescentando a opção -port e o número desejado. Se você estiver rodando em um servidor acessível pela internet, é possível que precise de alguma configuração para liberá-la. Isso já é outra história e não vou detalhar aqui.

Chamando o servidor


Agora, precisamos preparar a chamada ao nosso servidor. Vou apresentar primeiro um exemplo pronto e funcional, e depois explicar os passos:


import json
import urllib
import requests

def call_corenlp(text, dep_model_path, pos_model_path, ip, port):
    """
    Chama o parser do CoreNLP em um servidor.
    """
    properties = {'tokenize.whitespace': 'true',
                  'annotators': 'tokenize,ssplit,pos,depparse',
                  'depparse.model': dep_model_path,
                  'pos.model': pos_model_path,
                  'outputFormat': 'conllu'}

    # converte o dicionário em uma string
    properties_val = json.dumps(properties)

    # codifica os parâmetros com urllib para usar parâmetros GET. O conteúdo
    # do POST é o texto
    params = {'properties': properties_val}
    encoded_params = urllib.urlencode(params)
    url = 'http://{ip}:{port}/?{params}'.format(ip=ip, port=port,
                                                params=encoded_params)

    headers = {'Content-Type': 'text/plain;charset=utf-8'}
    response = requests.post(url, text.encode('utf-8'), headers=headers)
    response.encoding = 'utf-8'

    # apenas para ter certeza...
    output = response.text.replace('\0', '')

    return output


Parâmetros do CoreNLP

O dicionário properties inclui parâmetros do CoreNLP - cada um dos annotators (módulos como o POS tagger, parser, etc) do CoreNLP aceita um conjunto de parâmetros. A descrição de todos os parâmetros possíveis pode ser encontrada na página linkada na seção do servidor, mas infelizmente é bem fragmentada, com alguns que só podem ser entendidos perfeitamente olhando o código.

Os parâmetros que usei devem servir para a maioria dos casos:

  •  tokenize.whitespace indica que os tokens estão separados por espaço em branco (ou seja, você precisa já ter feito essa separação antes). Se não for indicado nada, é usado o tokenizador do Penn Treebank, o padrão para inglês.
  • annotators informa quais módulos do CoreNLP queremos rodar. Aqui, escolhi o tokenizador (necessário para todo o resto), separador de sentenças (ssplit), POS tagger e parser de dependências. Se você tiver outros módulos treinados e prontos, como extrator de informações ou identificador de entidades, eles seriam adicionados aqui.
  • depparse.model é o caminho para onde está salvo o modelo treinado do parser de dependência. Esse caminho é relativo ao diretório onde o servidor está sendo executado. Pode parecer estranho o cliente ter que saber onde o servidor armazena seus modelos, mas vamos dar um desconto - trata-se de software acadêmico, afinal!
  • pos.model Idem ao anterior, mas para o POS tagger.
  • outputFormat é o formato de saída, e o conllu é bem comum em PLN. Outras opções são json, xml e text. O formato text é o mais fácil de se visualizar e entender, mas não é bem estruturado como os outros. 

Preparação da Request

Após definir os parâmetros, precisamos preparar a request, isso é, a requisição que o cliente vai enviar para o servidor. O servidor CoreNLP espera receber uma requisição do tipo POST, cujo conteúdo é o texto para ser anotado, mas com uma URL parametrizada como num GET.

Se você não entende bem desses conceitos, não tem problema. Simplificando, é o seguinte: uma requisição POST é uma mensagem que o cliente envia para o servidor com algum dado acoplado. No nosso caso, esse dado é o texto para ser anotado. E para enviar uma mensagem ao servidor, precisamos saber onde encontrá-lo, o que é definido pela URL, e no nosso caso vai ser composta de ip, porta e alguns parâmetros. Por exemplo:
http://10.11.12.13:4567?param1=valor1&param2=valor2
Esses parâmetros da URL são onde vamos incluir os parâmetros do CoreNLP.

O trecho de código até a geração da variável url converte o dicionário de parâmetros para um formato compatível com uma URL.

Recebendo a resposta

O que resta agora é enviar a requisição para o servidor e aguardar a resposta. A primeira chamada pode demorar alguns segundos, pois o CoreNLP só carrega os modelos em memória quando precisa usá-los. Após isso, as próximas chamadas serão respondidas em questão de poucos milissegundos. 

No final do código, tem uma linha para remover o caracter \0. Eu a incluí por conta de um antigo bug no CoreNLP Server, que enviava a resposta com alguns caracteres desse no final. Não vi mais esse problema na última versão, mas também não faz mal deixar a linha ali. Esse problema me deu uma certa dor de cabeça quando aconteceu pela primeira vez!

Testando


Agora é só fazer uma chamada com o código. 

text = u'Processamento de língua natural é uma área muito interessante!'
output = call_corenlp(text, 'models/pt-br/dep-parser', 
                      'models/pt-br/pos-tagger.dat', 'localhost', 9000)
print(output)                      

E a saída:


1 Processamento _ _ NOUN _ 5 nsubj _ _
2 de _ _ ADP _ 1 adpmod _ _
3 língua _ _ NOUN _ 2 adpobj _ _
4 natural _ _ ADJ _ 3 amod _ _
5 é _ _ VERB _ 0 root _ _
6 uma _ _ DET _ 7 det _ _
7 área _ _ NOUN _ 5 attr _ _
8 muito _ _ ADV _ 9 advmod _ _
9 interessante! _ _ ADJ _ 7 amod _ _

Espero que seja útil para os leitores!

Um comentário:

  1. Olá, muito obrigado pela postagem.

    Pra quem usa Python 3, troca 'encoded_params = urllib.urlencode(params)' por 'encoded_params = urllib.parse.urlencode(params)'.

    O ssplit não estava funcionando aqui, então coloquei 'tokenize.whitespace': 'true' para 'false' e funcionou normalmente.

    ResponderExcluir