Não Tem Mosquito

As aventuras de se desenvolver software no nosso Patropi.

Sexta-feira, Setembro 26, 2008

Processando textos em Português usando Python


Minha mulher arrumou um bico em um livro a ser publicado. Tem que fazer uma pequena descrição de cada pessoa mencionada. Precisa ler todo o livro, marcar todos os nomes e só então fazer a pesquisa sobre cada um. Então me pergunta: "É fácil fazer um programa de computador para extrair todos os nomes próprios de um texto?"

Pausa dramática.

Na minha face apenas um riso besta e superior de quem sabe Python e expressões regulares. Vem então a resposta: "É mole!"

Sento no micro, faço umas pesquisas pra ver se já há algo pronto e desisto do que encontro. Abro o .doc no OpenOffice e salvo como texto. Faço umas perguntas para que ela me confirme o que será satisfatório. Começo a fazer de verdade e meia hora depois já tenho uma prova de conceito. Já estou "90% pronto"!

Estava certo que só faltava mais uns 90% e acabei gastando mais uns 360%. E tudo devido a pequenas pentelhações do Python. A cada obstáculo tinha que parar, googlar o problema ou ler a documentação da linguagem. Melhor compartilhar aqui as barreiras e soluções.

Locale

Palavra que começa com maiúsculas que não está no início de uma frase é um nome próprio. Este é quase todo o algoritmo. Funcionou até que me deparei um nome que começa com acento:
>>> print 'Água' == 'água'.capitalize()
False
>>> print 'água'.capitalize()
água
Ops! Aí eu lembro que pra que funcione direito precisamos do módulo locale. Na verdade este bicho de locale me morde há tanto tempo que por causa dele postei minha primeira mensagem na lista de discussão do Python. Foi há 10 anos! E o Guido em pessoa me respondeu. Tou mesmo ficando velho.
>>> import locale
>>> locale.setlocale(locale.LC_CTYPE, ('pt_BR', 'iso8859-1'))
'pt_BR.ISO8859-1'
>>> print 'água'.capitalize()
Água

Unicode

Agora no terceiro milênio, existe um novo tipo de dados nos programas: o texto. Aquele velho array de bytes, também chamado de string, ainda tem seus usos. Processamento de textos não é mais um deles. Para se ler dados deste novo tipo precisamos de um metadado, uma informação extra, que é o encoding. Sabendo o encoding, podemos usá-lo para converter qualquer cadeia de bytes e entrar no Maravilhoso Mundo do Unicode ™

Para usar Unicode, os programas modernos devem sempre trabalhar da seguinte forma:
  1. Lê os dados de input e os decodifica para Unicode usando o encondig;
  2. Faz todo o processamento interno manipulando o texto em Unicode;
  3. Na hora de fazer o output (escrever na tela, disco, transmitir) codifica o texto no enconding de output.
Assim seu software funcionará com todas a linguagens (humanas) do mundo, basta que sua linguagem (de programação) tenha suporte ao encoding.

E de onde vem o diabo deste metadado? Aí vai depender de onde você leu o texto. Se é um arquivo que você leu pela rede usando HTTP, será informado no header do protocolo. Se é digitado pelo usuário, usa-se a configuração padrão do sistema. Às vezes está escrito dentro do arquivo. Muitas vezes ninguém sabe. Para isto existem complexas bibliotecas de detecção de encoding. Outra alternativa é chutar.

Para nós brasileiros, há dois encodings que realmente importam: iso8859-1 e UTF-8. Aí é só testar. Se você abrir um arquivo e em toda letra acentuada tiver uma letra à com outro caracter, signfica que o arquivo está em UTF-8.

Para descobri em que enconding o OpenOffice gravou o arquivo, tentei decodificá-lo com o 8859-1 e o UTF-8:
>>> texto = open('arquivo.txt').read().decode('utf-8')
------------------------------------------------------------
Traceback (most recent call last):
File "", line 1, in
File "/usr/lib/python2.5/encodings/utf_8.py", line 16, in decode
return codecs.utf_8_decode(input, errors, True)
UnicodeDecodeError: 'utf8' codec can't decode bytes in position 10-12: invalid data

>>> texto = open('arquivo.txt').read().decode('iso8859-1')
>>> texto
u'texto com \xe1\xe7\xeant\xf5s'
>>> print texto
texto com áçêntõs
Tentei primeiro decodificar usando o UTF-8. Falhou. Foi a vez do iso8859-1. Este nunca dá erro, por isto é preciso conferir se os acentos foram corretamente interpretados. Tentando ver o valor de texto, aparece a letra u antes do plics, isto indica que é uma variável unicode. Com o comando print aparecem os acentos corretamente. A conversão foi feita com sucesso. Se não há letras acentuadas, o UTF-8, o iso8859-1 e o ASCII geram exatamente o mesmo arquivo.

Expressões Regulares

Agora é simples. Dividimos o texto em frases, quebramos cada frase em palavras e pegamos as palavras que começam com maiúsculas que não começam a frase. Quebrar um texto em palavras é trivial com a função split do poderoso módulo de expressões regulares do Python. Vamos usar a sequência especial \W, que casa com qualquer caracter que não seja palavra (\w casa com as palavras):
>>>import re
>>> re.split('\W+', u'açaí do bom')
[u'a', u'a', u'do', u'bom']
Falhou! Vendo a documentação descobrimos que — claro! — para o \w e \W funcionarem de acordo com a linguagem, temos que construir a expressão regular informando que o locale deve ser levado em consideração:
>>>locale.setlocale(locale.LC_CTYPE, ('pt_BR', 'iso8859-1'))
'pt_BR.ISO8859-1'
>>> palavrasRE = re.compile('\W', re.LOCALE)
>>> palavrasRE.split(u'açaí do bom')
[u'a\xe7a\xed', u'do', u'bom']

Ordenação

Agora tá tudo pronto. Estamos trabalhando com Unicode. O locale está para Português. Vamos listar os nomes que achamos e... A ordem vem errada!
>>>print ''.join(sorted(u'cbáa'))
abcá
Descubro então que existe um tipo de locale para caracteres (LC_CTYPE) que é usado para tornar uma string maiúscula, e um outro tipo de locale (LC_COLLATE) usado para ordenação. E enquanto o de string é usado automaticamente ao setar o locale, o de ordenação não. Precisamos passar a obscura função de comparação de locale na hora de fazer o sort:
>>> print ''.join(sorted(u'cbáa', cmp=locale.strcoll))
aábc
Caraca! Que coisa não-pythonica.

O Código

Agora que já sabemos escapar de todas as armadilhas, é só programar e correr para o abraço, beijos e carinhos que vamos ganhar de pagamento pelo sistema.

Como todo programa que faz qualquer tipo de parse, alguns testes unitários são absolutamente fundamentais. Para isto uso o doctest. Eles rodam quando qualquer parâmetro é passado.

Eis como ficou o código final:

#!/bin/env python
# -*- coding: utf-8 -*-

import textwrap
import sys
import re
import locale
locale.setlocale(locale.LC_CTYPE, ('pt_BR', 'ISO8859-1'))
locale.setlocale(locale.LC_COLLATE, ('pt_BR', 'ISO8859-1'))

pontuacaoRE = re.compile(r'[.!?:"\n]')
def divideFrases(texto):
return [frase.strip() for frase in pontuacaoRE.split(texto) if frase.strip()]


palavrasRE = re.compile('(\W+)', re.LOCALE) #o grupo fará que o split retorne os separadores
def dividePalavras(frase):
return palavrasRE.split(frase)


def isNome(palavra):
return (palavra == palavra.capitalize() and
palavra.isalpha())


def extraiNomes(frase):
"""
#estou ignorando: 'Paulo Eduardo de Almeida' e 'DeWitt'
>>> extraiNomes(u'Tem um Nome')
[u'Nome']
>>> extraiNomes(u'Tem um Nome Composto')
[u'Nome Composto']
>>> extraiNomes(u'Nome Composto começa frase')
[u'Nome Composto']
>>> extraiNomes(u'Nome simples começa frase')#dispenso este caso
[]
>>> extraiNomes(u'O Nome Composto com preposicao')
[u'Nome Composto']
>>> extraiNomes(u'Eles são Fulano, Sicrano, Beltrano')
[u'Fulano', u'Sicrano', u'Beltrano']
>>> extraiNomes(u'Aqui tem O título ')
[]
>>> extraiNomes(u'Aqui tem Um título ')
[]
"""
nomes = []
pos = 1 #pulo primeira palavra
palavras = dividePalavras(frase)
while pos < len(palavras):
nome = '';
while (pos < len(palavras) and
palavras[pos] and
(isNome(palavras[pos]) or palavras[pos].isspace()) ):
if not palavras[pos].isspace():
nome += ' ' + palavras[pos]
if pos == 2 and len(palavras[0]) > 2: #inclui primeira quando segunda palavra começa com maiúscula
nome = palavras[0] + nome
pos += 1
if len(nome.strip()) > 2: #ignoro nomes muito curtos
nomes.append(nome.strip())
pos += 1
return nomes


def processaTexto(nomeArq):
todasFrases = divideFrases(open(nomeArq).read())
todosNomes = {};
for frase in todasFrases:
nomes = extraiNomes(frase)
for nome in nomes:
todosNomes.setdefault(nome, []).append(frase)
return todosNomes


def printNomes(todosNomes):
"""Imprime nomes e as frases em que eles são mencionados para termos o contexto"""
nomes = todosNomes.keys()
nomes.sort(cmp=locale.strcoll)
for nome in nomes:
print nome
for frase in todosNomes[nome]:
print textwrap.fill('- ' + frase, initial_indent=' '*8, subsequent_indent=10*' ')
print


def agregaDicts(d1, d2):
agregado = d1.copy()
for chave, valor in d2.items():
agregado.setdefault(chave, []).extend(valor)
return agregado


def main():
import glob
arquivos = glob.glob('depoimentos/*.txt')
todosNomes = {}
for arquivo in arquivos:
todosNomes = agregaDicts(todosNomes, processaTexto(arquivo))
printNomes(todosNomes)

def _test():
import doctest
doctest.testmod()


if __name__ == '__main__':
if len(sys.argv) == 1:
main()
else:
_test()

Marcadores: