Testando o Código


Erros no código

É natural que ao escrever código, principalmente em projetos grandes, sempre sejam introduzidos erros. Existem três tipos básicos de erros:

  1. erros de sintaxe são o tipo mais básico e os mais fáceis de serem encontrados. Eles ocorrem quando uma ou mais linhas de código estão escritas incorretamente de forma que o interpretador do Python não consegue processá-las. Eles são quase sempre fatais, impedindo que o código seja executado. Eles são, em geral, erros de digitação, indentação incorreta ou argumentos incorretos passados para funções e classes. Por ex.: print "esqueci o parênteses" não é uma linha válida no python 3.x (embora esteja correta no python 2.x).
  2. erros de tempo de execução ocorrem quando a sintaxe está correta mas o interpretador não pode executar a ação. Isso pode ser causado pelo uso de uma variável não definida, um loop infinito, uma divisão por zero, etc.
  3. erros lógicos são os mais difíceis de serem detectados. O código roda sem interrupções mas não executa a tarefa proposta. Ele pode envolver uma operação matemática incorreta, uso incorreto de índices (como começar no indíce 1, e não 0), um loop interrompido prematuramente, etc.


IDES com preenchimento de código e realce de sintaxe ajudam bastante a evitar os dois primeiros tipos de erros. Frameworks de testes podem ser úteis na depuração de erros lógicos.

O processo de se buscar erros (bugs) no código pode (e deve) ser usado várias vezes durante sua construção. Mas, uma vez finalizado, pelo menos em etapa, o código deve ser testado para verificação de que está realizando corretamente a tarefa proposta. Esses últimos testes devem ser os mais amplos possíveis pois sempre podem aparecer erros não cobertos pelas tentativas prévias de checagem. Mesmo projetos completos e bem testados podem necessitar de novas versões com a inserção de novas funcionalidades ou aprimoramento das que existem. Inserir código em um projeto que já existe é uma prática sensível à introdução de erros.

Embora possam excluir muitos possíveis erros, os teste não são capazes de detectar todos os erros de um código porque é praticamente impossível avaliar seu comportamento sob todos os caminhos de execução, em projetos não triviais. Por isso é importante projetar testes que verificam aspectos do código tão amplos quanto possível.

Além dos testes de correção lógica do código, diversos outros aspectos devem ser testados:

  • performance de execução,
  • robustez do aplicativo sob alta demanda ou uso prolongado, principalmente em aplicativos web,
  • capacidade de implantação e instalação nas plataformas alvo,
  • adaptabilidade às atualizações de versões (quando existirem),
  • habilidade realização de backups de dados e do próprio aplicativo e seu estado,
  • segurança do sistema sob ação do aplicativo e capacidade de recuperação em caso de falha.

Existem muitos recursos disponíveis aos desenvolvedores para testes de código em python. A mais simples delas consiste em inserir verificações assert em pontos críticos do código.

Declaração ASSERT


Vimos na seção sobre tratamentos de erros como levantar uma exceção com assert. Podemos usar assert para inserir no código um teste que gera uma exceção caso uma expressão não seja verdadeira.

A sintaxe é: assert teste_booleano [, mensagem], que lança um AssertionError com uma mensagem opcional.

Para recordar o comportamento de assert definimos uma função que lança um erro quando seu argumento é maior que 3, emitindo uma mensagem de erro.

def menor_que_4(i):
    assert i < 4, 'O número deve ser menor que 4'
    dic={1:'um', 2:'dois', 3:'três'}
    return dic[i]

for i in range(1,5):
    try:
        print (menor_que_4(i))
    except AssertionError as msg:
        print(msg)
        
# esse código gera o output
  um
  dois
  três
  O número deve ser menor que 4

Essa é uma forma de se assegurar que um valor está em conformidade com o esperado e, caso contrário, descobrir que valor ofendeu a condição imposta. Se temos vários asserts no código saberemos também em que módulo e linha o erro ocorreu.

Variável __debug__: Caso existam muitas declarações assert no código, além de torná-lo mais extenso e menos legível, pode haver impacto no desempenho do aplicativo. Você pode remover ou comentar todas as declarações (o que não é difícil com um bom editor ou IDE) mas, nesse caso, terá que retornar com todas elas se precisar modificar o projeto.

Declarações assert funcionam junto com a variável interna (built-in) do Python __debug__, que é True por default. Internamente a declaração assert i < 4 é equivalente a:

if __debug__:
    if not i < 4:
        raise AssertionError

Se marcarmos __debug__ == False o teste de assert não será executado. Ocorre que não podemos atribuir valores a essa variável no código, como __debug__ == False. Para isso é necessário definir a variável de ambiente PYTHONOPTIMIZE ou executar o Python com a opção – O. Assim podemos ter todos os testes de assert ativos na fase de desenvolvimento e desligados na produção. Também é possível remover as instruções assert e as docstrings ao compilar o código com compileall. (Leia sobre compilação em Módulos e Pacotes.)

Docstrings

Um mecanismo mais poderoso que lançar erros em pontos específicos consiste em usar docstrings contendo testes e seus resultados, e usar o módulo doctest. (Leia mais sobre doscstrings.)

O módulo doctest procura por trechos na docstring com o formato de sessões interativas do Python e executa essas linhas de comando para conferir o output proposto. O doctest pode:

  • verificar se as docstrings estão atualizadas, conferindo se os exemplos interativos funcionam como documentado.
  • realizar testes de regressão para verificar se os exemplos interativos de um arquivo (ou objeto) em teste funcionam conforme o esperado.
  • facilitar a composição de documentos tutoriais sobre um pacote com com exemplos de entrada-saída. Se corretamente estruturados esses documentos podem ser considerados uma “documentação executável”.

Por exemplo, gravamos o arquivo testando.py, incluindo as linhas import doctest e doctest.testmod() no bloco de inicialização.

# testando.py
def fatorial(n):
    """Retorna fatorial de n inteiro, onde n >= 0.

    >>> [fatorial(n) for n in range(6)]
    [1, 1, 2, 6, 24, 120]
    >>> fatorial(30)
    265252859812191058636308480000000
    """

    if n ≤ 1:
        return 1
    else:
        return n * fatorial(n-1)

if __name__ == "__main__":
    import doctest
    doctest.testmod()

Quando esse script é executado diretamente o módulo doctest é importado e a documentação é “executada” em suas linhas de código e conferida com os outputs fornecidos. Se todos os testes forem bem sucedidos nenhuma mensagem será exibida.

# 1º teste
$ python testando.py

Introduzindo erros: Para efeito de teste vamos introduzir um erro proposital, alterando a linha >>> fatorial(30) para >>> fatorial(20) nas linhas do docstring. O output proposto agora está incorreto, e isso será mostrado no output de doctest.

# 2º teste
$ python testando.py
  **********************************************************************
  File "testando.py", line 6, in __main__.fatorial
  Failed example:
      fatorial(20)
  Expected:
      265252859812191058636308480000000
  Got:
      2432902008176640000
  **********************************************************************
  1 items had failures:
     1 of   2 in __main__.fatorial
  ***Test Failed*** 1 failures.

O resultado aponta como errônea a linha 6 do “testando.py”, como seria esperado.
Módulos executados com

if __name__ == "__main__":
    import doctest
    doctest.testmod()

executarão todas as suas docstrings. Alternativamente, podemos executar o arquivo com o sinalizador python testando.py -v, o que resultará em um output mais extenso e pormenorizado.

Outra forma interessante de se usar o doctest é passando um arquivo de texto como parâmetro para análise de suas linhas de código e output. Um arquivo de texto, digamos que sobre_python.txt (que pode ser parte de um livro, digamos) é gravado com testes idênticos aos de um docstring. Em seguida fazemos:

import doctest
doctest.testfile("sobre_python.txt")

O arquivo sobre_python.txt não precisa ser completo nem conter todas as definições de funções ou módulos usados. Nesse caso as funções e módulos testadas (ou necessárias para o teste) devem ser importadas.

    Sobre o módulo "testando"
    =========================
    Uso da função ``fatorial``
    -------------------
    Importe o módulo e função
    >>> from testando import fatorial
    Agora você pode usar:
    >>> fatorial(6)
    120

Existem algumas formas de alterar a forma como doctest lê as docstrings. As mais comuns são +ELLIPSIS (significando que um sinal de reticência representa qualquer substring) e +NORMALIZE_WHITESPACE (que força o tratamento de qualquer sequência de espaços em branco da mesma forma). Isso fazer isso basta inserir um comentário com a forma de # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE, onde o sinal + ativa a instrução. Os testes abaixos são bem sucedidos:

>>> lista = [2, 4, 6, 8, 10]
>>> lista # doctest: +ELLIPSIS
    [2, ..., 10]
>>> lista # doctest: +NORMALIZE_WHITESPACE
    [2,4,   6,8,     10]

Para desativar as instruções usamos , como em # doctest: -ELLIPSIS, -NORMALIZE_WHITESPACE. As instruções podem ser usadas em conjunto.

Ao realizar testes com doctest alguns cuidados devem ser tomados quando se compara objetos que podem ser retornados em diferentes ordens. Por exemplo, dicionários não são objetos ordenados, e um teste supondo uma ordem específica pode falhar.

# Testando valores em um dicionário
>>> capitais = {"Belo Horizonte":"MG", "São Paulo": "SP", "Rio de Janeiro":"RJ"}
>>> capitais
{"Belo Horizonte":"MG", "São Paulo": "SP", "Rio de Janeiro":"RJ"}

O teste acima falhará se o dicionário for retornado em ordem diferente. Uma solução consiste em testar por cada chave ou ordenar o dicionário a ser
testado. Como dicionários não possuem elementos com chaves repetidas, o mais apropriado é ordenar por chaves (key).

# Testando valores em um dicionário com ordenação
>>> capitais = {"Belo Horizonte":"MG", "São Paulo": "SP", "Rio de Janeiro":"RJ"}

>>> ordenado = dict(sorted(capitais.items(), key=lambda i: i[0]))
>>> ordenado
{"Belo Horizonte":"MG", "Rio de Janeiro":"RJ", "São Paulo": "SP"}

Além disso, como linhas vazias são consideradas marcas para terminar o processamento doctest, se linhas em branco fazem parte do output esperado é necessário inserir uma linha com <BLANKLINE>. Para inserir caracteres \ como escape de outro caracter ou para marcar continuação de linha, o string do docteste deve ser raw, ou seja, precedido por r.

Doctestes são úteis e devem ser usados, mas podem ficar grandes e pesados para projetos maiores. Outras formas de testagem estão disponíveis no python.

Testes unitários com unittest

Testes unitários permitem que unidades de código possam ser testadas em diversas de suas características. Uma unidade pode ser uma função individual, um método ou procedimento de uma classe ou objeto. Ele é feito durante o desenvolvimento pelo programador.

O módulo unittest, incluído na biblioteca padrão, fornece ferramentas para testes unitários. Com ele podemos projetar um conjunto de testes que verificam se uma função (por exemplo) se comporta como o esperado sob situações variadas. Um bom conjunto de testes considera os possíveis tipos de entrada que uma função pode receber, incluindo testes de cada dessas situações. Uma cobertura completa de testes, em um projeto grande, pode ser muito difícil e, nesses casos pode ser considerado suficiente cobrir os casos críticos de uso do bloco testado. Diversos editores e IDEs, incluindo Jupyter Notebook, PyCharm e VSCode, podem usar unittest integrado.

Para usar unittest vamos escrever uma função a ser testada. Em seguida importamos o módulo unittest e criamos uma classe que herda de unittest.TestCase. Objetos dessa classe chamam e verificam o comportamento dessa função testada ao serem inicializados. Métodos diversos podem ser inseridos para verificar o funcionamento da função sob a inserção de parâmetros diferentes.

Para observar o funcionamento dos testes unitários vamos gravar dois arquivos do python, formata_nomes.py e nomes.py. O primeiro contém a função que queremos testar, o segundo chama essa função.

#formata_nomes.py

def ler_nome_formatado(nomes):
    msg = ""
    if nomes.strip() == "":
        msg = ""
    partes = nomes.split()
    nome = partes[0].title()
    msg = f"Primeiro nome: {nome}"
    if len(partes) > 1:
        sobre = " ".join(partes[1:]).title()
        msg = f"{msg}, Sobrenome: {sobre}"
    return msg

Essa função recebe nomes e sobrenomes separados por espaços e retorna esse nome formatado como Primeiro nome: nome, Sobrenome: sobrenomes . Ela considera todas as palavras após a primeira como sobrenome. Para usar essa função gravamos e executamos o arquivo nomes.py.

from formata_nome import ler_nome_formatado as nf
print("Digite nome e sobrenomes.")
print("Deixe em branco para terminar.")
while True:
    nomes = input("\nDigite o nome completo: ")
    formatado = nf(nomes)
    if formatado=="": break
    print(f"\tNome formatado: {formatado}.")

Podemos iniciar uma sessão no console (terminal) e executar python nomes.py. O output aparece no código abaixo.

$ python nomes.py
  Digite nome e sobrenomes.
  Deixe em branco para terminar.

  Digite o nome completo: PEDRO
	  Nome formatado: Primeiro nome: Pedro.

  Digite o nome completo: pedro de alcantara
	  Nome formatado: Primeiro nome: Pedro, Sobrenome: De Alcantara.

  Digite o nome completo: pedro II
	  Nome formatado: Primeiro nome: Pedro, Sobrenome: Ii.

  Digite o nome completo:

Na última linha foi inserida uma string vazia, o que termina o loop. Aparentemente a função retorna o que se espera. Mesmo assim vamos testar nossa função: em um novo módulo importamos unittest e a função que pretendemos testar. Depois criamos uma classe que herda de unittest.TestCase e acrescentamos diversos métodos para verificar aspectos diferentes da função. Cada um dos métodos test_1, test_2, test_3 verifica um comportamento da função para diferentes tipos de inputs.

# teste_formata_nomes.py
import unittest
from formata_nome import ler_nome_formatado as nf

class TestaFormataNomes(unittest.TestCase):
    """Testes para 'formata_nome.py'."""

    def test_1(self):
        """testando o nome 'palito'."""

        formatado = nf('palito')
        self.assertEqual(formatado, 'Primeiro nome: Palito')

    def test_2(self):
        """testando nomes com maísculas."""

        formatado = nf('MARCO POLO')
        self.assertEqual(formatado, 'Primeiro nome: Marco, Sobrenome: Polo')

    def test_3(self):
        """testando strings vazias."""

        formatado = nf('')
        self.assertEqual(formatado, '')

if __name__ == '__main__':
    unittest.main()

Podemos rodar esse teste e observar seu output:

$ python teste_formata_nome.py
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK

Nenhum erro foi encontrado em nenhum dos três testes, como mostrado no console. Essa classe pode ter qualquer nome embora seja boa ideia dar um nome representativo de seu objetivo. Ela contém três métodos para testar a função formata_nome.ler_nome_formatado. Qualquer classe que herda de unittest.TestCase executa automaticamente todos os seus métodos que começam com test_ quando é invocada. O retorno da função testada é comparado com o resultado na linha self.assertEqual(formatado, 'string esperada') (um dos método de unittest.TestCase) e gera a informação sobre se o teste foi bem sucedido ou não, com as devidas mensagens.

O bloco if no final, como já vimos, verifica o valor da variável especial __name__. Se o arquivo estiver sendo rodado como programa principal, como ocorreu no nosso caso, ela assume o valor __name__ = "__main__". Nesse caso unittest.main() é chamado e os testes executados.

Suponha que queremos expandir nossa função ler_nome_formatado para que ela retorne uma mensagem de erro caso algum dígito esteja entre os caracteres dos nomes. Se um nome for digitado como “lu1s quinze” a função deve retornar: “Erro: dígito encontrado!”

Vamos então acrescentar um teste em teste_formata_nomes.py. O código abaixo mostra só o acréscimo ao arquivo.

# teste_formata_nomes.py
...
    def test_4(self):
        """testando dígitos no nome."""

        formatado = nf('lu1z paulo')
        self.assertEqual(formatado, 'Erro: dígito encontrado!')
...

Rodamos o teste novamente: desta vez um nome inserido com um dígito não retorna o resultado correto e uma mensagem de erro informa qual o teste falhou, onde e porque.

$ python teste_formata_nome.py
...F
======================================================================
FAIL: test_4 (__main__.TestaFormataNomes)
testando dígitos no nome.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "teste_formata_nome.py", line 29, in test_4
    self.assertEqual(formatado, 'Erro: dígito encontrado!')
AssertionError: 'Lu1Z Paulo' != 'Erro: dígito encontrado!'
- Lu1Z Paulo
+ Erro: dígito encontrado!
----------------------------------------------------------------------
Ran 4 tests in 0.001s
FAILED (failures=1)

Claro que esse defeito deve ser corrigido em formata_nomes.py. Alteramos o código da seguinte forma:

# formata_nomes.py
def ler_nome_formatado(nomes):
    msg = ""
    if nomes.strip() == "":
        msg = ""
    elif True in [i.isdigit() for i in nomes]:
        msg = "Erro: dígito encontrado!"
    else:
        partes = nomes.split()
        nome = partes[0].title()
        msg = f"Primeiro nome: {nome}"
        if len(partes) > 1:
            sobre = " ".join(partes[1:]).title()
            msg = f"{msg}, Sobrenome: {sobre}"
    return msg

No código temos a condição True in [i.isdigit() for i in nomes] que testa cada caracter da variável de string nomes, retornando uma lista de valores booleanos. O teste resulta verdadeiro se uma ou mais das entradas dessa lista for True, ou seja, se existirem dígitos no nome. Com essa alteração rodarmos o teste mais uma vez e veremos que todas as condições testadas foram satisfeitas.

$ python teste_formata_nome.py
...
----------------------------------------------------------------------
Ran 4 tests in 0.000s
OK

Para ignorar um dos testes podemos decorar a função com @unittest.skip.

...
    @unittest.skip('Esse teste já foi executado!')
    def test_4(self):
        formatado = nf('lu1z paulo')
        self.assertEqual(formatado, 'Erro: dígito encontrado!')
...

Uma classe inteira pode ser ignorada.

@unittest.skip("Uma classe a ser ignorada")
class Classe_de_Teste(unittest.TestCase):
    def um_metodo_qualquer(self):
        pass

Os seguintes decoradores estão disponíveis na classe:

@unittest.skip(msg) ignore o teste em qualquer caso,
@unittest.skipIf(bool, msg) ignore o teste se bool==True,
@unittest.skipUnless(bool, msg) ignore o teste, exceto se bool==True,
@unittest.expectedFailure marca o teste como falha esperada. Se o teste falhar mensagem de sucesso é emitida e, se passar um erro é lançado,
exception unittest.SkipTest(msg) Uma exceção é levantada ao ignorar em teste,

msg é a mensagem retornada com a exceção, que deve ser descritiva do problema ocorrido. bool é qualquer expressão que retorne um booleano.

Bibliografia

Livros:

  • Ceder, Vernon; Mcdonald, Kenneth: The Quick Python Book, 2nd. Ed., Manning, Greenwich, 2010.
  • Hunt, John: Advanced Guide to Python 3 Programming, Springer, Suíça, 2019. Disponível em Academia.edu.
  • Romano, F., Phillips, D., Hattem, R.: Python: Journey from Novice to Expert, Packt Publishing, 2016.

Sites:

todos eles visitados em março de 2020.




Testes: unittest

Módulos e Pacotes

Executando o código

Podemos digitar comandos em sessões interativas do python diretamente em uma sessão do terminal, e ler nele os outputs das linhas executadas. Vários editores e IDEs (Integrated Development Environment), como Pycharm e VSCode, oferecem acesso a um terminal onde o código pode ser executado diretamente sem abandonar o ambiente de desenvolvimento. O Jupyter Notebook também roda de modo interativo, exibindo em suas células o resultado de blocos de código.

# sessão interativa do python (no terminal)
$ python
  Python 3.12.0 (main, Oct  2 2023, 00:00:00) [GCC 13.2.1 20230918 (Red Hat 13.2.1-3)] on linux
  Type "help", "copyright", "credits" or "license" for more information.
  >>> lista = ["sogra","da","Casa"]
  >>> for i in lista[::-1]:
  ...     print(i, end = " ")
  Casa da sogra

Para programas mais extensos, e que devem ser executados várias vezes, torna-se mais prático gravar em um arquivo as linhas de código e executá-lo depois, em bloco. Esses arquivos são chamados de scripts e podem ser divididos em múltiplos arquivos, se isso for necessário para facilitar seu desenvolvimento e manutenção.

Módulos

Uma prática comum consiste em gravar arquivos separados com um conjunto de funções que podem ser usadas em outras partes do mesmo projeto ou em projetos diferentes, o que facilita a reutilização do código. Esses arquivos são os módulos do Python. Ele devem ser gravados com extensão .py e seu nome (sem a extensão) é o nome do módulo, usado na sua importação. Dentro de cada módulo seu nome está disponível na variável global __name__.

Por exemplo, gravamos um arquivo com o nome texto.py com duas funções. Uma delas remove as letras duplicadas de uma string (banana ↦ ban), e outra retorna strings com suas letras invertidas entre maiúscula e minúsculas (AzUlEjO ↦ aZuLeJo).

# arquivo texto.py
def remove_duplicadas(palavra):
    removida, foi = "", ""
    for i in palavra:
        if not i in foi:
            removida += i
        foi += i    
    return removida

def inverte_caso(palavra):
    t = [x.upper() if x.islower() else x.lower() for x in palavra]
    return "".join(t)

Para usar essas funções criamos agora um arquivo que importa texto.py, dando a ele o nome de usa_texto.py.

# usa_texto.py
# (1º bloco)    
import texto as tx
print(tx.remove_duplicadas("inconstitucionalissimamente"))
print(tx.inverte_caso("rUA DA bAHIA"))

Em seguida o executamos no prompt do terminal, estando todos na mesma pasta:

$ python usa_texto.py
  incostualme
  Rua da Bahia

Outras formas de importação para usa_texto.py estão listadas abaixo, todas com o mesmo resultado no output:

# (2º bloco)
  from texto import *
  print(remove_duplicadas("inconstitucionalissimamente"))
  print(inverte_caso("rUA DA bAHIA"))

# (3º bloco)
  from texto import remove_duplicadas, inverte_caso
  print(remove_duplicadas("inconstitucionalissimamente"))
  print(inverte_caso("rUA DA bAHIA"))

# (4º bloco)
  import texto
  remove = texto.remove_duplicadas
  inverte = texto.inverte_caso
  print(remove("inconstitucionalissimamente"))
  print(inverte("rUA DA bAHIA"))

No 2º bloco todos os objetos são importados. Essa prática é desaconselhada porque, em módulos grandes, pode aumentar a chance de conflitos de nomes (de existirem objetos com nomes iguais em mais de um módulo). A instrução de import apenas indica o caminho onde estão os objetos que serão usados. Ela não implica carregamento de objetos não usados para a memória. import * promove a importação de todos os nomes de objetos exceto aqueles que começam com _ (underscore ou sublinhado). import module as m cria um aliás (um apelido) para module e from module import objeto as obj faz o mesmo para um objeto dentro do módulo. No 4º bloco as funções receberam nomes, o mesmo que from texto import remove_duplicadas as remove, etc.

Além de funções, um módulo pode conter outras instruções destinadas a inicializar o módulo. Elas são executados na primeira vez que o nome do módulo é encontrado em uma importação ou quando o arquivo é executado como um script. Cada módulo tem seu próprio namespace (leia sobre o assunto) ou tabela de símbolos, que é usado por todas suas as funções. Essas variáveis ​​globais no módulo não conflituam com outras eventuais que apareçam com o mesmo nome em outras partes do código. Mesmo assim é uma boa prática se referir às variáveis e funções de um módulo com a notação nome_modulo.nome_item, onde item é um objeto qualquer dentro do módulo.

Também podemos importar módulos dentro de outros módulos. Nomes (que são referências aos objetos) dos módulos importados são juntados no mesmo namespace do módulo importador. É uma convenção, mas não obrigatório, listar todas as instruções de importação no início do módulo, o que facilita a leitura do código.

Executando um módulo

Módulos podem ser preparados para executar diretamente no terminal. Para ver isso gravamos o seguinte conteúdo em arquivo com o nome fatorial.py.

# fatorial.py
import sys
def fatorial(n):
    if n <= 1:
        return 1
    else:
        return n * fatorial(n-1)
      
if __name__ == "__main__":
    print(fatorial(int(sys.argv[1])))    

Se esse arquivo for executado diretamente no terminal, a variável __name__ assume o valor “__main__” e a condição final é satisfeita. sys.argv[1] traz para o código um parâmetro digitado no terminal, após o nome do módulo.

$ python fatorial.py 8
40320
$ python fatorial.py 10
3628800

!# shebang


Podemos executar diretamente um arquivo de script se ele contiver uma referência ao interpretador. Para isso acrescentamos a 1ª linha com #!/usr/bin/env python3. O sinal #! é denominado shebang e é destinado a indicar o caminho para o interpretador, no caso python 3 na pasta #!/usr/bin/env. Gravamos o arquivo letras.py, com a função mai_min_uscula() com o efeito de transformar uma string em outra com letras alternadamente maiúsculas e minúsculas (azulejo ↦ aZuLeJo).

#!/usr/bin/env python3

def mai_min_uscula(palavra):
    coleta = ""
    for i in range(len(palavra)):
        j = palavra[i]
        coleta += j.upper() if i%2==0 else j.lower()
    return coleta   

if __name__ == "__main__":
    import sys
    print(mai_min_uscula(sys.argv[1]))

Antes de tentar rodá-lo diretamente no terminal temos que tornar o arquivo executável. No linux:

# para tornar o arquivo executável    
$ chmod +x letras.py

# para executá-lo
$ ./letras.py "mais vale um pássaro na mão"
MaIs vAlE Um pÁsSaRo nA MãO

Além de permitir executar diretamente o arquivo, o shebang também é útil quando queremos que outras versões do python instaladas no computador sejam usadas.

# o comando
$ ./letras.py "texto"
# será executado como
usr/bin/env python3 letras.py "texto"


Naturalmente se o script for executado em outra máquina pode ser necessário ajustar o endereço em #!endereço para corresponder a uma instalação presente do python. Apenas para recordar, se o módulo for importado, e não executado diretamente, a variável __name__ tem outro valor e o bloco final é ignorado.

Localização de módulos

Quando importamos o módulo caminho/modulo_nome.py o interpretador busca por esse nome

  • primeiro no módulo interno, (built-in);
  • se não encontrar ele busca nos diretórios listados na variável sys.path que contém caminho (ou pasta local, ativa no momento, se caminho não for especificado);
  • nos diretórios estabelecidos em PYTHONPATH, uma variável de ambiente;
  • ou na pasta default estabelecida pela instalação de seu python.

É possível aterar dinamicamente, dentro do código, o conteúdo de sys.path de forma a procurar por módulos nos diretórios estabelecidos.

Python Bytecode

Python é uma linguagem interpretada e o interpretador é instalado junto com o próprio python. A sintaxe do código é a mesma para qualquer plataforma mas o interpretador é diferente para cada uma delas. Dessa forma pode-se escrever um único código que roda em qualquer plataforma. Também existem compiladores para transformar o arquivo (ou projeto) em um executável.

Na execução o interpretador gera um arquivo de bytecode (instruções de máquina de baixo nível) que, em seguida pode ser executada pela máquina virtual do python (python virtual machine). Esse bytecode fica em __pycache__, um subdiretório do diretório ativo na compilação. Apenas módulos carregados por imports são armazenados em __pycache__. Para obter todo o código como arquivo.pyc ele deve ser compilado com instruções explícitas para isso (como mostrado abaixo).

Na complição os seguintes arquivos são armazenados, com as extensões:

  • arquivo.py: O código fonte sendo executado.
  • arquivo.pyc: arquivo bytecode compilado. Todos os módulos importados são convertidos em *.pyc pelo interpretador. Esse arquivo pode ser carregado mais rapidamente que o módulo original.
  • arquivo.pyo: Um arquivo *.pyc é criado quando o parâmetro de otimização (-O) é usado.
  • arquivo.pyd: Arquivo dll do Windows.

Quando um arquivo é executado em um terminal as extensões .pyc tem prioridade para serem carregadas, a menos que o código fonte tenha sido alterado mais recentemente. Na compilação as chaves (opções) -O ou -OO podem ser usadas para diminuir o tamanho do módulo complilado:

  • A opção -O remove as instruções assert,
  • a opção -OO remove as instruções assert e as docstrings.

Ao fazer isso deve-se ter certeza de que tais instruções não serão mais necessárias. Módulos compilados são carregados em menor tempo mas não executam mais rápido. O módulo compileall pode ser usado para criar arquivos .pyc para todos os módulos de um diretório.

Módulos na biblioteca padrão e módulos instalados


Na instalação padrão do Python diversos módulos são instalados por default e podem ser importados diretamente em qualquer outro módulo. Alguns deles estão descritos no artigo Biblioteca Padrão, neste site. Alguns módulos são específicos da plataforma, como o winreg para o Windows, que permite diversas operações com o registro desse sistema.

Um exemplo é o módulo sys com diversas propriedades e métodos úteis.

import sys
sys.version
'3.8.8 (default, Apr 13 2021, 19:58:26) \n[GCC 7.3.0]'
sys.path
['/home/guilherme/.anaconda3/lib/python38.zip',
 '/home/guilherme/.anaconda3/lib/python3.8',
 '/home/guilherme/.anaconda3/lib/python3.8/lib-dynload',
 '',
 '/home/guilherme/.anaconda3/lib/python3.8/site-packages']
# acrescenta um diretório no path
sys.path.append('/home/guilherme/Projetos/Python/unittest')

A variável sys.path contém uma lista de strings, inicializada com a variável de ambiente PYTHONPATH. Ela pode ser modificada com
sys.path.append('novo/caminho')

Pacotes (Packages)

Um pacote (ou package) é uma coleção de módulos, dispostos de forma organizada para tornar mais simples a construção do código, sua utilização e reutilização. Essa estrutura facilita a localização e execução cada um de seus módulos. Pacotes são compreendidos como:

  • aqueles instalados de fontes externas usando ferramentas como pip ou pipenv. Frequentemente buscamos pacotes no Python Package Index, (Pypi);
  • aqueles construídos pelo desenvolvedor com seu próprio código. Pacotes são usados como formas de estruturar o projeto.

Os pacotes permitem que os namespaces (as tabelas de referência entre nomes e objetos) de módulos do python sejam estruturados usando a notação de ponto. Por exemplo modulo1.modulo2 é uma referência para o submódulo modulo2 contido em modulo1. Isso ajuda a impedir conflito de nomes em projetos grandes, principalmente quando módulos diferentes foram escritos por desenvolvedores diferentes.

Um diretório com um pacote deve necessariamente conter um arquivo __init__.py. O nome do pacote é mesmo do diretório base. É boa prática dar nomes que nos ajudem a lembrar de sua funcionalidade.

Arquivo __init__.py


O arquivo especial __init__.py é sempre executado quando o pacote é importado. Ele pode ser vazio ou pode conter código de inicialização do aplicativo ou dos módulos em seu diretório. Uma prática comum é a de importar os módulos necessários nessa inicialização. Quando um subpacote é importado, por exemplo com import meu_app.gerar_excel (em referência à figura), os arquivos de inicialização são executados na seguinte ordem:

  1. meu_app.__init__.py
  2. meu_app.gerar_excel.__init__.py

Por exemplo, vamos exibir a estrutura de um pacote de nome meu_app, cuja finalidade é ler e escrever dados numéricos em arquivos .txt ou .csv, e construir planilhas Excel com esses dados. Na figura mostramos a estrutura de diretórios, com nomes após sinais de #. Usuários do pacote podem importar os módulos:

# para ler os arquivos csv    
import meu_app.ler.ler_csv

# para gerar planilhas com dados importados
import meu_app.gerar_excel.gerar_xl
# ou
from  meu_app.gerar_excel import gerar_xl

# para importar todos os módulos de meu_app.escrever from meu_app.escrever import *

Se o objeto buscado não for encontrado um ImportError é lançado. No último exemplo, podemos limitar os módulos importados com * inserindo a variável especial __all__ em __inti__.py. Se meu_app.escrever.__init__.py contiver a linha: __all__=["escrever_csv"] apenas esse módulo será importado. Essa variável recebe uma lista dos módulos a serem importados: __all__=["modulo1", "modulo2", ...].

Quando os pacotes estão estruturados como o meu_app na figura, contendo subpacotes, podemos importar módulos de pacotes diferentes usando caminhos completos. Por exemplo, se para gerar uma planilha precisamos de ler_csv usamos from meu_app.ler import ler_csv dentro de gerar_xl.

Podemos ainda usar um atributo especial, __path__ contendo uma lista com todos os caminhos onde existem pacotes. Desta forma subpacotes que são partes de um pacote central podem estar distribuídos em diretórios quaisquer.

Função dir()

A função built-in dir() retorna uma lista de nomes definidos em um namespace. Sem argumentos ela retorna a lista, em ordem alfabética, de todos os nomes definidos na tabela de símbolos local. Abaixo dir() é usado logo após a inicialização do python, após a importação do módulo math e depois da criação de uma nova classe.

$ python
Python 3.8.8 (default, Apr 13 2021, 19:58:26) 
[GCC 7.3.0] :: Anaconda, Inc. on linux
>>> dir()
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__']

# importando math
>>> import math
>>> dir()
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'math']

# se definirmos uma classe ou definirmos uma variável
>>> class Nova():
...     pass
>>> x =1010
>>> dir()
['Nova', '__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'math', 'x']

Dessa forma a função é útil para mostrar o que foi inserido na tabela após uma importação ou qualquer outra ação do usuário. Se um um módulo for passado como argumento dir(modulo) lista os nomes dentro desse módulo.

>>> dir(math)
['__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'ceil', 'comb', 'copysign', 'cos', 'cosh', 'degrees', 'dist', 'e', 'erf', 'erfc', 'exp', 'expm1', 'fabs', 'factorial', 'floor', 'fmod', 'frexp', 'fsum', 'gamma', 'gcd', 'hypot', 'inf', 'isclose', 'isfinite', 'isinf', 'isnan', 'isqrt', 'ldexp', 'lgamma', 'log', 'log10', 'log1p', 'log2', 'modf', 'nan', 'perm', 'pi', 'pow', 'prod', 'radians', 'remainder', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'tau', 'trunc']

Classes, Módulos e Pacotes

No python um módulo pode conter diversas classes (diferente do Java ou C#), funções e definições de constantes. Módulos muito grandes, com muitas classes, podem ser divididos, principalmente se existirem objetivos diferentes em suas classes. Módulos não precisam ser parte de um pacote. Um pacote pode conter diversos módulos, com funcionalidade e objetivo semelhante.

Além de tornar o código mais estruturado e fácil de ser lido (protanto mais fácil de manter) a estrutura de classes, módulos e pacotes favorece a reutilização de código. Essa prática está em acordo com o princípio DRY (“Don’t Repeat Yourself” ou “Não se repita”). A abordagem DRY para programação se refere à agrupar partes repetidas do código em funções, classes, módulos ou pacotes (dependendo da complexidade) que podem ser chamadas em partes diversas do projeto.

Compilando o código

Usamos aqui o termo “compilar” significando “gerar um bytecode”.

Quando executamos um aplicativo ou módulo do python apenas os módulos importados são armazenados como bytecodes. Para forçar a compilação e armazenamento podemos usar py_compile e compileall.

py_compile(arquivo.py) Compila arquivo.py para bytecode
compileall.compile_dir(dir) Compila todos os arquivos no diretório para bytecode
compileall.compile_file(arquivo.py) Compila arquivo.py para bytecode
compileall.compile_path() Compila arquivos em sys.path para bytecode

Esses módulos são rodados no terminal do sistema operacional:

# para compilar um arquivo
$ python -m py_compile arquivo_compilar.py

# para compilar arquivos em um diretório
$ python -m compileall.compile_dir caminho_da_pasta

Com esses processos (ou durante a execução usual do python) os arquivos *.pyc são colocados na pasta “__pycache__” com o nome arquivo_compilar.cpython-39.pyc, onde cpython-xx indica que foram compliados sob cpython-xx, sendo xx a versão do cpython. Esses arquivos podem ser executados com:

$ cd __pycache__    
$ python arquivo_gerado_bytecode.cpyton-39.pyc

Você pode entregar apenas os arquivos *.pyc para um usuário final que deve ter o interpretador do python instalado em seu computador para executá-los.

Embora python seja uma linguagem interpretada existem compiladores desenvolvidos para transformar um aplicativo em um executável. Entre eles:

  • py2exe transforma programas do Python em pacotes que podem rodar no Windows sem a necessidade de ter uma instalação do Python.
  • Nuitka the Python Compiler, um compilador compatível com python2 e python3.
  • Pyinstaller, empacota todo um aplicativo python, com suas dependências, em um pacote único. Funciona com python3.6 ou superior.

PyInstaller é um empacotador que permite ao usuário final executar o aplicativo sem instalar um interpretador python ou qualquer módulo. Ele funciona corretamente com os principais pacotes, como numpy, PyQt, Django e wxPython. Ele deve ser executado na versão específica de plataforma a que é destinado o pacote final. O PyInstaller tem sido usado com sucesso no Windows, Mac OS X, GNU/Linux, Solaris, FreeBSD e OpenBSD.

Python Eggs e Wheels


Python Eggs: Dois formatos de distribuição de aplicativos do python são os arquivos *.egg e *.whl. Eggs são uma tecnologia mais antiga, recentemente substituída pelo formato wheels. Um arquivo egg é basicamente um arquivo zipado com instruções para a instalação de um pacote. Apesar de ter sido substituído ainda existem arquivos eggs para serem baixados e instalados. Ele faz uso do pacote SetupTools, que é a forma padrão original de lidar com pacotes do PyPI (e outras fontes) usando comando de linha. Você pode criar seu próprio egg para distribuí-lo, ou instalar pacotes baixados de terceiros. (Documentação sobre eggs).

Python wheels: O formato Wheel foi criado para substituir os python eggs. Ele trabalha junto com o pip e foi projetado para conter todos os arquivos para uma instalação de maneira próxima do formato em disco após a instalação. Semelhante a um *.egg, arquivo *.whl também é um arquivo zip com extensão renomeada. Se essa extensão for renomeada (*.whl ↦ *.zip), o arquivo pode ser aberto e analisado com qualquer aplicativo zip.

O nome de uma instalação wheel contém informações sobre quais as versões do python e da plataforma são suportadas, o que é usado pelos instaladores para fazer uma escolha correta da versão do pacote a ser instalado. Existe suporte para wheels em pip >= 1.4 e setuptools >= 0.8.

Um único arquivo .whl contém todos os módulos de seu projeto, inclusive aqueles importados por ele. Uma vez criado ele pode ser carregado para um ambiente na nuvem, como o Pypi, e dali instalado por qualquer usuário que queira usá-lo. Se o arquivo se chama arquivo.whl ele pode ser instalado com o comando pip install arquivo.whl.

Para usar wheels precisamos instalá-lo. Certifique-se de que você tem uma versão recente do pip fazendo o upgrade, e instale o wheel e setuptolls, lembrando que o ideal é fazer isso em um ambiente virtual (Leia sobe Ambientes Virtuais):

# upgrade do pip    
$ pip install --upgrade pip
# instalação do wheel e setuptools
$ pip install wheel
$ pip install setuptools

Para a criação de um arquivo .whl devemos proceder da seguinte forma:

  • Copie todos os módulos e pacotes de seu projeto em um diretório raiz. Vamos chamá-lo aqui de raiz. Lembramos, como visto acima, que todos os diretórios e subdiretórios devem possuir um arquivo __init__.py. (Não obrigatório no diretório raiz.)
  • Crie um arquivo com o nome setup.py no diretório raiz. Esse arquivo deve conter um mínimo de informações como: nome da distribuição, número da versão e lista de nomes de pacotes.

Um exemplo mínimo desse arquivo seria o seguinte:

from setuptools import setup, find_packages

setup(
    # nome da distribuição
    name = 'nome_da_distribuição', 
    # versão
    version='1.0', 
  
    # definição de nomes dos pacotes e módulos (uma das 3 abordagens)
 
    # 1. se existem poucos módulos no diretório raiz
    packages = ['']
  
    # 2. liste todos os nomes dos pacotes
    packages = ['pacote1', 'pacote2', ...]
  
    # 3. encontre automaticamente todos os pacotes
    packages = find_packages()
)
  • O nome da distribuição será exibido com python -m pip list ou conda list.
  • A versão pode ser incrementada, conforme o desenvolvedor.
  • A 1ª abordagem pode ser usada quando existem poucos módulos na raiz e nenhum subdiretório com pacotes. Os módulos podem ser importados diretamente sem o uso do nome da distribuição (como import modulo_1 para importar o arquivo import modulo_1.py.
  • Na 2ª abordagem todos os pacotes estão na lista e a importação é feita como from pacote1 import modulo1 ou import pacate1.modulo1 as m1.
  • Na 3ª abordagem a lista packages é preenchida automaticamente pelo método find_packages().

Depois, no terminal, navegue até o diretório raiz e execute setup

$ cd raiz
$ python setup.py bdist_wheel

Esse procedimento cria .whl (o arquivo wheel) em um subdiretório chamado dist de raiz. (Ou seja, cria raiz/dist/nome_completo_do_wheel.wheel). Para testar seu funcionamento você pode instalá-lo com

$ pip install nome_completo_do_wheel.whl

Wheels são uma boa maneira de criar um repositório local, com as dependências para seu projeto, que pode ser instalado rapidamente. A criação de vários repositórios wheel facilitam a alternância entre versões para fins de teste. Quando usado em conjunto com os ambientes virtuais essa tecnologia permite verificar como as versões atualizadas das dependências de seu projeto podem afetar seu desepenho sem precisar baixá-las várias vezes.

Bibliografia

todos acessados em março de 2020.

Flask, parte 2


Templates do Jinja2


Um aplicativo web (ou outro qualquer) deve ser escrito de forma clara, para facilitar sua expansão e manutenção. Uma das formas usadas pelo Flask para implementar esse estratégia é a de colocar código python e html separados. Os templates, como vimos, são modelos ou estruturas básicas que podem ser preenchidas dinamicamente, de acordo com as requisições. Esse é o chamado de modelo de separação entre lógica de negócio e lógica de exibição (business and presentation logic). Templates são tratados por um dos módulos que compõem o Flask: o módulo Jinja2.

Um exemplo básico de template para a exibição de um artigo poderia ser o seguinte:

# template.html
<h1>{{ titulo }} </h1>
<p>{{ autor }}, {{ data }} </p>
<p>{{ texto_do_artigo }} </p>
<p>{{ pe_de_pagina }} </p>

Os campos {{ variavel }} são chamados de localizadores (placeholders) para os valores que serão passados pelas funções view. Em muitos casos as informações usadas para popular essas variáveis são lidas em um banco de dados.

Já vimos o exemplo:

@app.route(“/frutas/<nome_da_fruta>”)
def frutas(nome_da_fruta):
return render_template(“frutas.html”, nome_da_fruta = nome_da_fruta)

onde /frutas/<nome_da_fruta>, fornece o valor da varíavel passada para o parâmetro da função (em vermelho). Dentro do corpo da função a variável de mesmo nome (em verde) recebe esse valor. Esses nomes não precisam ser os mesmo, embora esse seja uma prática comum entre programadores do python.

O método render_template() é parte do Jinja2 para integrar templates e lógica do aplicativo.

Filtros


Variáveis e objetos do python podem ser integrados nos templates de algumas formas. Por meio do módulo Jinja temos diversos filtros para manipular campos em templates. Já vimos como inserir uma variável em um template. Um exemplo de filtro é title(), que torna a string no formado de título, com a primeira letra de cada palavra em maísculo.

# suponha que temos a variável titulo = "a casa da mãe joana"
# essa string pode ser exibida dentro de uma tag <h1>
<h1> {{ titulo }} </h1>
↳ a casa da mãe joana

# para maísculas na primeira letra de cada palavra
<h1> {{ titulo | title() }} </h1>
↳ A Casa Da Mãe Joana

Uma descrição dos filtros para texto está na tabela abaixo.

Filtro Descrição
capitalize Converte 1º caracter em maiúsculo, os demais em minúsculo
lower Converte todos os caracteres minúsculo
upper Converte todos os caracteres maiúsculo
title Converte 1º caracter de cada palavra em maiúsculo
trim Remove espaços em branco no início e no fim
safe Renderiza o valor sem aplicar escape (inclui tags)
striptags Remove todas as tags HTML do valor

safe: O filtro safe informa ao Flask que a tag html pode ser renderizada com segurança. Mais exemplos abaixo.
striptags: remove as tags <tag> e </tag> e retorna o texto puro.

Exemplos de Filtros em Strings

Suponha que temos uma variável de nome titulo. Nos templates ela pode ser exibida diretamente, como uma string, ou passando por algum dos vários filtros. Nos quadros seguintes os comentários são iniciados por # enquanto outputs são identificados pelo sinal .

# suponha que a variável titulo2 não está definida
# default fornece um valor default (se titulo2 não está definido).
<h1> {{titulo2 | default ("Título Não Encontrado")}} </h1>
↳ Título Não Encontrado

# torna maiúscula a primeira letra
<h1> {{"mercado" | capitalize()}} </h1>
↳ Mercado

# em linha anterior ao uso podemos definir um valor
# capitalize torna maiúscula a 1ª letra de cada palavra
{% set titulo2 = "um título para a página" %}
<h1> {{ titulo2 | capitalize()}} </h1>
↳ Um título para a página

# title() torna maiúscula a 1ª letra de cada palavra
<h1> {{titulo2 | title()}} </h1>
↳ Um Título Para A Página

# substituir um trecho em uma string
{{ "Bom dia galera!" | replace("Bom dia", "Boa noite") }}
↳ Boa noite galera!

# inverter a ordem dos elementos
{{ "Olá galera!" | reverse() }}
↳ !arelag álO

Conversores

Por default os valores passados em uma url e capturados como valores do python são strings. Alguns conversores podem transformar essas strings em caminhos (que usam barras / ), inteiros ou decimais.

@app.route("/usuario/<int:id>")
def exibir_id(id):
    # esta função recebe id como um inteiro e o exibe
    return f"O id digitado é {id}"

@app.route("/path/")
def exibir_caminho(caminho):
    # recebe e retorna o caminho passado
    return f"Caminho {caminho}"

Os seguintes conversores estão disponívies:

string (default) qualquer string sem barras / ou \
int converte em inteiros positivos
float converte em números decimais
path strings contendo barras de caminho
uuid strings UUID†

† Uma string UUID (Universally Unique IDentifier), também chamada de GUID (Globally Unique IDentifier) é um número de 128-bits usado na troca de informações em computação.

Valores numéricos podem ser convertidos entre inteiros e decimais, e um valor default ser fornecido.

# números inteiros podem ser convertidos em decimais, ou decimais em inteiros
{{ 10 | float() }}
↳ 10.0 ou 0.0      # 0.0 se a conversão não for possível
{{ 10.0 | int() }}
↳ 10

# um valor default, em caso de erro
{{ "qualquer" | float (default = "Erro: texto não pode ser convertido em decimal") }}
↳ Erro: texto não pode ser convertido em decimal

Manipulação de Listas

Diversas operações são disponíveis em listas.

# join: junta elementos de uma lista
{{ [1, 2, 3] | join() }}
↳ 123
{{ ["Um", "Dois", "Tres"] | join() }}
↳ UmDoisTres

# inserindo um separador
{{ [1, 2, 3] | join ("|") }}
↳ 1|2|3
{{ ["Um", "Dois", "Tres"] | join("-") }}
↳ Um-Dois-Tres

# o filtro list() retorna uma lista
{{ "Guilherme" | list()}}
↳ ["G","u","i","l","h","e","r","m","e"]

# random() seleciona um item aleatorio da lista
{{ ["Mercúrio", "Venus", "Terra"] | random() }}
↳ Venus

{% set pe_pagina = ["citacao 1", "citacao 2", "citacao 3", "citacao 4", "citacao 5"] %}
{{ pe_pagina | random() }}
↳ citacao 4

# replace (visto acima para strings) também pode ser usado em listas
{% set lista = ["Nada", "a", "dizer"] %}
{{ lista | replace ("Nada", "Tudo") }}
↳ ["Tudo", "a", "dizer"]

# o filtro reverse() também pode inverter uma lista
# mas seu resultado é um objeto iterador
{% set lista = ["unidade", "dezena", "centena"] %}
{{ list | reverse() }}
↳ <list_reverseiterator object at 0x7fc0b6262518>

# para usar o objeto lista sem usar iterações temos que usar o método list()
{{ list | reverse() | list() }}
↳ ["centena", "dezena", "unidade"]

O filtro random() pode ser útil para exibir um artigo aleatório do site na homepage, para escolher uma imagem ou um pé de página, etc.

Outros exemplos de manipulação de listas incluem o uso de first(), last(), uso de índices e de laços para percorrer toda a lista.

# first() é 1º elemento da lista, last() é o último elemento
{% set nomes = ["João", "Pedro", "da", "Silva"]  %}
<p> Nome: {{ nomes | first() }} </p>
<p> Segundo Nome: {{ nomes [1] }} </p>
<p> Sobrenome: {{ nomes | last() }} </p>
↳ Nome: João
↳ Segundo Nome: Pedro
↳ Sobrenome: Silva

# o tamanho de uma lista é retornado com {{ lista | length }}
# laços for são usados para percorrer os elementos
{% set comentarios = ["Comenta 1", "Comenta 2", "Comenta 3", "Comenta 4"]%}
<p>Temos ({{comentarios | length}}): comentários</p>
{% for comentario in comentarios %}
    <p> {{ comentario }} </p>
{% endfor%}
# resulta em
↳ Temos 4: comentários
↳ Comenta 1
↳ Comenta 2
↳ Comenta 3
↳ Comenta 4

O filtro safe

O filtro safe serve para passar para o interpretador do Flask a informação de que as tags html devem ser renderizadas. Sem ele a string "<texto>" é exibida literalmente, inclusive com os delimitadores "<>".

Os códigos &lt; (<) e &gt; (>) são entidades html, descritas nesse site.

Por motivo de segurança o Jinja2 remove as tags html. Por exemplo: uma variável com valor "<li> TEXTO </li>" será renderizada como "&lt;li&gt; TEXTO &lt;/li&gt;" por extenso e sem provocar a renderização do navegador. Com o filtro safe o TEXTO é exibido como um ítem de lista.

# exibição literal de uma string
{{ "<b>Texto a exibir!</b>" }}
↳ <b>Texto a exibir!</b>

# para forçar a renderização da tag <b> (negrito)
{{ "<b>Texto a exibir!</b>" | safe }}
↳ Texto a exibir!

# define uma lista
{% set lista = ["<li>Um elefante</li>", "<li>Dois elefantes</li>", "<li>Três elefantes</li>"] %}
<ul>
{% for item in list %}
    {{ item | safe }}
{% endfor %}
</ul>
# será renderizado como
↳
⏺ Um elefante
⏺ Dois elefantes
⏺ Três elefantes

# alternativamente
{% set lista = ["Um elefante", "Dois elefantes", "Três elefantes"] %}
<ul>
{% for item in list %}
    <li> {{ item }} </li>
{% endfor %}
</ul>
# será renderizado da mesma forma.
# Nesse caso não existem tags na lista e safe é desnecessário.

Observação importante: Qualquer input digitado por usuários deve passar pelo filtro safe para evitar que alguma instrução danosa seja processada pelo navegador.

Laços e bifurcações


Vimos que um template recebe variáveis do python e pode processá-las com código. Por exemplo, modificamos o template frutas.html da seguinte forma:

# frutas.html
<body>
    {% if nome_da_fruta == None %}
        <p>Você não escolheu uma fruta!</p>
    {% elif nome_da_fruta == "laranja" %}
        <p>Você escolheu laranja, a melhor fruta!</p>
    {% else %}
        <p>Você escolheu a fruta: {{ nome_da_fruta }}</p>
    {% endif %}
</body>

No código de meu_site.py modificamos a função frutas para que por default ela receba None (caso nada seja escrito após o nome do diretório /frutas/:

# meu_site.py (apenas trecho)
@app.route("/frutas/")
@app.route("/frutas/<nome_da_fruta>")
def frutas(nome_da_fruta=None):
    return render_template("frutas.html", nome_da_fruta=nome_da_fruta)

Agora temos as respostas:

url resposta no navegador
http://127.0.0.1:5000/frutas/ Você não escolheu uma fruta!
http://127.0.0.1:5000/frutas/laranja Você escolheu laranja, a melhor fruta!
http://127.0.0.1:5000/frutas/goiaba Você escolheu a fruta: goiaba

No template, os trechos entre chaves não são parte do html e sim do Python, gerenciado pelo Flask. Dessa forma podemos integrar as páginas da web com as inúmeras bibliotecas do Python.

Quatro tipos de marcações estão disponíveis para para inserção do código nos templates.

sintaxe usadas para
{% ... %} linhas de instruções
{{ ... }} expressões
{# ... #} comentários
# ... ## instruções inline

Variáveis

Variáveis a serem usadas nos templates podem ser de qualquer tipo. Por exemplo:

{% set nomes = ["João", "Pedro", "da", "Silva"]  %}
<p>O segundo nome da lista: {{ nomes[1] }}.</p>
↳ O segundo nome da lista: Pedro.

{% set id = 3 %}
<p>Quem? {{ nomes[id] }}!</p>
↳  Quem: Silva!

# uso de um dicionário
{% set dicionario = {"Nome":"Paul"; "Sobrenome":"Dirac"; "Profissão":"Físico"}  %}
<p>Estes são os dados do: {{ dicionario["Nome"] }}.</p>
{% for chave, valor in dicionario.items() %}
    <p>{{ chave }} : {{ valor }}</p>
{% end for %}
↳ Estes são os dados do: Paul.
↳ Nome: Paul
↳ Sobrenome: Dirac
↳ Profissão :Físico

# no caso geral, se um objeto é passado para o template e tem um método, podemos usar:
<p>Obtendo um valor com um método de objeto disponível: {{ objeto.metodo() }}.</p>

Incluindo trechos com include

Se uma parte do template é repetida várias vezes ela pode ser colocada à parte, em arquivo separado, e incluída na template principal. Por ex., se temos um pé de página que aparece em diversas de nossas páginas ele pode ser gravado à parte.

# arquivo pe_de_pagina.html
<div class="pe_pagina">
<p>Esté é o meu pé de página</p>
</div>

Esse código assume que existe a definição de uma classe css chamada pe_pagina.
Em todos os arquivos que devem exibir o pé de página inserimos:

# todo o texto da página
# pé de página
{% include 'pe_de_pagina.html' %}

Macros

Macros são formas de implementar o princípio DRY (Don’t Repeat Yourself ), uma prática de desenvolvimento de software que visa reduzir a repetição do linhas semelhantes ou com mesma função no código. Ele torna o código mais legível e de fácil manutenção, uma vez que alterações devem ser feitas em um único bloco. Templates, macros, inclusão de conteúdo externo (como em include()) e heranças de modelos são todos instrumentos utilizados para isso.

Outra forma de gerar código reutilizável é através da criação de macros, um recurso similar às funções usuais do Python. Macros podem ser gravadas em arquivos separados e importadas dentro de todos os templates que fazem uso delas, facilitando a modularização do código.

Uma macro pode executar tarefas simples como simplesmente montar as linhas da lista. Suponha que temos uma lista de linhas e queremos montar uma lista não ordenada em html:

 {% macro montar_lista(linha) %}
     <li>{{ linha }}</li>
 {% endmacro %}
 <ul>
 {% for linha in linhas %}
     {{ montar_lista(linha) }}
 {% endfor %}
 </ul>

Outra possibilidade importante para a modularização consiste em gravar um arquivo macros.html que contém a macro vermelho(texto, marcas). Ele retorna linhas de uma lista coloridas de vermelho se texto == marcas, de azul caso contrário.

# arquivo macros.html
{% macro vermelho(texto, marcar) %}
{% if texto == marcar %}
    <li style="color:red;">{{ texto }}</li>
{% else %}
    <li style="color:blue;">{{ texto }}</li>
{% endif %}
{% endmacro %}

O arquivo mostrar_frutas.html importa a arquivo anterior, com a sua macro, e faz uso dela para exibir a lista ordenada.

# arquivo mostrar_frutas.html
{% from "macros.html" import vermelho %}
{% set frutas = ["Abacate", "Abacaxi", "Laranja", "Uva"]  %}
{% set selecionado = "Abacaxi"  %}
<ol>
 {% for fruta in frutas %}
     {{ vermelho(fruta, selecionado) }}
 {% endfor %}
</ol>

O resultado no navegador é o seguinte:

Assim como é válido no Python, podemos fazer a importação de forma alternativa (mostrando só linhas diferentes):

# arquivo mostrar_frutas.html
{% import "macros.html" as macros %}
{% for fruta in frutas %}
    {{ macros.vermelho(fruta, selecionado) }}
{% endfor %}

Herança de Templates

Similar à herança de classes no modelo POO do python, podemos criar um template base e derivar dele outros templates que herdam a sua estrutura. Os templates base definem blocos que podem ser sobrescritos nos templates filhos. Um template base pode ter a seguinte estrutura:

# arquivo base.html    
<html>
<head>
 {% block head %}
 <title> Artigo {% block title %}{% endblock %} </title>
 {% endblock %}
</head>
<body>
 {% block body %}
 {% endblock %}
 {% block final %}
 <p>Site construído com Python-Flask!</p>
 {% endblock %} 
</body>
</html>

A instrução {% block nome_do_bloco %}{% endblock %} pode ser substituída por conteúdo nos templates filhos (ou derivados). Para herdar desse template usamos extends e redefinimos os blocos da base:

# arquivo derivado.html    
{% extends "base.html" %}
{% block title %}Nome do Artigo{% endblock %}
{% block head %}
<style>
# estilos css ficam aqui
</style>
{% endblock %}
{% block body %}
<h1>Nome do Artigo</h1>
<p>Texto do artigo</p>
{% endblock %}
{% block final %}
 {{ super() }}
{% endblock %}

O bloco final, {% block final %}{{ super() }}{% endblock %}, usa super() para simplesmente importar o conteúdo do arquivo base. Se a base e o derivado contém texto o conteúdo da base é sobreposto.

Solicitações e Respostas do Servidor (Request, Response)

† O que é uma thread?

Ao receber uma solicitação de um cliente o Flask responde passando para as funções de visualização (view functions) os objetos que serão usados para a construção da página web. Um exemplo é o objeto request, que contém a solicitação HTTP enviada pelo cliente. Temos que nos lembrar que o aplicativo pode receber um grande volume de solicitações em múltiplas threads. Para evitar que todas as funções de visualização recebam essas informações como parâmetros, o que pode tornar o código complexo e gerar conflitos o Flask usa contextos para que esses objetos fiquem temporariamente acessíveis dentro desses contextos.

Para os exemplos que se seguem vamos trabalhar usando o python no terminal. Para isso ativamos o ambiente virtual e o próprio python:

$ cd ~/caminho_para_o_projeto
$ source ./bin/activate
# o prompt muda para indicar ativação de venv
$ (venv) $ python
Python 3.9.10 (main, Jan 17 2022, 00:00:00)

Graças aos contextos, funções de visualização como a seguinte podem ser escritas:

from flask import request
@app.route('/')
def index():
    navegador = request.headers.get('User-Agent')
    return f'<p>Verificamos que seu navegador é o {navegador} </p>'

Que retorna no navegador (no meu caso):

Contextos: Note que request não foi passado explicitamente para index(), agindo como se fosse uma variável global. Isso é obtido pelo Flask com os contextos ou ambientes reservados. Dois ambientes são usados: o contexto de aplicativo e o contexto de requisição.

As seguintes variáveis existem nesses contextos:

nome da variável Contexto Descrição
current_app Aplicativo A instância do aplicativo ativo.
g Aplicativo Objeto usado pelo aplicativo para armazenamento de dados durante uma requisição. Resetada a cada requisição.
request Requisição Objeto que encapsula o conteúdo da requisição HTTP enviada pelo cliente.
session Requisição Representa a sessão do usuário, um dicionário que o aplicativo usa para armazenar valores entre requisições.

Esses contextos só estão disponíveis quando o aplicativo recebe uma requisição (por meio de uma url digitada no navegador). O Flask ativa o contexto de aplicativo disponibilizando current_app e g, e o contexto de requisição disponibilizando request e session, para a thread, e em seguida os remove. Um exemplo desse comportamento pode ser visto no código, executado dentro do python:

from meu_site import app
from flask import current_app
current_app.name                     # um mensagem de erro é exibida
                                     # pois o contexto não está ativo
RuntimeError: working outside of application context

app_contexto = app.app_context()
app_contexto.push()                 # o flask ativa o contexto
current_app.name                    # o nome do app é impresso
'meu_site'
app_contexto.pop()

Flask usa os métodos objeto.push() e objeto.pop() para iniciar e terminar um contexto. A variável current_app.name só existe enquanto o contexto está ativado.

Preparando uma resposta

Para responder a uma solicitação o Flask armazena um mapa que associa URLs (e suas partes) à função resposta que deve ser executada. Esse mapa está armazendo em app.url_map.

from meu_site import app
app.url_map
Map([<Rule '/contatos/' (HEAD, OPTIONS, GET) -> contatos>,
 <Rule '/frutas/' (HEAD, OPTIONS, GET) -> frutas>,
 <Rule '/' (HEAD, OPTIONS, GET) -> index>,
 <Rule '/frutas/<nome_da_fruta>' (HEAD, OPTIONS, GET) -> frutas>,
 <Rule '/static/<filename>' (HEAD, OPTIONS, GET) -> static>])

A lista mostra o mapeamento das funções view que criamos e mais um, denominado /static, acrescentado automaticamente para acessar arquivos estáticos como arquivos de estilo (cascading style sheets, *.css) e imagens. Os elementos (HEAD, OPTIONS, GET) são passados dentro da URL. Os dois primeiros são gerenciados internamento pelo Flask.

Objeto request

O objeto request, armazenado na variável request, contém toda a informação passada na requisição pela URL. Os atributos e métodos mais comuns desse objeto são listados a seguir.

Atributo/Método Descrição
form Dicionário com todos os campos de form submetidos na requisição.
args Dicionário com todos os argumentos passados na string de pesquisa da URL.
values Dicionário com valores combinados de form e args.
cookies Dicionário com todos os cookies incluídos na requisição.
headers Dicionário com todos os cabeçalhos HTTP incluídos na requisição.
files Dicionário com todos os arquivos de upload incluídos na requisição.
get_data() Retorna dados em buffer na requisição.
get_json() Retorna dicionário com dados JSON incluído na requisição.
blueprint Nome do blueprint que está processando a requisição.
endpoint Nome do endpoint processando a requisição. Flask usa a função view como nome do endpoint para um caminho.
method Método da requisição HTTP, (GET ou POST).
scheme Esquema da URL (http or https).
is_secure() Retorna True se a requisição veio de conexão segura (HTTPS).
host O host definido na requisição, incluindo o número da porta se fornecido pelo cliente.
path Parte da URL que define o caminho.
query_string Parte da URL que define a string de pesquisa (query), como um valor binary (raw).
full_path Parte da URL que define caminho e pesquisa (query).
url Requisição completa da URL fornecida pelo cliente.
base_url O mesmo que url, sem a parte de pesquisa (query).
remote_addr Endereço de IP do cliente.
environ Dicionário com o ambiente de WSGI da requisição.
(†) Blueprints serão vistos mais tarde.

Hooks de Solicitação (request hooks)

Com o Flask podemos registrar funções que devem ser chamadas antes ou depois de uma solicitação. Essas funções podem ser usadas para executar tarefas úteis, tais como autenticar um usuário, abrir e fechar a conexão com um banco de dados, etc. Quatro ganchos (hooks) são disponibilizados:

before_first_request Registra função para execução antes da primeira requisição. Útil para tarefas de inicialização do servidor.
before_request Registra função a ser executada antes de cada requisição.
after_request Registra função a ser executada após cada requisição, caso não ocorram exceções não tratadas.
teardown_request Registra função a ser executada após cada requisição, mesmo que ocorram exceções não tratadas.

Um exemplo de uso desses hooks seria o de usar before_request para coletar dados que serão usados ao longo do ciclo de vida do aplicativo para um usuário e os armazenar na variável g para uso posterior.

Como vimos, uma requisição resulta em uma resposta por meio de uma das funções view enviada ao cliente. Ela pode ser uma página simples de html construída com auxílio dos templates ou algo mais complexo. Junto com a resposta, de acordo com o protocolo HTTP, é enviado um código de status (status code) indicando o sucesso da solicitação. Por default o Flask retorna o status_code = 200 para solicitação bem sucedida. Podemos usar uma função view para retornar outro código.

@app.route('/')
def index():
    return 'Ocorreu um erro!', 400
42 é a resposta do Guia do Mochileiro das Galaxias!

A função acima retorna uma tupla com uma string e um inteiro. É possível e útil fazer com que essas funções retornem um objeto response no lugar da tupla.

from flask import make_response
@app.route('/')
def index():
   response = make_response('Resposta Final sobre o Universo!')
   response.set_cookie('resposta', '42') return response

Dessa forma response passa a conter um cookie (que é gerenciado pelo navegador que o recebe). A tabela seguinte mostra métodos e atributos mais usados no objeto response.

Métodos e atributos do objeto response

Atributo/Método Descrição
status_code Código numérico de status do HTTP
headers Objeto tipo dicionário com todos os cabeçalhos a serem eviados em response
set_cookie() Acrescenta um cookie no objeto response
delete_cookie() Remove um cookie
content_length Comprimento do corpo da response
content_type Tipo de midia do corpo da response
set_data() Define o corpo da response como string ou bytes
get_data() Retorna o corpo da response


Dois tipos de resposta especiais para casos que ocorrem com frequência são fornecidos como funções auxiliares. Uma delas é uma forma de lidar com erros, eviando um código por meio do método abort(). No exemplo abaixo usamos essa função para enviar uma mensagem 404 (página não encontrada) caso a id de um usuário não seja encontrada com load_user(id).

from flask import abort
@app.route('/usuario/<id>')
def usuario(id):
    usuario = load_user(id)
    if not usuario:
        abort(404)
    return f'<div class="usuario">Bem vindo {usuario.name}</div>'

Esse exemplo supõe que exista um template para usuario e que ele carrega instruções css para a classe usuario.

Se load_user(id) retornar None é executada abort(404) que retorna a mensagem de erro. Uma exceção é levantada e a função usuario() é abandonada antes de atingir a instrução return.

O outro tipo de resposta é o redirect que não retorna uma página mas sim uma nova URL redirecionando o navegador. redirect retorna status_code = 302 e a nova URL (dada no código).

from flask import redirect
@app.route('/')
def index():
    return redirect('https://phylos.com/programacao')

Bibliografia

Veja a bibliografia na Parte 1.


Flask, Parte 3 está em preparação!

Flask, parte 1


Web Frameworks

Para a construção de páginas e aplicativos web é essencial algum conhecimento de html e css, não cobertos nesse artigo.

Um aplicativo web web application ou simplesmente web app) é um aplicativo executado através de um servidor web, diferente de um aplicativo executado na máquina local, e geralmente rodados e visualizados por meio de um browser ou navegador. Eles podem ser um conjunto de páginas de texto dinâmicas contendo, por exemplo, pesquisas em uma biblioteca, um gerenciador de arquivos na nuvem, um gerenciador de contas bancárias ou emails, um servidor de músicas ou filmes, etc. Com frequência esses aplicativos estão conectados a um banco de dados, podendo fazer neles consultas e modificações.

Um framework para a web é um conjunto de softwares destinados a oferecer suporte ao desenvolvimento de aplicativos na Web. Eles buscam automatizar a construção dos web apps e seu gerenciamento durante o funcionamento, após sua publicação na web. Alguns frameworks web incluem bibliotecas para acesso a banco de dados, templates para a construção dinâmica das páginas e auxílio à reutilização de código. Eles podem facilitar o desenvolvimento de sites dinâmicos ou, em alguns casos, de sites estáticos.

Framework Flask

O Flask é um micro-framework em Python usado para desenvolvimento e gerenciamento de web apps. Ele é considerado micro porque possui poucas dependências para seu funcionamento e pode ser usado com uma estrutura inicial bem básica, voltada para aplicações simples. Apenas duas bibliotecas são instaladas junto com o Flask. Ele não contém, por ex., um gerenciador de banco de dados ou um servidor de email. No entanto esses serviços podem ser acrescentados para ampliar as funcionalidades do aplicativo.

Em particular, o Flask não inclui uma camada de abstração com banco de dados. Em vez disso é possível instalar extensões, escolhendo o banco de dados específico que se quer usar. Essas extensões também podem auxiliar a validação de formulário, manipulação de upload, tecnologias de autenticação aberta, entre outras.

Flask foi desenvolvido por Armin Ronacher e lançado em 2010. Algumas vantagens citadas em seu uso são: (a) projetos escrito com a Flask são simples (comparados àqueles gerados por frameworks maiores, como o Django) e tendem a ser mais rápidos. (b) Ele é ágil e modular: o desenvolvedor se concentra apenas nos aspectos utilizados de seu aplicativo, podendo ampliar sob demanda. (c) Os projetos são pequenos mas robustos. (d) Existe uma vasta comunidade de desenvolvedores contribuindo com seu desenvolvimento, apresentando bibliotecas que ampliam sua funcionalidade.

Criando um aplicativo básico

Nessa primeira parte vamos criar um aplicativo bem básico mas funcional. Depois entraremos em outros detalhes do Flask.

Para criar um projeto usando o Flask (ou, na verdade, outro projeto qualquer) é aconselhável criar antes um ambiente virtual para que os pacotes instalados não conflituem com outros em projetos diferentes. A criação e manutenção de ambientes virtuais está descrita na página Ambientes Virtuais, PIP e Conda. Alguns IDEs, como o Pycharm realizam automaticamente esse processo. No meu caso ele ficará abrigado em ~/Projetos/flask/venv. Para simplificar denotarei esse ambiente simplesmente pelo nome flask, que é também o nome da pasta que abriga esse projeto.

Nesse ambiente instalamos o Flask com pip install flask. Uma estrutura básica de ambiente já estará montada após esse passo. Em seguida criamos um arquivo do python, de nome meu_site.py.

# meu_site.py
from flask import Flask
app = Flask(__name__)

@app.route("/")
def homepage():
    return "Esta é a minha homepage"

if __name__ == "__main__":
    app.run()

# é exibido no console do python
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

Esse arquivo não deve se chamar flask.py para evitar conflito de nomes.

A variável __name__, passada para o construtor do Flask contém o nome do módulo principal e é usada para determinar a localização do aplicativo no app. Outros diretórios e arquivos, como pastas de templates, arquivo de estilo e imagens, serão localizadas à partir dessa raiz.

Aplicativos do Flask incluem um servidor de desenvolvimento que pode ser iniciado com o comando run. Esse comando busca pelo nome do aplicativo na variável de ambiente FLASK_APP. Se queremos rodar o aplicativo meu_site.py executamos na linha de comando:

# Linux ou macOS
(venv) $ export FLASK_APP=meu_site.py
(venv) $ flask run
 * Serving Flask app "hello"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

# Microsoft Windows
(venv) $ set FLASK_APP=meu_site.py
(venv) $ flask run
 * Serving Flask app "hello"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 
 # alternativamente se pode iniciar o servidor com
 app.run()

No Pycharm, ou outros IDES, você pode executar diretamente esse código.

Da biblioteca flask importamos apenas (por enquanto) a classe Flask. Uma instância da classe é criada com app = Flask(__name__) onde a variável __name__ contém o nome do projeto. A linha @app.route("/") é um decorador que informa que a função seguinte será rodada na raiz / do site. Quando esse arquivo .py é executado dentro de uma IDE ou usando python meu_site.py, na linha de comando, é exibido no console várias mensagens, entre elas a url http://127.0.0.1:5000/, que pode ser clicada ou copiada para a linha de endereço do navegador. Isso resulta na exibição, dentro do navegador, da página:


Clientes e Servidores: O navegador age como o cliente que envia ao servidor uma solicitação, através de uma URL digitada na barra de endereços. O servidor da web transforma essa solicitação em ações a serem realizadas do lado do servidor e retorna uma página com conteúdo de texto e multimídia, renderizados pelo navegador. O Flask fica do lado do servidor, construindo a resposta. Entre outras coisas ele possui um mapeamento entre as URLs e as funções route() que serão executadas no código *.py.

O endereço e a porta 127.0.0.1:5000 são padrões para o Flask. app.run() cria um servidor que atende à requisição HTTP do navegador, exibindo a página html. Qualquer texto retornado pela função homepage() é renderizado no formato html e exibido no navegador. Por exemplo, se fizermos as alterações, colocando o texto entre tags h1:

@app.route("/")
def homepage():
    return "<h1>Esta é a minha homepage</h1>"

if __name__ == "__main__":
    app.run(debug=True)

o texto agora é renderizado como um título de nível 1:

o mesmo texto será exibido mas agora com formatação de título, a tag h1. Todas as demais tags podem ser utilizadas. O parâmetro debug=True faz com que alterações no código sejam imediatamente repassadas para as requisições ao servidor, sem a necessidade de rodar todo o projeto novamente. Com isso basta recarregar a página do navegador para que alterações sejam exibidas, clicando no ícone de atualização ou pressionando F5. No mode debug os módulos dois módulos chamados reloader e debugger estão ativados por default. Com o debugger ativado as mensagens de erro são direcionadas para a página exibida. O mode debug nunca deve ser ativado em um servidor em produção pois isso fragiliza a segurança do site.

Também podemos ativar o módulo no código que executa o aplicativo:

(venv) $ export FLASK_APP=meu_site.py
(venv) $ export FLASK_DEBUG=1
(venv) $ flask run

O decorador @app.route("/") registra a função homepage() junto com a página raiz do site. Outras páginas vão executar outras funções. Por exemplo, uma página de contatos pode ser inserida por meio da inserção de nova função no código. Nesse caso criaremos a função contatos().

# meu_site.py

from flask import Flask
app = Flask(__name__)

@app.route("/")
def homepage():
    return "<h1>Esta é a minha homepage</h1>"

@app.route("/contatos")
def contatos():
    txt = (
    "<h1>Página de Contatos</h1>"
    "<ul>"
    "<li>Contato 1</li>"
    "<li>Contato 2</li>"
    "</ul>"
    )
    return txt

if __name__ == "__main__":
    app.run(debug=True)

Usamos acima a concatenação de string com parênteses: (str1 str2 ... strn).
Agora, além da homepage temos a página de contatos em 127.0.0.1:5000/contatos, com a seguinte aparência.

A funções contatos() e homegage() são chamadas funções de visualização (view functions).

Html e templates: Notamos agora que o código em meu_site.py contém sintaxe misturada de Python e html e pode ficar bem complexo em uma página real exibida na web. Para evitar isso o Flask permite a criação de templates. Fazemos isso da seguinte forma: no diretório raiz onde está o projeto (o mesmo onde foi gravado meu_site.py) criamos o diretório template (o nome default do Flask). Dentro dele colocamos nossos templates. Por exemplo, criamos os arquivos homepage.html,

# homepage.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Homepage: teste Flask</title>
</head>
<body>
    <h1>Este é o título da Homepage</h1>
    <p>Com os devidos parágrafos...</p>
</body>
</html>

e contatos.html:

# contatos.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Contatos</title>
</head>
<body>
    <h1>Página de Contatos</h1>
    <ul>
    <li>Contato 1</li>
    <li>Contato 2</li>
    </ul>
    </body>
</html>

Vários IDEs podem auxiliar na criação desses arquivos html, fornecendo um esqueleto básico a ser preenchido pelo programador.

Além disso modificamos nosso código python para usar a renderização dos templates, importando render_template.

# meu_site.py
from flask import Flask, render_template
app = Flask(__name__)

@app.route("/")
def homepage():
    return render_template("homepage.html")

@app.route("/contatos")
def contatos():
    return render_template("contatos.html")

if __name__ == "__main__":
    app.run(debug=True)

Quando esse código é executado temos a referência ao link que, se aberto, mostra as páginas criadas: digitando 127.0.0.1:5000 abrimos nossa homepage:

Mas, se digitarmos 127.0.0.1:5000/contatos a outra página é exibida:

Uma página pode receber parâmetros do código em python. Por exemplo, digamos que queremos exibir uma página para cada produto existente em uma loja virtual que vende frutas. Nesse caso acrescentamos no código de meu_site.py:

@app.route("/frutas/<nome_da_fruta>")
def frutas(nome_da_fruta):
    return render_template("frutas.html")

Para receber esse parâmetro temos que gravar a página frutas.html na pasta templates, com um conteúdo que receba essa variável.

# frutas.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Frutas disponíveis</title>
</head>
<body>
    <h1>Frutas</h1>
    <p>Você escolheu a fruta: {{nome_da_fruta}}</p>
</body>
</html>

Se for digitado no campo de endereços do navegador, ou passado por meio de um link na tag <a href="http://127.0.0.1:5000/frutas/laranja">Laranja</a> a parte do endereço <nome_da_fruta> = laranja é passado como valor de parâmetro na função frutas("laranja") que é disponibilizado dentro do código html como {{nome_da_fruta}}.

Resumindo: @app.route("/frutas/<nome_da_fruta>") envia uma string na variável nome_da_fruta para a função frutas que, por sua vez repassa ao código html. Dentro do html a variável fica disponível como {{nome_da_fruta}} (dentro de uma dupla chave).

Por exemplo, se digitamos na barra de endereços do navegador http://127.0.0.1:5000/frutas/laranja teremos a exibição de

Essa técnica pode ser usada, por ex., para criar páginas para diversos usuários usando um único template usuario.html.

@app.route('/usuario/<nome>')
def usuario(nome):
    return render_template("usuario.html")
# ou
@app.route('/usuario/<int:id>')
    return render_template("usuario.html")

A parte do código <int:id> é um filtro que transforma a entrada digitada em inteiro, quando possível e será melhor explicada adiante.

Formatando com CSS

O texto dentro de uma página html (HyperText Markup Language) pode ser formatado de algumas formas diferentes, usando css (Cascading Style Sheets). Quando se trata do uso de um framework a forma preferida consiste em apontar no cabeçalho para um arquivo externo css. No Flask isso é feito da seguinte forma: um arquivo css é gravado na pasta static da pasta do projeto. Digamos que gravamos o arquivo /static/styles.css com um conteúdo mínimo, apenas para demonstração, tornando vermelhas as letras do título e azuis as letras dos parágrafos:

# arquivo /static/styles.css
h1 { color:red; }
p { color:blue; }

No cabeçalho das páginas html, dentro da tag <head> colocamos um link para o arquivo de estilos:

# homepage.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" type="text/css" href="static/styles.css">
    <title>Homepage: teste Flask</title>
</head>

Agora, ao acessar a homepage veremos:


Com todas essas alterações o projeto tem agora a estrutura de pastas mostrada. Na figura à esquerda todas as pastas, inclusive aquelas criadas pelo gerenciador de ambientes virtuais são mostradas. No meu caso elas foram criadas automaticamente pelo IDE Pycharm, mas podem ser criadas pelo programador sem dificuldade. Na figura à direita são mostradas apenas as pastas criadas pela programador diretamente. Um projeto com esse formato roda perfeitamente, apesar de não contar com as vantagens do ambiente virtual (veja artigo).

Outras estruturas de código podem ser inseridas nos templates, como veremos.

Opções de comando de linha

Quando se roda o flask diretamente no terminal podemos ver uma mensagem de ajuda com (venv) $ flask --help, verificar os caminhos definidos no app ou entrar em uma shell interativa.

# Exibir ajuda do flask    
(venv) $ flask --help
  Usage: flask [OPTIONS] COMMAND [ARGS]...
  
    A general utility script for Flask applications.
  
    Provides commands from Flask, extensions, and the application. Loads the
    application defined in the FLASK_APP environment variable, or from a wsgi.py
    file. Setting the FLASK_ENV environment variable to 'development' will
    enable debug mode.
  
      $ export FLASK_APP=hello.py
      $ export FLASK_ENV=development
      $ flask run
  
  Options:
    --version  Show the flask version
    --help     Show this message and exit.
  
  Commands:
    routes  Show the routes for the app.
    run     Run a development server.
    shell   Run a shell in the app context.
  
# exibe os caminhos ativos no aplicativo
  (venv) $ flask routes
  Endpoint  Methods  Rule
  --------  -------  -----------------------
  contatos  GET      /contatos/
  frutas    GET      /frutas/
  frutas    GET      /frutas/
  homepage  GET      /
  static    GET      /static/
  
# entra em uma shell interativa    
  (venv) $ flask shell

A shell do flask inicia uma sessão python no contexto do atual aplicativo onde podemos executar testes ou tarefas de manutenção. O comando flask run admite vários parâmetros:

(venv) $ flask run --help
  Usage: flask run [OPTIONS]
  
    Run a local development server.
  
    This server is for development purposes only. It does not provide the
    stability, security, or performance of production WSGI servers.
  
    The reloader and debugger are enabled by default if FLASK_ENV=development or
    FLASK_DEBUG=1.
  
  Options:
    -h, --host TEXT                 The interface to bind to.
    -p, --port INTEGER              The port to bind to.
    --cert PATH                     Specify a certificate file to use HTTPS.
    --key FILE                      The key file to use when specifying a
                                    certificate.
    --reload / --no-reload          Enable or disable the reloader. By default
                                    the reloader is active if debug is enabled.
    --debugger / --no-debugger      Enable or disable the debugger. By default
                                    the debugger is active if debug is enabled.
    --eager-loading / --lazy-loading
                                    Enable or disable eager loading. By default
                                    eager loading is enabled if the reloader is
                                    disabled.
    --with-threads / --without-threads
                                    Enable or disable multithreading.
    --extra-files PATH              Extra files that trigger a reload on change.
                                    Multiple paths are separated by ':'.
    --help                          Show this message and exit.  

O argumento --host informa ao servido qual é o ambiente web que pode acessar nosso servidor de desenvolvimento. Por default o servidor de desenvovimento do Flask só aceita chamadas do computador local, em localhost. Mas é possível configurá-lo para receber chamadas da rede local ou de ambientes mais amplos. Por exemplo, como o código

(venv) $ flask run --host 0.0.0.0
 * Serving Flask app "hello"
 * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)

todos os computadores conectados pelo mesmo ip terão acesso ao aplicativo.

Implantação de um aplicativo Flask

O processo de implantação (ou deploy) de um aplicativo consiste nas etapas necessários para colocá-lo acessível para seus usuários. No caso de um aplicativo web a implantação significa estabelecer um servidor ou usar servidores já disponíveis, que os usuários possam acessar, e colocar seu aplicativo como um de seus serviços.

O desenvolvimento do aplicativo se dá em um ambiente de desenvolvimento onde podem existir condições próprias para o debug e nem todas as medidas de segurança estão implementadas. Depois ele passa para a etapa de uso, no ambiente de produção. Uma conta no Heroku pode ser criada, e um site com poucos acessos pode ser mantido sem custos. Se o site for escalonado e crescer a conta deve ser atualizada e paga. A lista abaixo contém links para o Heroku e outros provedores.

Bibliografia

Livros sobre Flask

  • Aggarwal, Shalabh: Flask Framework Cookbook, 2.Ed., Packt, Birmingham-Mumbai, 2019.
  • Ashley, David: Foundation Dynamic Web Pages with Python Create Dynamic Web Pages with Django and Flask, Apress, 2020.
  • Gaspar, D.;StoufferHaider, J.: Mastering Flask Web Development, 2.Ed., Packt, Birmingham-Mumbai, 2018.
  • Grinberg, Miguel: The Flask Mega-Tutorial, Edição do autor, 2020.
  • Grinberg, Miguel: Flask Web Development, Developing Web Applications with Python, O’Reilly, Sebastopol, 2018.
  • Haider, Rehan: Web API Development With Python, CloudBytes, 2020.
  • Maia, Italo: Building Web Applications with Flask, 2.Ed., Packt, Birmingham-Mumbai, 2015.
  • Relan, Kunal: Building REST APIs with Flask, 2.Ed., Apress, 2019.

Referências na Web

Sobre HTML e CSS

todos acessados em fevereiro de 2022.


Ambientes Virtuais, PIP e Conda


Ambiente virtual

Um ambiente virtual é uma área isolada de seu computador onde pacotes específicos são instalados para o uso, sem o problema de conflitarem com outras versões instaladas. Com isso cada projeto pode ter suas próprias dependências, diferentes das possíveis dependências em outros projetos. Até mesmo versões diferentes do python podem ser usadas. Um projeto do Python pode usar diversos pacotes e módulos, sendo que alguns deles podem não estar na biblioteca padrão. Vários ambientes podem ser criados e gerenciados separadamente, sem limite na quantidade, pois são apenas diretórios contendo scripts. Esses ambientes podem ser criados usando as ferramentas no comando de linha venv, virtualenv ou pyenv. Nos concentraremos aqui na ferramenta venv.

Criando ambientes virtuais

Na construção de um aplicativo uma versão específica de uma biblioteca, ou até do próprio Python, pode ser necessária. Para isso a linguagem oferece a possibilidade de se criar ambientes virtuais: um ambiente independente armazenado em uma árvore de diretórios própria contendo a instalação do Python e pacotes em versão específica.

O módulo venv é usado para criar e gerenciar ambientes virtuais. Ele seleciona e organiza a versão do Python e dos módulos usados no projeto.

Para criar um ambiente virtual você deve decidir em que diretório ele deve ser abrigado. Depois execute o módulo venv como um script, no prompt de comando, informando o caminho do diretório. Uma boa prática é criar uma pasta oculta .venv na sua pasta raiz ou pasta de projetos para abrigar todos os seus ambientes virtuais. No exemplo criaremos a pasta ~/Projetos/.venv/aprendendo:

$ python3 -m venv ~/Projetos/.venv/aprendendo
Figura 1: estrutura de arquivos com venv.

Dentro da pasta aprendendo é criada uma estrutura de pastas contendo uma cópia do interpretador e alguns arquivos de configuração, mostrada na figura 1.

Essas pastas tem o conteúdo:

  • bin: arquivos que interagem com o ambiente virtual
  • include: cabeçalhos C que compilam os pacotes Python
  • lib: uma cópia da versão do Python junto com uma pasta site-packages onde cada dependência está instalada

Parte desses arquivos são links simbólicos (ou symlinks) que apontam para as corretas versões das ferramentas do python.

Na pasta bin ficam os scripts de ativação usados para definir o ambiente para uso. Depois de criado o ambiente pode ser ativado por meio do comando activate.

No Windows activate.bat é um arquivo de lote que indica a posição do executável que aciona o ambiente. No Linux source é um comando interno da shell que lê e executa o arquivo indicado.
# No windows:
$ ~\Projetos\.venv\aprendendo\Scripts\activate.bat

# No Linux (bash shell)
$ source ~/Projetos/.venv/aprendendo/bin/activate

Ao ser carregado um novo ambiente o prompt de comando muda para indicar qual ambiente está em uso. Nesse prompt carregamos o Python (no meu caso 3.8.8 Anaconda), importamos a biblioteca sys e verificamos os caminhos em uso.

(aprendendo) $ python
Python 3.8.8 (default, Apr 13 2021, 19:58:26) 
[GCC 7.3.0] :: Anaconda, Inc. on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.path
   ['', '/home/usr/.anaconda3/lib/python38.zip', '/home/usr/.anaconda3/lib/python3.8', '/home/usr/.anaconda3/lib/python3.8/lib-dynload', '/home/usr/Projetos/.venv/aprendendo/lib/python3.8/site-packages']
>>> sys.prefix
   '/home/usr/Projetos/.venv/aprendendo'

Esse ambiente está isolado do meio externo. Por exemplo, no meu caso o pandas foi instalado junto com o Anaconda. No entanto ele não pode ser apontado por um código rodando no ambiente (aprendendo).

>>> import pandas
    Traceback (most recent call last):
      File "", line 1, in 
    ModuleNotFoundError: No module named 'pandas'

Para sair do ambiente reservado usamos deactivate no terminal.

(aprendendo) (base) [guilherme@gui ~]$ deactivate
(base) [guilherme@gui ~]$ python
Python 3.8.8 (default, Apr 13 2021, 19:58:26)
[GCC 7.3.0] :: Anaconda, Inc. on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pandas

Como o pandas está instalado globalmente, nenhuma mensagem de erro é gerada e o módulo fica disponível para uso.

A ativação de um ambiente significa a especificação do local onde estão os executáveis e bibliotecas importadas. Para reativar o ambiente nos “deslocamos” até a pasta onde ele está instalado e executamos activate.

$ cd /home/usuario/Projetos/.venv/aprendendo
$ source bin/activate
# o prompt é alterado
(aprendendo) $ python
>>> import sys
>>> sys.prefix
    '/home/guilherme/Projetos/.venv/aprendendo'
Observação Importante: Para instalar env com versão específica do Python

Pode ocorrer que existam mais de uma versão do Python instalada em seu computador. Para criar um ambiente com outra versão devemos executar o script do venv na versão que desejamos para o ambiente virtual. Por exemplo, para um ambiente com python 3.11 (supondo que ele esteja instalado nesse computador) devemos executar:

$ python3.11 -m venv ~/Projetos/.venv/VsCode
$ source ~/Projetos/.venv/VsCode/bin/activate
$ python
>>> Python 3.11.0a7 (main, Apr  7 2022, 00:00:00) [GCC 11.2.1 20220127 (Red Hat 11.2.1-9)] on linux
>>> Type "help", "copyright", "credits" or "license" for more information.

Nesse caso criamos um ambiente virtual no diretório ~/Projetos/.venv/VsCode.

Uma discussão um pouco mais detalhada sobre a instalação de ambientes virtuais e uso do pip para sistemas onde mais de uma versão do python estão instaladas pode ser lida em: Python, pip e venv.

Gerenciando ambientes virtuais com pip

Uma vez dentro do novo ambiente você pode instalar pacotes usando pip que, por default, encontra e instala pacotes do Python Package Index. PIP é o gerenciador padrão de pacotes (ou bibliotecas) do Python, que acessa um reservatório de pacotes publicados no Python Package Index, ou PyPI. Em versões mais recentes ele vem instalado por default. Um pacote é um conjunto de arquivos que executam uma ou várias funções. Eles podem ser importados em um aplicativo para extender a funcionalidade do Python padrão. PIP funciona por meio de comandos de linha, digitados no prompt do sistema operacional.

A sintaxe de venv é descrita abaixo. Aqui usamos a notação [parâmetros opcionais], o pipe | para indicar opções (um ou | outro). Apenas ENV_DIR é obrigatório e posicional.

venv [-h] [–system-site-packages] [–symlinks | –copies] [–clear] [–upgrade]
[–without-pip] [–prompt PROMPT] [–upgrade-deps] ENV_DIR [ENV_DIR …]
Cria o ambiente virtual em um ou mais diretórios especificados.
ENV_DIR Diretório onde criar o ambiente virtual,
-h, –help exibe (o presente) texto de ajuda,
–system-site-packages Dá acesso ao ambiente virtual para a pasta site-packages do sistema.
–symlinks Tenta usar symlinks no lugar de cópias, quando os symlinks não são default na plataforma.
–copies Tenta usar cópias no lugar de symlinks, mesmo que symlinks sejam o default na plataforma.
–clear Apaga o conteúdo do diretório de ambiente, se existe, antes da criação do ambiente.
–upgrade Atualiza o diretório de ambiente, caso o python do ambiente tenha sido atualizado.
–without-pip Interrompe a instalação ou atualização via pip nesse ambiente. (Por default o pip é acionado).
–prompt PROMPT Estabelece um prefixo alternativa para o prompt desse ambiente.
–upgrade-deps Atualiza as dependências do pip setuptools para o última versão disponível em PyPI.

Depois que o ambiente é criado você deve ativá-lo com o script source pasta/do/ambiente/bin/activate.

Instalação de PIP

Você pode verificar a presença do pip (ou conferir a versão) com pip --version, no prompt de comando.

$ pip --version
  pip 22.0.3 from /home/usuario/Projetos/.venv/aprendendo/lib/python3.8/site-packages/pip (python 3.8)    

O output do comando mostra a versão do pip e do python sendo usados no ambiente virtual instalado na pasta /home/usuario/Projetos/.venv/aprendendo.

Alternativamente, é possivel encontrar onde está instalado o pip:

# no Windows    
C:\> where pip3  
# no Linux
$ which pip
~/.anaconda3/bin/pip
$ which pip3
~/.anaconda3/bin/pip3

Caso o pip não esteja instalado, isso pode ser feito de duas formas: ensurepip e get-pip.py.

No prompt do terminal de seu sistema (que representaremos por $, comentários por #) digite:

$ python -m ensurepip
# ou 
$ python -m ensurepip --upgrade

A chave -m garante que pip seja executado como um módulo. Na segunda forma se garante que apenas versões mais novas que atual (se presente) seja instalada. Nenhuma ação será executa se já existe a instalação, ou se está em sua versão mais atual, no segundo caso. pip será instalado globalmente ou no ambiente virtual, se esse estiver ativo.

Outra alternativa é baixar o script get-pip.py e executá-lo com a primeiro linha no código. A segunda linha é uma forma de atualizá-lo.

# instalar pip
$ python get-pip.py
# fazer atualização de pip
$ python -m pip install --upgrade pip

Instalando módulos com o PIP

O uso geral de pip é o seguinte:

# no linux/Mac    
$ python -m pip <argumentos>
# ou
$ pip <argumentos>
# no windows    
$ py -m pip <argumentos>

Para instalar um módulo com o PIP executamos pip com o argumento install e o nome do módulo. Vários módulos podem ser instalados com uma única linha.

$ python -m pip install 
# ou    
$ pip install 
# por exemplo, para instalar o Flask
$ pip install Flask
# vários módulos
$ python -m pip install   ... 

Para controlar qual é a versão a ser instalado usamos:

python -m pip install Modulo             # instalar a última versão
python -m pip install Modulo ==1.0.4     # instalar versão especificada
python -m pip install 'Modulo >=1.0.4'   # especificar a versão mínima

É possível passar para pip uma lista de requisitos para a exata reprodução de um ambiente.

# para gerar o arquivo relativa a um ambiente:
$ python -m pip freeze > requirements.txt
# para reproduzir a instalação:
$ python -m pip install -r requirements.txt    

O arquivo requirements.txt contém uma lista dos argumentos do pip install. Essa lista pode ser gerada com freeze.

pip pode ser usado para instalar pacotes de outros repositórios. Por exemplo, se você deseja instalar o pacote rptree disponível em TestPyPI package index, ou no GitHub.

# no TestPyPI    
python -m pip install -i https://test.pypi.org/simple/ rptree
# no GitHub
python -m pip install git+https://github.com/realpython/rptree

Observação: pode ocorrer que em seu computador o python 3 esteja instalado com o nome python3.

<h2=”idm”>Listando e desinstalando módulos

Módulos instalados podem ser vistos com o argumento list. A lista obtida reflete a instalação do Flask e suas dependências. Uma lista com pacotes desatualizados é obtida com a chave list –outdated.

$ python -m pip list
  Package      Version
  ------------ -------
  click        8.0.3
  Flask        2.0.2
  itsdangerous 2.0.1
  Jinja2       3.0.3
  MarkupSafe   2.0.1
  pip          22.0.3
  setuptools   49.2.1
  Werkzeug     2.0.2    

# listar apenas pacotes desatualizados
$ python -m pip list --outdated
  Package    Version Latest Type
  ---------- ------- ------ -----
  setuptools 49.2.1  60.8.1 wheel

Pode ocorrer que você deseje usar outro pacote e queira remover o antigo de seu computador. A desinstalação é feita com uninstall. pip desinstala pacotes com versões desatualizadas antes de fazer uma atualização para versão mais nova.

Um cuidado deve ser tomado: Quando um pacote é instalado é possível que ele possua dependências que são instaladas juntas com ele. Se você tem muitos pacotes instalados é possível que mais de um use a mesma dependência. Por isso é importante verificar se ele é dependência de outro ou se possui dependências. Para isso usamos python -m pip show <modulo>.

$ python -m pip show Flask
Name: Flask
Version: 2.0.2
Summary: A simple framework for building complex web applications.
Home-page: https://palletsprojects.com/p/flask
Author: Armin Ronacher
Author-email: armin.ronacher@active-4.com
License: BSD-3-Clause
Location: /home/guilherme/.anaconda3/lib/python3.8/site-packages
Requires: itsdangerous, Jinja2, click, Werkzeug
Required-by: 

Vemos no output que Flask possui as dependências (Requires: )itsdangerous, Jinja2, click e Werkzeug. Por outro lado ele não é exigido por nenhum outro modulo (Required-by:) portanto pode ser seguramente desinstalado. Para isso usamos uninstall.

$ python -m pip uninstall Flask

O mesmo procedimento deve ser usado com as dependências, caso você queira apagá-las.

Busca por pacotes

pip pode fazer buscas por um pacote com o comando:

python -m pip search "query"

A pesquisa retorna uma lista de pacotes com uma breve descrição de cada um.
Importante: pip search deixou de funcionar em dezembro de 2020. Um substituto para esse comando é poetry (que deve ser instalado em seu sistema). Por exemplo, uma busca por pacotes com “pandas” no nome: (o output está truncado).

$ poetry search "pandas"
  pandas (1.4.0)
     Powerful data structures for data analysis, time series, and statistics
  pandas3 (0.0.1)
     Boto3 extension to help facilitate data science workflows with S3 and Pandas
  pandas-alchemy (0.0.2)
     SQL based, pandas compatible DataFrame & Series


Poetry é similar ao npm do JavaScript, gerenciando pacotes e auxiliando na criação de distribuições de aplicativos e bibliotecas, além da inserção no PyPI. Outro pacote é o Pipenv que gerencia pacotes e controla ambientes virtuais. Real Python: Pipenv Guide.

Estrutura de um projeto Python

O Python é bastante flexível na questão de estrutura de pastas para um projeto. No entanto algumas sugestões foram dadas para um desenho ótimo para um projeto. Aqui eu sigo as sugestões de Lucas Tonin.
Vamos denominar nosso projeto de meu_projeto. Se estamos usando, como é recomendado, um ambiente virtual podemos criá-lo com python3 -m venv ~/Projetos/.venv/meu_projeto, que já estabelece uma estrutura mínima de pastas. Vamos por hora ignorar as pastas relativas ao ambiente virtual.

Para definir uma terminologia chamamos de projeto python tudo aquilo que estará no diretório base, que em nosso caso é ~/Projetos/.venv/meu_projeto. Todos os arquivos relacionados ao desenvolvimento, teste e arquivos auxiliares ficam nesse diretório. Chamamos de pacote (package) ao conteúdo de um subdiretório dentro do projeto com o mesmo nome. O pacote contém o código-fonte do aplicativo. Ele isola o código fonte de todos os outros arquivos. Depois de pronto a instalaçao do projeto inclui apenas os arquivos contidos nesse diretório, ignorando código fonte e testes.

Arquivo __init__.py: O pacote deve necessariamente conter pelo menos um arquivo como o nome __init__.py. A presença desse informa ao python que esse diretório é um pacote. __init__.py é executado automaticamente quando esse pacote é carregado e deve conter as inicializações para o aplicativo. Duas coisas importantes podem ser aí incluídas: (a) uma variável ROOT_DIR com o caminho absoluto do atual pacote, onde estiver no momento; (b) as configurações de logger, quando existir.

from os.path import dirname, abspath
ROOT_DIR = dirname(abspath(__file__))
# inicialização de logs

Um arquivo de documentação, README.md: geralmente esses arquivos são escritos em MARKDOWN. Ele deve conter uma descrição de seu projeto para outros usuários de seu código, ou para você mesmo no caso de retomar após um tempo esse trabalho, além de instruções de instalações. Um exmplo simples:

# Meu Projeto
Um aplicativo simples para a importação de arquivos *csv* e exportação para banco de dados SQL.
## Instalação
Para instalar execute `pip install /caminho/meu_projeto`

Usando setup.py: o arquivo setup.py contém informações sobre configurações do pacate a ser instalado. No mínimo ele deve conter:

import setuptools
setuptools.setup(name='meu_projeto', packages=['meu_projeto'])

que informa o nome do projeto e do pacote. O parâmetro packages informa ao pip que apenas esse diretório será instalado.

Para projetos maiores e mais complexos, que envolvem muitas dependências, é útil acrescentar um arquivo requirements.txt Este arquivo lista os pacotes necessários para o projeto, que não fazem parte da biblioteca padrão. Com ele o pip pode baixar e instalar automaticamente todas as dependências, se não o encontrar já instalado. Por exemplo, se o projeto depende do numpy (um pacote para computação científica) a arquivo deverá conter pelo menos a linha

numpy==1.18.2

O arquivo setup.py deve ser modificado para que essa informação seja usada.

# setp.py
import setuptools
with open('requirements.txt', 'r') as f:
    install_requires = f.read().splitlines()

setuptools.setup(name='meu_projeto',
                 packages=['meu_projeto'],
                 install_requires=install_requires)


Um arquivo LICENCE, que descreve a licença sob a qual você está distribuindo seu projeto, pode ficar no diretório base, onde pode ser facilmente encontrado. Finalmente, uma pasta separa para os testes usados para testar a correção do código.

O projeto fica portanto com a seguinte estrutura mostrada na figura.

Usando Jupyter Notebook em ambiente virtual

Anaconda é uma plataforma de distribuição de Python e R muito usada para a ciência de dados e aprendizado de máquina. Ele simplifica a instalação de pacotes como pandas, NumPy, SciPy, e pode ser usada com diversas outras linguagens. Conda é o gerenciador padrão de pacotes do Anaconda, multiplataforma e agnóstico à linguagem e que pode ser usado para instalar pacote de terceiros. O Anaconda Navigator, instalado junto com o Anaconda, é uma interface gráfica que permite o gerenciamento de pacotes coda, com busca, instalação, atualização e desinstalação, execução dos aplicativos incluidos na Anaconda Cloud ou outro repositório Anaconda local. Todo esse sitema está disponível para Windows, macOS e Linux.

O Anaconda Cloud é um serviço de nuvem que abriga pacotes, notebooks e ambientes Python para variadas situações e casos, incluindo pacotes conda e PyPI públicos e privados. Ele permite o uploud de pacotes de usuário e notebooks, sem a necessidade de um login ou conta na nuvem.

Jupyter Notebook é uma ambiente interativo de interface do usuário da Web onde se pode rodar código nas linguagens instaladas, criar documentos de notebook contendo texto (em Markdown), imagens e vídeos. Esses documentos podem ser partilhados e publicados na nuvem, onde podem ser alterados e executados por outros usuários.

O Jupyter Notebook faz um gerenciamento de ambiente próprio. Mas tambem podemos criar um ambiente virtual específico para ele. Isso é bastante útil pois esse ambiente é usualmente dedicado à computação científica e aplicações com grandes volumes de dados, exigindo bibliotecas específicas. Para isso criamos um ambiente virtual, que chamaremos de jupyter. Depois ativamos o ambiente e instalamos o Jupyter Notebook dentro desse ambiente.

# criamos o ambiente virtual    
$ python3 -m venv ~/Projetos/.venv/jupyter_venv
# ativamos esse ambiente
$ cd /home/usuario/Projetos/.venv/jupyter_venv
$ source bin/activate 
# instala o Jupyter Notebook no ambiente
(jupyter) $ ipython kernel install --user --name=jupyter_venv

Para usar o ambiente virtual abra o jupyter e selecione o kernel no menu kernel | change kernel. A opção para o ambiente jupyter_venv deve estar disponível, como mostra a figura, como uma das opções de kernel a usar.

Para desinstalar o ambiente fazemos:

$ jupyter-kernelspec uninstall jupyter_venv

Gerenciador conda


Para quem está trabalhando com a distribuição Python Anaconda (site) é mais interessante usar o gerenciador conda, que além de gerenciar pacotes e suas dependências controle também ambioentes virtuais. Ele pode ser usado com Python, R, Ruby, Lua, Scala, Java, JavaScript, C/ C++, FORTRAN e outras.

Com o conda você pode pesquisar por pacotes, instalar os pacotes desejados ou construir um pacote do usuário com build (conda-build deve ser instalado).

A versão de conda pode ser verificada, e atualizada se necessário. Essa atualização pode incluir atualização de outros pacotes e remoção de pacotes não usados.

# verificar versão    
$ conda --version
  conda 4.10.3
# informações mais detalhadas podem ser obtidas
$ conda info
    active environment : base
    active env location : /home/guilherme/.anaconda3
# -----(outpup truncado)-----

# atualizar versão
$ conda update conda
# se existir versão mais recente
  Proceed ([y]/n)? y

A pesquisa e instalação de pacotes é feita com search e install. A construção (build) de pacotes é feitas com build.

# pesquisar   
$ conda search scipy
# instalação
$ conda install scipy
# o novo pacote deve estar na lista
$ conda list

# construir um pacote
$ conda build meu_projeto

Versões podem ser especificadas, inclusive com o uso de operadores lógicos.

conda install numpy=1.11                  #(instala versão especificada)
conda install numpy==1.11                 #(idem)
conda install "numpy>1.11"                #(versão superior a 1.11)
conda install "numpy=1.11.1|1.11.3"       #(versão 1.11.1 ou 1.11.3)
conda install "numpy>=1.8,<2"          #(versão maior ou igual a 1.8 mas inferior a 2)

A barra | é o operador OR: “pacote=1.1|1.3” significa 1.1 ou 1.3.
A vírgula , é o operador AND: “pacote=1.1,1.3” significa ambos 1.1 e 1.3.
O igual = é o operador fuzzy: “pacote=1.11” pode ser “1.11”, “1.11.1”, , “1.11.8”, etc.
O duplo igual == é o operador exato: “pacote==1.11” pode ser “1.11”, “1.11.0”, , “1.11.0.0”, etc.

Gerenciamento de ambientes com conda

Um novo ambiente, que chamaremos de cientifico pode ser criado, e simultanemanete instalado nele o pacote pandas. Caso um versão do Python diferente da versão default do Anaconda instalado usamos a declaração conda create --name nome_ambiente python=n, onde n é a versão desejada.

# criar um ambiente com um pacote
$ conda create --name cientifico pandas
  Proceed ([y]/n)? y
  
# ativar o ambiente
$ conda activate cientifico

# para verificar a versão do python em uso
$ python --version
  Python 3.8.8

# para criar ambiente coom outra versão do python
conda create --name cientifico python=3.9

Uma lista de todos os ambientes disponíveis pode ser vista com info --envs. As pastas listadas dependem do local onde os ambientes foram criados.

$ conda info --envs
  conda environments:
      base           /home/username/Anaconda3
      cientifico   * /home/username/Anaconda3/envs/cientifico

O ambiente ativo aparece com um asterisco *.

Um canal conda é um local na rede onde pacotes estão armazenados. Por default Conda busca em uma lista de canais e baixa arquivos de Repo Anaconda, onde alguns pacotes podem ser prorietários. Outros repositórios podem ser apontados com o conda, por exemplo o Conda Forge, uma organização parte do GitHub que contém um grande número de pacotes gratuitos. O parâmetro --override-channels é usado para que os canais default (gravados em .condarc) sejam ignorados.

# para apontar para o conda-forge
$ conda install scipy --channel conda-forge

# múltilpos repositórios podem ser apontados
$ conda install scipy --channel conda-forge --channel bioconda
# argumentos que aparecem na frente são pesquisados primeiro

# para pesquisar em repositório local, ignorando os defaults
$ conda search scipy --channel file:/caminho/local-channel --override-channels

Para instalar um pacote presente no conda-forge também é possível fazer:

$ conda config --add channels conda-forge
$ conda config --set channel_priority strict
$ conda install "nome-do-pacote"

Muito mais é possível com o Conda: consulte as instruções em Conda Docs.

Argumentos positionais
comando descrição
clean Remove pacotes e caches não utilizados.
compare Compara pacotes entre ambientes conda.
config Modifica os valores de configuração em .condarc. (feito após a configuração do comando git).
Grava arquivo .condarc do usuário
create Cria um ambiente conda a partir de uma lista de pacotes especificados.
help Exibe uma lista de comandos conda disponíveis e suas ajudas.
info Exibe informações sobre a instalação atual do conda.
init Inicializa o conda para interação do shell. [Experimental]
install Instala uma lista de pacotes em um ambiente conda especificado.
list Lista pacotes vinculados em um ambiente conda.
package Utilitário de pacote conda de baixo nível. (EXPERIMENTAL)
remove Remove uma lista de pacotes de um ambiente conda especificado.
uninst Alias para remove.
run Execute executável em um ambiente conda. [Experimental]
search Pesquisa pacotes e exibe informações associadas.
update Atualiza pacotes conda para a versão compatível mais recente.
upgrade Alias para update.
Argumentos opcionais
comando descrição
-h, –help Mostra ajuda,
-V, –version Mostra versão.

Bibliografia

Uma discussão sobre a instalação de ambientes virtuais e uso do pip para sistemas onde mais de uma versão do python está instalada pode ser lida em: Python, pip e venv.

Gráficos com Bokeh

O que é Bokeh


Bokeh é uma biblioteca de visualização de dados interativa em Python que existe desde 2013. Ela pode ser usada para a plotagem de gráficos em diversos níveis de sofisticação, representando conjuntos simples ou complexos de dados. A biblioteca pode ser usada por usuários com pouca experiência em programação ou programadores experientes com acesso aos seus comandos mais intrincados. Os gráficos do Bokeh podem ser interativos e embutidos em páginas da web.

Algumas definições básicas na terminologia de Bokeh são necessárias:

Application um aplicativo Bokeh é um documento renderizado e executado no navegador.
Glyphs glifos são os blocos de construção do Bokeh como linhas, círculos, retângulos e outras formas,
Server o servidor Bokeh é usado para compartilhar e publicar gráficos e aplicativos interativos para um público de sua escolha
Widgets os widgets do Bokeh são controles tais como menus suspensos, controles deslizantes e outras ferramentas de interface gráfica com o usuário que permitem interatividade

Instalação

Para instalar o Bokeh, se você tem Anaconda ou Miniconda, basta usar o comando: conda install bokeh.
Usando pip a biblioteca pode ser instalada com: pip install bokeh.

Comandos básicos

Dois tipos de saídas podem ser obtidas: o gráfico enviado para um arquivo output_file('arquivo.html') ou embutidos no Jupyter Notebook, output_notebook(). Bokeh possui uma interface similar à do matplotlib, que é denominada bokeh.plotting. A classe principal dessa interface é Figure que contém os métodos para a inclusão de glyphs em um gráfico.

» # importar as classes necessárias
» from bokeh.io import output_notebook, show
» from bokeh.plotting import figure
» output_notebook()
Figura 1
» # dados a plotar » x = [0,1, 0,3] » y = [0,10,90,10] » # instanciar um objeto figure » fig = figure(plot_width=450, plot_height=300) » # desenhar uma linha ligando os pontos dados » fig.line(x,y) » # exibir a figura 1 » show(fig)

A variável fig contém um objeto da classe com largura e altura especificadas, e instrução relativas às ferramentas a serem apresentadas, do lado direito no caso. O comando fig.line(x,y) usa o glyph line (linha) para ligar os pontos dados nas duas listas.

Glyphs

Glyphs são todos os elementos gráficos como linhas, círculos e cruzes marcadores de pontos, etc. Diferentes glyphs podem ter parâmetros ajustáveis diferentes. No exemplo aplicamos uma cor de fundo à figura, largura e altura. fig.circle() recebe os parâmetros posição (x,y), tamanho, que no caso é variável, cada círculo com raio size=y, largura de linha (as circunferências) line_width=5, e cor color=['red', 'blue','green','yellow']. Cada um dos discos tem uma cor diferente.

» # define dados    
» x = [1,2,3,4]
» y = [10,40,90,160]
Figura 2
» # instancia figura com cor de fundo e dimensões dadas » fig = figure(background_fill_color='#aabbff', » plot_width=450, plot_height=300) » fig.circle(x,y, size=y, line_width=5, color=['red', 'blue','green','yellow'], alpha=.5) » # exibir figura 2 » show(fig)

Os seguintes glyphs estão disponíveis:

asterisk() cross() diamond() diamond_cross()
circle() circle_x() circle_cross() triangle()
inverted_triangle() square() square_x() square_cross() x()

Alguns exemplos de uso de glyphs line, circle, cross, asterisk, x estão abaixo. As ordenadas y foram calculadas para formarem uma sequência de parábolas empilhadas, exceto pela reta horizontal amarela larga de fundo.

» # define valores da abscissa. Ordenadas serão calculadas    
» x = np.arange(10)

» plot = figure(plot_width=650, plot_height=300)
» plot.line(x, 100, color='yellow', line_width=140, alpha=.2,)
» plot.circle(x, x**2, size = 20, color='red', alpha=.5, line_width=7)
» plot.cross(x, x**2+50, size = 20, color='blue', alpha=.8, line_width=7)
» plot.asterisk(x, x**2+100, size = 40, color='green', alpha=.8, line_width=7)
» plot.x(x, x**2+150, size = 40, color='black', alpha=.8, line_width=7)
# figura 3
» show(plot)
Figura 3

As propriedades de cada glyph podem ser calculadas e dependentes em qualquer fonte de dados. Na caso abaixo usamos a própria ordenada x para calcular alguns desses parâmetros. A propriedade color=['yellow','blue']*5 garante que os 10 ‘diamantes’ plotados alternem entre as cores amarelo e azul.

» x = np.arange(10)    
» plot = figure(plot_width=650, plot_height=300)
» plot.circle_cross(x, x, size = 5+x, color='#ffaaff', alpha=1, line_width=7+x)
» plot.circle_dot(x, x, size = 30-2*x, color='#66aaff', alpha=.5, line_width=2)
» plot.inverted_triangle(x, x+5, size = 30-2*x, color='red', alpha=.9, line_width=2)
» plot.diamond(x, x+5, size = 30-2*x, color=['yellow','blue']*5, alpha=.8, line_width=2)
» show(plot)
» # figura 4 é plotada

» # outro plot com tamnho e cor variáveis
» x = np.arange(10)
» plot = figure(plot_width=600, plot_height=300)

» for k in range(100):
»     plot.circle(k, (k-50)**2, size = k*2, color=(255*k/100, 200, 255),
»                 fill_color=(2.5*k, 100, 255-2.5*k), alpha=.4, line_width=2)
» show(plot)
» # figura 5 é plotada

Gráficos de Barras (Bar Plots )

Para gráficos de barras a sintaxe é um pouco diferente. As coordenadas x são o ponto central da barra vertical, top é a altura. A largura width= 1 significa nenhum espaçamento entre barras. As cores podem ser uma só ou uma lista, de mesmo tamanho que o número de barras. Para as barras horizontais o comprimento das barras é dado por right e a largura da barra é height.

» x = [8,9,10]
» y = [1,4,2]

» # barras verticais
» plot = figure(plot_width=600, plot_height=300)
» # plot.vbar para traçar barras verticais
» plot.vbar(x,top = y, color = ['blue','red','green'], width= .8, alpha=.5)
» show(plot)     # exibe gráfico 6

» # barras horizontais
» plot = figure(plot_width=600, plot_height=300)
» plot.hbar(x, right = y, color = ['#77aaff','#aa77ff','#ff77aa'], height= .9, alpha=.5)
» show(plot)     # exibe gráfico 7


O desenho da regiões ou patches é feito com plot.patches. As regiões são descritas por meios das coordenadas de suas arestas, dois pares de listas para cada figura. As propriedades fill_color, line_color, line_width, alpha receberam listas de 3 elementos, um para cada figura. Se um valor único for passado ele será válido para todas as figuras.

» # regiões a colorir
» x_coords = [[1,1,3,], [2,2,2.5], [1.5,1.5,4,4]]
» y_coords = [[2,6,4], [3,6,7], [3,6,7,2]]

» plot = figure(plot_width=600, plot_height=300)
» plot.patches(x_coords, y_coords, fill_color = ['#77aaff','#aa77ff','#ff77aa'],
               line_color ='black', alpha=.4)
» show(plot)      # figura 8
Figura 8

Gráficos de Dispersão (Scatter Plots )

Gráficos de dispersão podem ser feitos com qualquer um dos glyphs. No exemplo abaixo a mesma plotagem é feita com círculos e com cruzes de tamanhos diversos, para efeito estético.

» from bokeh.models import Range1d
» plot = figure(plot_width=400, plot_height=250,
»               x_axis_label = 'Coordenada x (abcissa)',
»               y_axis_label = 'Ordenada y', title='Gráfico de dispersão')
» plot.x_range = Range1d(0, 5)
» plot.y_range = Range1d(0, 8)
» fcor = ['red','green','blue','brown','violet']
» x = np.array([1,2,3,4,4])
» y = np.array([5,6,2,2,4])
» plot.circle(x,y, size =x*15, color = '#aa55ff', fill_color=fcor, fill_alpha=.3)
» plot.diamond(x,y, size = x*15, color = 'red', alpha=.5,
»              fill_alpha=.4, fill_color=fcor[::-1])

» show(plot)    # figura 9
Figura 9

Observe que as coordenadas x, y poderiam ser listas. Como são arrays (do numpy) as operações para o cálculo do tamanho são permitidas. As faixas de coordenadas e ordenadas plotadas são controladas por x_range, y_range e estabelecidas por meio da função Range1d(m, n) (importada de bokeh.models). Os parâmetros color e alpha se referem ao traçado do glyph, enquanto fill_color e fill_alpha ao seu preenchimento. Relembrando, fcor[::-1] retorna a lista em ordem reversa.

Dataframes e ColumnDataSource

Usamos, até aqui, listas e arrays como fonte de nossos dados e serem plotados. Também podemos usar dataframes como fontes e o processo não é muito diferente. Se um dataframe tem uma coluna x e outra y plotamos o gráfico x × y simplesmente passando as series como parâmetros para x e y: plot.line(x = df['x'], y = df['y']).

Para montar um gráfico um pouco mais elaborado vamos usar os dados já descritos na seção sobre matplotlib. São dados sobre o número de nascimentos em países do mundo de 1950 até 2020, e a estimativa à partir de 2021. Importamos o arquivo .csv para o dataframe dfBrasil e selecionamos apenas as linhas relativas ao Brasil, até o ano de 2020. Esse dataframe é usado para plotar o gráfico de linhas. Outro dataframe, dfDecada, contendo apenas linhas com anos múltiplos de 10, é usado para plotar círculos. O raio do círculo é proporcional ao número de nascimentos.

» import pandas as pd
» dfNasc = pd.read_csv('./dados/number-of-births-per-year.csv')
» # selecionamos apenas linhas sobre o Brasil, até 2020
» dfBrasil = dfNasc[(dfNasc['Entity']=='Brazil') & (dfNasc['Year'] < 2021)]
» dfBrasil = dfBrasil.rename(columns={'Year':'ano', dfBrasil.columns[3]:'nasc'})
» # mantemos apenas colunas 'ano', 'nasc'
» dfBrasil = dfBrasil[['ano', 'nasc']]
» dfBrasil.head(2)
↳          ano         nasc
  4050    1950    2439820.0
  4051    1951    2467186.0

» # criamos outro df, apenas com anos multiplos de 10
» dfDecada = dfBrasil[dfBrasil['ano']%10==0]

» cor = ['salmon','gold','teal','plum','powderblue','coral','wheat','azure']
» plot = figure(plot_width=400, plot_height=250,
»               x_axis_label = 'Ano',
»               y_axis_label = 'Nascimentos (milhões)',
»               title='Número de Nascimentos no Brasil')
» plot.line(x = dfBrasil['ano'], y = dfBrasil['nasc']/1e6, color='black')
» plot.circle(x = dfDecada['ano'], y = dfDecada['nasc']/1e6,
»             size=dfDecada['nasc']/1e5, fill_color = cor,
»             fill_alpha=.5)
» show(plot)    # figura 10
Figura 10

Uma forma útil de fazer a conexão com os dados é o objeto ColumnDataSource. Ela é especialmente útil quando se usa a mesma fonte para diversas plotagens e para vários widgets. ColumnDataSource cria um dicionário onde as chaves podem ter nomes definidos pelo usuário e as valores correspondentes são os dados contidos em colunas do dataframe (ou outra fonte).

Vamos retornar aos dados relativos aos nascimentos nos países do mundo. Dessa vez vamos manter apenas dados sobre o Brasil e a Indonésia (escolhido porque é um país que tem população próxima à brasileira), apenas nos anos de 1950 até 2020. Nessa tabela os países recebem os códigos Code='BRA' e 'IDN', respectivamente.

» dfNasc = pd.read_csv('./dados/number-of-births-per-year.csv')
» # selecionamos as linhas sobre o Brasil e a Indonésia, até 2020
» dfBI = dfNasc[((dfNasc['Code']=='BRA') | (dfNasc['Code']=='IDN')) & (dfNasc['Year'] < 2021)]
» dfBI = dfBI.rename(columns={'Year':'ano', dfBI.columns[3]:'nasc'})

Desses dados criamos um dataframe apenas com dados brasileiros, outro com dados sobre a Indonésia. Para mesclar esses dataframes alteramos as colunas ‘nasc’ respectivamente para ‘BRA’ e ‘IDN’.

» dfB = dfBI[['ano','nasc']][dfBI['Code']=='BRA'].rename(columns={'nasc':'BRA'})
» dfB.head(3)
↳          ano          BRA
  4050    1950    2439820.0
  4051    1951    2467186.0
  4052    1952    2523577.0

» dfI = dfBI[['ano','nasc']][dfBI['Code']=='IDN'].rename(columns={'nasc':'IDN'})
» dfI.head(3)
↳          ano          IDN
  14700    1950    2867664.0
  14701    1951    2939269.0
  14702    1952    3078414.0
Para ler mais sobre a operação do pandas realizada, similar a um INNER JOIN do sql, consulte o artigo Pandas e SQL Comparados, nesse site.

Ambos os dataframes têm 71 linhas. Usamos pandas.merge() para juntar esses dataframes pelo campo ‘ano’, um processo similar ao INNER JOIN do sql. Depois criamos três novas colunas: (1) campo dif, com a diferença por ano entre os números brasileiros e indonésios, (2), difM, a média entre os dois e (3) raio, descrito no comentário † abaixo.

» dfBI = pd.merge(dfB, dfI, on='ano')
» dfBI.head(3)
↳       ano          BRA          IDN
  0    1950    2439820.0    2867664.0
  1    1951    2467186.0    2939269.0
  2    1952    2523577.0    3078414.0

» dfBI['dif'] = dfBI['IDN'] - dfBI['BRA']
» dfBI['difM'] = (dfBI['IDN'] + dfBI['BRA'])*.5
» dfBI['raio'] = dfBI['dif']/33000                   # veja comentário †

» # o dataframe fica assim:
» dfBI
↳          ano           BRA           IDN          dif          difM         raio
    0     1950     2439820.0     2867664.0     427844.0     2653742.0     12.964970
    1     1951     2467186.0     2939269.0     472083.0     2703227.5     14.305545
    2     1952     2523577.0     3078414.0     554837.0     2800995.5     16.813242

() A terceira coluna adicional, raio, é a diferença vezes um fator para que os discos em plot.circle() preencham o espaço entre os nascimentos nos dois países, centrados na média. Essa plotagem aqui tem apenas efeito visual e para demonstrar os parâmetros do plot.

» from bokeh.models import Range1d
» from bokeh.plotting import ColumnDataSource
    
» # cria o objeto ColumnDataSource
» data = ColumnDataSource(dfBI)
» plot = figure(width=900, height=250, x_axis_label = 'Ano', y_axis_label = 'Nascimentos e diferenças',
»               background_fill_color='#cfefff', border_fill_color='#ddeeff',
»               title='Nascimentos no Brasil e Indonésia')

» plot.x_range = Range1d(1950, 2035)
» plot.y_range = Range1d(0, 5.5E6)

» plot.line(x = 'ano', y = 'BRA', source = data, color = 'red', legend_label = "Brasil")
» plot.line(x = 'ano', y = 'IDN', source = data, color = 'green', legend_label = "Indonésia")
» plot.x(x = 'ano', y = 'dif', source = data, color = 'blue', legend_label = "diferença")
» plot.asterisk(x = 'ano', y = 'difM', source = data, color = 'black', legend_label = "média")
» plot.circle(x = 'ano', y = 'difM', source = data, fill_color = 'whitesmoke', alpha=.2, size = 'raio')

» show(plot)    # figura 11
Figura 11

Nesse gráfico introduzimos as legendas para cada plot. O campo difM foi plotado duas vezes, uma com um asterisco, outro com círculos com tamanhos determinados pelo campo raio. As faixas de plotagem, ranges, foram determinados para incluir gráfico e legendas. Cor de fundo para o gráfico e bordas são definidas com background_fill_color e border_fill_color.

Para o próximo gráfico baixamos para a subpasta dados do atual projeto o arquivo owid-covid-data.csv, publicado por Our World in Data com dados diários sobre a vacinação mundial contra o covid, entre 01/01/2020 e 26/09/2021. Deste aproveitamos apenas algumas colunas para plotar gráficos para efeito de demonstração do bokeh.

» # importamos os dados para um dataframe
» dfVacina = pd.read_csv('./dados/owid-covid-data.csv')

» # o dataframe tem 64 colunas e 119454 linhas
» dfVacina.shape      # (119454, 64)

» # podemos ver os nomes das colunas com
» dfVacina.columns    # nomes omitidos aqui

» # usamos apenas as colunas no dicionário
» colunas = {'date':'data',
»            'iso_code':'code',
»            'total_cases':'total',
»            'gdp_per_capita':'pib',
»            'human_development_index':'idh',
»            'life_expectancy':'expVida',
»            'total_deaths_per_million':'mortes',
»            'people_vaccinated_per_hundred':'vacinados'           
»           }
» # renomeamos as colunas
» dfVacina = dfVacina.rename(columns=colunas)

» # uma lista dos novos nomes:
» lst = list(colunas.values())
» # geramos novo df apenas com essas colunas
» df = dfVacina[lst]
» # eliminamos os linhas com NaN
» df = df.fillna(method='bfill')      # veja comentário ‡

» # as três primeiras linhas são
» df.head(3)
↳              dia    code   total         pib      idh   expVida    mortes   vacinados
   0    2020-02-24     AFG     5.0    1803.987    0.511     64.83     0.025         0.0
   1    2020-02-25     AFG     5.0    1803.987    0.511     64.83     0.025         0.0
   2    2020-02-26     AFG     5.0    1803.987    0.511     64.83     0.025         0.0

» # finalmente montamos um dataframe contendo apenas o último dia registrado
» dfUltimo = dfU[dfU['dia']=='2021-09-26']

() O método df.fillna(method='bfill') preenche valores nulos com o valor encontrado na mesma coluna, em linha posterior. (Leia aqui sobre tratamento de dados ausentes).

Lembramos que code identifica o país, total é o número total de casos de infecção por covid, mortes é o número total de mortes, por milhão e vacinados é o número de pessoas vacinadas, por 100 mil.

Podemos, em alguns casos, desejar incluir no gráfico um valor calculado a partir de um ou mais campos da tabela. Por ex., considerando que o campo idh varia entre 0,4 até 0,95, podemos usar esse campo, multiplicado por um fator, como informação do tamanho dos círculos plotados. Para fazer isso poderíamos incluir uma coluna extra com esse valor, como já foi feito em exemplos anteriores. Mas quando usamos o ColumnDataSource temos uma forma mais direta de fazer o mesmo. Podemos passar valores calculados no dicionário de valores que alimenta o ColumnDataSource.

» from bokeh.plotting import ColumnDataSource
» data = ColumnDataSource(data = {
»                        'idh' : dfUltimo['idh'],
»                        'expVida' : dfUltimo['expVida'],
»                        'tamanho': dfUltimo['idh']*20,
»                        'grande': dfUltimo['idh']*40,
»                        'alfa': dfUltimo['idh']*.08})
» plot = figure(width=600, height=300, x_axis_label = 'IDH',
»               y_axis_label = 'Exp. Vida', outline_line_color='black',
»               background_fill_color='#F5F1E3', title='IDH x Expectativa de Vida')

» plot.circle(x = 'idh', y = 'expVida', source = data, color='blue', alpha=.6,
»             fill_color = 'white', fill_alpha=1,  size = 'tamanho')
» plot.circle(x = 'idh', y = 'expVida', source = data, color='black', alpha= .1,
»            fill_color = 'red', fill_alpha='alfa', size = 'grande')

» show(plot)     # figura 12
Figura 12

Os campos do dataframe foram passados como valores em um dicionário cujas chaves são usadas como nome de campos nas plotagens. Os campos 'tamanho': dfUltimo['idh']*20 e 'grande': dfUltimo['idh']*40 são calculados para servir como informação para o tamanho (size ) dos círculos. O segundo círculo plotado tem apenas efeito estético, com um tamanho maior que o primeiro. O campo calculado alfa (uma fração do idh) é usado para regular a transparência dos discos vermelhos maiores.

O uso de ColumnDataSource permite que mais de um dataframe forneça dados para o gráfico. No entanto todas as series envolvidas devem ter o mesmo tamanho. Para ver isso vamos separar os dados sobre o Brasil e os EUA em duas tabelas separadas.

» # separa os dados relativos ao Brasil e os EUA
» dfBU = df[(df['code']=='BRA') | (df['code']=='USA')].copy()    # comentário §

» # para usar as datas no eixo x transformamos o campo 'dia' de string em datetime
» dfBU.loc[:,'dia'] = pd.to_datetime(df.loc[:,'dia'], format='%Y/%m/%d')    

» # com essa transformação a coluna passa a conter um datetime (timestamp). Por ex.:
» dfBU.loc[15250][0]
Timestamp('2020-02-26 00:00:00')

» # criamos dataframes para os dois países                      # comentário ‡
» dfUS = dfBU[(dfBU['code']=='USA') & (dfBU['dia'] &ge '2020-02-26')]
» dfBR = dfBU[dfBU['code']=='BRA']

(§) O uso de df2 = df1.copy() realiza uma cópia e não apenas pega um slice de df1. Esse procedimento evita mensagens de erro na linha seguinte, quando um campo do dataframe será alterado.

() No dataframe original existe um número maior de valores para os EUA. O corte na data especificada faz com que dfUS e dfBR tenham o mesmo tamanho.

Podemos agora plotar gráficos do número de mortes por COVID no Brasil e EUA, no mesma figura.

» cds = ColumnDataSource(data = {
»                        'dataBRA' : dfBR['dia'],
»                        'dataUSA' : dfUS['dia'],
»                        'mortesBRA' : dfBR['mortes'],
»                        'mortesUSA' : dfUS['mortes']
»                        })

» plot = figure(width=600, height=300,
»               x_axis_type = 'datetime', x_axis_label = 'data', y_axis_label = 'mortes',
»               background_fill_color='#fafaff', title='Mortes no Brasil e EUA')

» plot.circle(x = 'dataBRA', y = 'mortesBRA', source = cds, color='green' ,alpha=.2,
»             fill_color = 'yellow', fill_alpha=.3, size = 15, legend_label='EUA')


» plot.circle(x = 'dataBRA', y = 'mortesUSA', source = cds, color='blue' ,alpha=.2,
»             fill_color = 'red', fill_alpha=.3, size = 15, legend_label='EUA')

» plot.legend.location = 'top_left'

» show(plot)    # figura 13
Figura 13

Introduzimos nesse gráfico o uso de x_axis_type = 'datetime' para informar que o eixo x receberá dados de uma series temporal. plot.legend.location = 'top_left' informa a posição para as legendas.

Layouts

Layouts permitem a organização de gráficos em linhas e colunas múltiplas. Neles é possível vincular escalas de eixos entre gráficos diferentes.

Para explorar os layouts vamos usar o dataframe já montado df, que contém os campos dia, code, total, pib, idh, expVida, mortes, vacinados, descritos acima. Com ele construiremos 4 gráficos e os exibiremos em linhas, colunas e matrizes. A tabela inclui dados dos países ao longo de vários anos e, portanto, não há uma interpretação muito clara de seu significado. O objetivo é apenas o aprendizado da técnica.

» # transformando a coluna dia para um datetime
» df.loc[:,'dia'] = pd.to_datetime(df.loc[:,'dia'], format='%Y/%m/%d')

» #  a fonte de todos os gráficos é a mesma, nesse caso
» from bokeh.plotting import ColumnDataSource
» cds = ColumnDataSource(data = df)

» # gráfico 1
» plot1 = figure(width=300, height=200, x_axis_type = 'datetime',
»                x_axis_label = 'Data', y_axis_label = 'Mortes',
»                background_fill_color='#fafaff', title='Mortes no Mundo')

» plot1.dot(x = 'dia', y = 'mortes', source = cds, color='rosybrown' ,alpha=.5)

» # gráfico 2
» plot2 = figure(width=300, height=200,
»                x_axis_label = 'Expectativa de vida', y_axis_label = 'mortes',
»                background_fill_color='#fafffa', title='Expectativa de Vida x PIB')

» plot2.dot(x = 'expVida', y = 'pib', source = cds, color='red' ,alpha=.1)

» # gráfico 3
» plot3 = figure(width=300, height=200,
»                x_axis_type = 'datetime', x_axis_label = 'data', y_axis_label = 'mortes',
»                background_fill_color='#ffefff', title='PIB x Mortes')

» plot3.dot(x = 'pib', y = 'mortes', source = cds, color='blue' ,alpha=.05)

» # gráfico 4
» plot4 = figure(width=300, height=200, x_axis_label = 'PIB', y_axis_label = 'IDH',
»                background_fill_color='#9f9fff', title='PIB x IDH no mundo')
» plot4.dot(x = 'pib', y = 'idh', source = cds, color='yellow')

No código acima construimos quatro gráficos. Abaixo exploramos as possibilidades de layouts em linha, em coluna e em matriz.

» from bokeh.layouts import row, column
» # agrupar 2 gráficos em uma linha
» linha_layout = row(plot1,plot2)
» show(linha_layout)

» coluna_layout = column(plot3,plot4)
» show(coluna_layout)


» matriz_layout = column(row(plot1,plot2), row(plot3,plot4))
» show(matriz_layout)

Uma solução também interessante consiste em apresentar todos os gráficos no mesmo espaço, usando as classes Tabs e Panel. No código abaixo criamos 3 painéis e passamos nos argumentos os gráficos já construídos. Cada painel pode conter linhas e colunas, vistas anteriormente e passados no argumento child, além de um título que será usado nas guias ou tabs. Os painéis são inseridos em um objeto Tabs e exibidos.

» # importamos as classes necessárias
» from bokeh.models.widgets import Tabs, Panel
» # criamos 3 paineis
» tab1 = Panel(child = plot1, title = 'Mortes')
» tab2 = Panel(child = row(plot2,plot3), title = 'Exp Vida, PIBxMortes')
» tab3 = Panel(child = plot4, title = 'PIB x IDH')
» # insere os paineis no objeto Tabs
» objeto_tabs = Tabs(tabs = [tab1, tab2, tab3])
» # exibe o objeto
» show(objeto_tabs)

Ao clicar em uma guia o painés correspondente é exibido. Na figura estão mostrados a 1ª guia (figura 17) e a 3ª (figura 18).

Um layout de rede (grid layout) pode reunir gráficos em uma matriz, gerando resultado similar ao mostrado na figura 16. Para isso podemos usar o seguinte código.

» from bokeh.layouts import gridplot
» # cria uma rede ou grid
» grid_layout = gridplot([plot1, plot2], [plot3, plot4])
» show(grid_layout)
» # uma figura como a figura 16 é plotada.

Ao montar o grid_layout um espaço em branco pode ser inserido com None no lugar da variável do gráfico.

Algumas vezes é importante que dois ou mais gráficos tenham a mesma escala em um ou ambos os eixos. Para isso usamos o código como o seguinte.

» # criamos plots com a mesma escala (aqui no eixo do x)
» plot2.x_range = plot1.x_range
» # criamos um layout  (aqui em linha)
linha_layout = row(plot2, plot1)
show(linha_layout)

Anotações e Widgets

Para os próximos exemplos vamos usar o aqquivo population.csv, baixado do site Our World in Data, na página sobre população mundial.

O arquivo ./dados/population.csv foi baixado no link acima.

import pandas as pd
» # Importar dados para um dataframe
» df = pd.read_csv('./dados/population.csv')    

» # as colunas têm os nomes
» df.head(0)
↳ Entity   Code   Year   Total population (Gapminder, HYDE & UN)

» # 4 colunas e 53307 linhas
» df.shape # (53307, 4)

» # renomeamos as colunas
» colunas = {'Entity':'pais',
»            'Code':'codigo',
»            'Year':'ano',
»            'Total population (Gapminder, HYDE & UN)':'populacao'}
» df = df.rename(columns=colunas)

» # as colunas agora têm os nomes
» df.head(0)
↳ pais   codigo   ano   populacao

Já vimos como colocar títulos e legendas nas gráficos. No exemplo abaixo o título e posição são ajustados como uma propriedade de plot, diferente do parâmetro usado antes. Além disso podemos marcar regiões do gráficos com cores diferentes e incluir texto explicativo para realçar algum aspecto dos dados. Para isso usamos as classes Label e LabelSet.

Para alimentar esse gráfico vamos criar 3 ColumnDataSouces diferentes: para população e ano geramos cdsUSA para os EUA, cdsBRA para o Brasil, ambos após 1750. cdsLabel é usado para inserir anotações sobre os anos de independência e abolição da escravidão para os dois países.

» cdsUSA = ColumnDataSource(data = {
»     'ano' : df[(df['codigo']=='USA')  & (df['ano'] >= 1750)]['ano'],
»     'pop' : (df[(df['codigo']=='USA')  & (df['ano'] >= 1750)]['populacao'])/1e6,
» })
» cdsBRA = ColumnDataSource(data = {
»     'ano' : df[(df['codigo']=='BRA')  & (df['ano'] >= 1750)]['ano'],
»     'pop' : (df[(df['codigo']=='BRA')  & (df['ano'] >= 1750)]['populacao'])/1e6,
» })

» cdsLabel = ColumnDataSource(data=
»      dict(x=[1776, 1800, 1882, 1888],  y=[50, 100, 200, 260],
»           nota=['Indep. EUA (1776)', 'Abol. EUA (1857)',
»           'Indep. BR (1882)', 'Abol. BR (1888)']))

Agora estamos prontos para plotar esses dados. As únicas importações novas são das classes Label, LabelSet. Os dois gráficos de barra abaixo recebem os campos ano e pop, cada um relativo a um dos países.

» from bokeh.io import output_file, show, output_notebook
» from bokeh.plotting import figure
» from bokeh.plotting import ColumnDataSource
» from bokeh.models import Label, LabelSet

» output_notebook()

» grafico = figure(plot_width=600, plot_height=300, x_axis_label = 'ano',
                   y_axis_label = 'População (em milhões)')
» grafico.title.text = 'População do Brasil e do EUA de 1800 até o presente'
» grafico.title_location = 'above'

» grafico.vbar(x = 'ano', top = 'pop', source=cdsUSA,
               color = 'red', width= .1, legend_label = 'EUA')
» grafico.vbar(x = 'ano', top = 'pop', source=cdsBRA,
               color = 'green', width= 1, legend_label = 'Brasil')

» labels = LabelSet(x='x', y='y', text='nota', x_offset=0,
                    y_offset=0, source=cdsLabel, render_mode='canvas')

» texto = Label(x=1750, y=150, render_mode='css',
»               text='Independência e Abolição', text_color='blue',
»               border_line_color='#a0a0f0', border_line_alpha=1.0,
»               background_fill_color='linen', background_fill_alpha=1.0)

» grafico.add_layout(labels)
» grafico.add_layout(texto)
» grafico.legend.location = 'top_left'
» show(grafico)

Os objetos Label, LabelSet são criados com seus respectivos atributos e depois inseridos no grafico.

Usando mapas de cor

Para atribuir cores para uma categoria de dados, separando visualmente a informação para cada categoria, podemos atribuir uma cor a cada uma delas usando CategoricalColorMapper. Nele associamos a uma lista de fatores (factors ou dados categóricos) com uma lista de cores (em palette).

No exemplo inicializamos a variável mapaDeCor como um CategoricalColorMapper atribuindo os parâmetros factors e palette aos nomes das categorias e uma lista de cores. A associação é feita através do parâmetro transform no scatter plot. Novamente dois plots são traçados para efeito estético.

» from bokeh.io import output_notebook, show
» from bokeh.plotting import figure, CategoricalColorMapper
» from bokeh.models import ColumnDataSource, Range1d
» output_notebook()

» cor = ['salmon','gold','firebrick','plum','powderblue','teal','wheat','red']
» nome = ['Otto', 'Ana', 'Joana', 'Jorge', 'Marco', 'Agildo','Lu','Zana']
» dicio= dict(nome=nome,
»             altura=[1.70, 1.65, 1.48, 1.88, 1.58, 1.62, 1.83, 1.91],
»             peso=[97, 65, 89, 76, 67, 74,65, 94]
»            )
» mapaDeCor = CategoricalColorMapper(factors=nome, palette=cor)

» cds = ColumnDataSource(data=dicio)

» p = figure(title='Alunos: distribuição peso x altura',
»            x_range=Range1d(60, 110), y_range=Range1d(1.2, 2.2),
»            plot_width=400, plot_height=250)

» p.scatter(x='peso', y='altura', size=20, source=cds,
»           color=dict(field='nome', transform=mapaDeCor), alpha=.2)
» p.scatter(x='peso', y='altura', size=10, source=cds,
»           color=dict(field='nome', transform=mapaDeCor))
» p.xaxis[0].axis_label = 'Peso (kgs)'
» p.yaxis[0].axis_label = 'Altura (metros)'

» labels = LabelSet(x='peso', y='altura', text='nome',
                    x_offset=0, y_offset=8, source=cds)

» p.add_layout(labels)
» show(p)

Bibliografia

  • Jolly, Kevin: Hands-On Data Visualization with Bokeh, Interactive web plotting for Python using Bokeh, 2018 Packt Publishing, Mumbay.
  • Site Bokeh: Documentation, acessado em agosto de 2021.
  • Site Bokeh: First Steps, acessado em agosto de 2021.
  • Site Our World in Data, contendo grande variedade de tabelas com dados sobre vários temas, do mundo.
  • Rodés-Guirao, Lucas: COVID-19 Dataset by Our World in Data no Github. Acessado em outubro de 2021.

Python: Iteradores, Itertools e Funções Geradoras


Iteradores

Nessa seção para imprimir vários resultados eu uso quase sempre print(x, end=' ') para que uma nova linha não seja lançada após cada impressão. O objetivo é diminuir o espaço de página usada e facilitar a leitura.

Já vimos que objetos que são sequências e coleções podem ser lidos iterativamente com o uso do operador for.

» lista = ['Aa', 'Bb', 'Cc', 'Dd', 'Ee', 'Ff']
» for t in lista:
»     print(t, end=' < ')
↳ Aa < Bb < Cc < Dd < Ee < Ff <

Laços for são claros e concisos. Por trás desse resultado simples, a instrução for chama a função iter() na sequência (ou coleção) que retorna um objeto iterador. Dentro do iterador existe o método __next__() que acessa os elementos no contêiner um de cada vez. Ao final da iteração, quando se extinguem os elementos, __next__() levanta uma exceção StopIteration que é reconhecida pelo laço como o fim da iteração. O método __next__() pode ser chamado através da função interna next(), como mostra o exemplo:

» iterador = iter('OMS')
» print(next(iterador))
» print(next(iterador))
» print(next(iterador))
» print(next(iterador))
↳ O
↳ M
↳ S
↳ StopIteration:

No código acima usamos iter(sequencia) que retorna um iterável da sequência ‘OMS’. StopIteration é uma mensagem de erro ao final da iteração, com o iterador esgotado. Ela aparece aqui resumida.

Podemos construir uma classe iterável implementado nela os métodos __iter__() e __next__(). No caso abaixo a classe simplesmente retorna a sequência invertida, do último elemento para o primeiro

» class Inverter:
»     def __init__(self, data):
»         self.data = data
»         self.index = len(data)

»     def __iter__(self):
»         return self

»     def __next__(self):
»         if self.index == 0:
»             raise StopIteration
»         self.index -= 1
»         return self.data[self.index]

# instanciamos um objeto Inverter usando uma lista como argumento
» inv = Inverter(['Aa', 'Bb', 'Cc', 'Dd', 'Ee', 'Ff'])

» for t in inv:
»     print(t, end=' < ')
↳ Ff < Ee < Dd < Cc < Bb < Aa <

# seq é Inverter usando uma string como argumento
» seq = Inverter('Joazeiro')
» for t in seq:
»     print(t, end=' < ')
↳ o < r < i < e < z < a < o < J <     

Sequências podem ser transformados em iteradores com as funções:

iter(objeto, sentinel) retorna o objeto (uma sequência) como iterável,
interrompe a iteração quando o valor retornado for igual à sentinel (opcional),
reversed(objeto) retorna a sequência como iterável, em ordem inversa.

que, como vimos, são úteis quando usadas com loops for, while. Por exemplo, com uma lista (ou uma tupla):

» x = ["apple", "banana", "cherry"]
» print(next(x))
↳ TypeError: 'list' object is not an iterator

» # transformado a lista em um iterador
» x = iter(["apple", "banana", "cherry"])
» print(next(x), next(x), next(x))
↳ apple banana cherry


Um objeto zip() é um iterador que junta duas sequências e retorna tuplas com elementos das sequências em seus argumentos.

» z = zip(['a', 'b', 'c', 'd'], [1, 2, 3, 4])
» print(next(z))
» print(next(z))
↳ ('a', 1)
↳ ('b', 2)

Da mesma forma map retorna um iterável.

» m = map(lambda x, y: x**y, [8, 2, 9], [5, 3, 7])
» print(next(m))
» print(next(m))
» print(next(m))
↳ 32768
↳ 8
↳ 4782969

# outro exemplo
» list(map(len, ['Abacate', 'Uva', 'Jacoticaba']))
↳ [7, 3, 10] 

Observe que um objeto pode ser um iterável (como uma lista) mas não ser um iterador. No entanto ele pode ser transformado um um iterador.

» numeros = [1, 2, 3, 4, 5]
» next(numeros)
↳ TypeError: 'list' object is not an iterator

» i = iter(numeros)
» next(i)
↳ 1
» next(i)
↳ 2
» # o estado do iterador é armazenado entre iterações
» for x in i:
»     print(x, end=' ')
↳ 3 4 5

Muitas das funções do Python retornam iteradores ao invés de listas ou tuplas. Por exemplo enumerate, reversede open(file)retornam iteradores.

» alunos = ['Pedro', 'Maria', 'Marco']
» nAluno = enumerate(alunos)        # enumerate
» next(nAluno)
↳ (0, 'Pedro')

» for t in nAluno:
»     print(t)
↳ (1, 'Maria')
↳ (2, 'Marco')

» invertido = reversed(alunos)       # reversed
» next(invertido)
↳ 'Marco'

» f = open('./dados/linhas.txt')     # arquivo
» next(f)
↳ 'Esta é a linha 1\n'

Por outro lado muitas funções internas (e das bibliotecas) aceitam iteráveis (e iteradores) como parâmetros. Abaixo exemplos do uso de zip, dict recebendo iteradores.

» numeros = [10, 20, 30]
» quadrados = (n**2 for n in numeros)
» quadrados    # é um objeto generator (que é um iterador)
↳ <generator object <genexpr> at 0x7f67280c1120>

» z = zip(numeros, quadrados)        # zip recebe um iterador como argumento
» print(next(z), next(z), next(z))
↳ (10, 100) (20, 400) (30, 900)

» # Um dicionário pode receber um iterável como argumento
» alunos = ['Pedro', 'Maria', 'Marco']
» nAluno = enumerate(alunos)         # um iterador
» d = dict(nAluno)
» d
↳ {0: 'Pedro', 1: 'Maria', 2: 'Marco'}

Módulo itertools

Outros módulos foram tratados em A Biblioteca Padrão dessas notas.

Um módulo da biblioteca padrão interessante para manipulação de iteradores é o itertools, que contém diversos métodos para manipulação de iteráveis e funções que retornam iteráveis. Elas são voltadas para a velocidade de execução e uso otimizado de memória.

Existem dois tipos de interadores: iteradores infinitos continuam a rodar indefinidamente se nenhuma condição de parada for imposta, enquanto os iteradores finitos são criados com um número determinado de ciclos determinados. O módulo Itertools possui funções de cada tipo:

iteradores infinitos count, repeat, cycle,
iteradores finitos chain, compress, tee, dropwhile, takewhile.

Iteradores finitos

Função chain(): A função chain reune listas transformando-as em um único iterável.

» import itertools as it
» abc = ['Aa', 'Bb', 'Cc']
» vxz = ['Vv', 'Xx', 'Zz']
» for txt in it.chain(abc, vxz):
»     print(txt, end=' ')
↳ Aa Bb Cc Vv Xx Zz 

» # o objeto retornado por chain é um iterável: 
» letras = it.chain(abc, vxz)
» for i in range(3):
»     print(next(letras), end=' ')
» print(next(letras), next(letras), next(letras))
↳ Aa Bb Cc Vv Xx Zz

Os argumentos de chain(iteravel,...) podem ser de qualquer tipo, desde que iteráveis. Por exemplo, podemos juntar uma lista, um dicionário e um it.count():

» lista = ['Aa', 'Bb', 'Cc']
» dicio = {'G': 'g', 'H': 'h'}
» conta = it.count()
» for k in it.chain(lista, dicio.items(), conta):
»     print(k, end=' ')
↳ Aa Bb Cc ('G', 'g') ('H', 'h') 0 1 2 3 4 5 6 7 8 9 10 ...

O 4º e 5º loop retornam as tuplas do dicionário. Em seguida os inteiros são retornados. (A exibição foi interrompida manualmente!)

Função compressed(iteravel, seletor): compressed recebe um iterável e um seletor (uma lista de booleanos) e retorna um iterável filtrado pelo seletor, contendo apenas elementos onde o seletor é True.

» aluno = ['Paulo', 'Maria', 'Ricardo']
» aprovado = [False, True, True]
» for n in it.compress(aluno, aprovado):
»     print(n, end=' ')
↳ Maria Ricardo 

Os argumentos de compressed podem ser iteráveis com infinitos elementos. Por exemplo, abaixo construímos o iterador com elementos True ou False dependendo de ser o número par ou impar.

» par = (x%2==0 for x in it.count())
» for pares in it.compress(it.count(), par):
»     print(pares, end=' ')
↳ 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 ...

Função tee(iteravel): recebe um iterável e retorna dois objetos clonados do original. O iterável original fica esgotado depois da operação. Também é possível passar um parâmetro para gerar n clones, em itertools.tee(iteravel, n).

» elementos = (x**x for x in range(6) if x**x%2==0)
» lista1, lista2 = it.tee(elementos)
» print(lista1)                      # as listas são objetos itertools._tee
↳ <itertools._tee object at 0x7fc032da5ac0>

» print(list(lista1), list(lista2))
↳ ['1', '2', '3'] ['1', '2', '3']
» print(list(elementos))             # a lista original fica esgotada
↳ []

# um parâmetro pode ser passado para gerar n clones
» vitaminas = ['A', 'B12']
» vitas = it.tee(vitaminas, 4)
» for i in vitas:
»     print(list(i), end=' ')
↳ ['A', 'B12'] ['A', 'B12'] ['A', 'B12'] ['A', 'B12'] 

Função dropwhile(função, iteravel): recebe um iterável e uma função seletora (que avalia cada elemento do iterável como um booleano) e retorna os elementos do iterável à partir do primeiro retorno False da função. Em outras palavras ela descarta os elementos avaliados como True no início da lista (ou iterável). Podemos obter a lista à partir do último valor avaliado como False com reversed(lista).

» def par(x):                       # retorna True se x é par
»     return x % 2 == 0
» numeros = [0, 2, 4, 8, 16, 17, 32, 64, 67, 128]

» # o primeiro False ocorre em 17
» for t in it.dropwhile(par, numeros):
»     print(t, end=' ')
↳ 17 32 64 67 128

» # o mesmo pode ser conseguido com uma função lambda
» print(list(it.dropwhile(lambda t: t%2==0 , numeros)))
↳ [17, 32, 64, 67, 128]

» # lista anterior ao último valor avaliado como False (em ordem reversa)
» print(list(it.dropwhile(lambda t: t%2==0 , reversed(numeros))))
↳ [67, 64, 32, 17, 16, 8, 4, 2, 0]

Função takewhile(função, iteravel): retorna todos os termos iniciais de um iterável até que um de seus elementos avalie como False pela função. Os demais elementos, mesmo que avaliando como True, são descartados.

» anos = [10,30,50, 70, 90, 110, 1, 2, 3]
» take = it.takewhile(lambda x: x < 100, anos)
» for a in take:
»     print(a, end= ' ')
↳ 10 30 50 70 90 

Função itertools.zip_longest(iter1, iter2): diferente da função interna zip(), zip_longest(iter1, iter2) retorna um iterador com o comprimento da maior entre iter1, iter2, substituindo valores ausentes por None.

» # usando zip (built-in)    
» x = [0, 1, 2, 3, 4, 5]
» y = ['A', 'B', 'C']
» list(zip(x, y))
↳ [(0, 'A'), (1, 'B'), (2, 'C')]

» # usando itertools.zip_longest
» list(it.zip_longest(x, y))
↳ [(0, 'A'), (1, 'B'), (2, 'C'), (3, None), (4, None), (5, None)]

Iteradores infinitos

Função count(): a funçãocount() produz uma sequência de comprimento indefinido de inteiros, apropriada para contagem.

» import itertools as it
» t = 0
» for x in it.count():
»     if x > 10: break
»     print(x, end=' ')
↳ 0 1 2 3 4 5 6 7 8 9 10    

Se uma condição de parada do loop não for inserida ele gera uma quantidade indefinida de números. A função pode receber os parâmetroscount(inicio, passo), para indicar o início da contagem é o incremento.

» for i in it.count(10, 2):
»     if i > 24:
»         break
»     else:
»         print(i, end=' ')
↳ 10 12 14 16 18 20 22 24

Um iterador não retorna elementos indexados, o que significa que não podemos tomar apenas uma fatia (ou slice). No entanto podemos usar a função itertools.isslice().

Função isslice(iterador, n): Retorna os primeiros n elementos do iterador.

» quadrados = (n**2 for n in it.count())
» list(it.islice(quadrados, 10))
↳ [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

Outra abordagem possível é usar itertools.takewhile (ou itertools.dropwhile) para fazer filtragens:

» algunsQuadrados = (n**2 for n in it.count(25, 5))
» list(it.takewhile(lambda x: x<2000, algunsQuadrados))
↳ [625, 900, 1225, 1600]

Função repeat(objeto, n): retorna o objeto repetido n vezes, em um iterável. Se n for omitido (o que é o mesmo que passar n=None) o ciclo se repete indefinidamente.
Função cycle(objeto): é o mesmo que repeat() retornando o objeto um número indefinido de vezes.

»  função repeat
» for i in it.repeat([1,2,3], times = 3):
»     print(i, end = ' ')
↳ [1, 2, 3] [1, 2, 3] [1, 2, 3]

» função cycle
» l = ['Far', 'West', 'Wing']
» i = 0
» for t in it.cycle(l):
»     if i < 5:
»         print(t, end=' ')
»     else:
»         break
»     i += 1
↳ Far West Wing Far West 

Funções Geradoras

Como sabemos, uma função do Python é executada de forma sequencial e sem interrupções, até que uma instrução return seja encontrada. Nada depois disso é executado. (Lembrando: uma função sem return retorna None.)

» def exibeLinhas():
»     print('linha 1')
»     print('linha 2')
»     print('linha 3')

» exibeLinhas()
↳  linha 1
↳  linha 2
↳  linha 3

Instrução yield: É possível pausar uma função no meio de sua execução, retomando depois no ponto de pausa. Com isso é possível criar funções que agem como um iterador. Uma função contendo pelo menos uma instrução yield, é chamada uma função geradora. Geradores são uma generalização de iteradores que produzem seus dados apenas sob demanda, sendo por isso chamados de lazy (prequiçosos).

» def funcaoGeradora():
»     print('Linha 1 é retornada')
»     yield 1
»     print('Linha 2 é retornada')
»     yield 2
»     print('Linha 3 é retornada')
»     yield 3

» gera = funcaoGeradora()
» print(gera)
↳ <generator object funcaoGeradora at 0x7f69643c8dd0>
» print(next(gera))
↳ Linha 1 é retornada
↳ 1
» print(next(gera))
↳ Linha 2 é retornada
↳ 2
» print(next(gera))
↳ Linha 3 é retornada
↳ 3
» print(next(gera)) # um erro é lançado "StopIteration"

Esse tipo de função retorna um objeto gerador que é, em termos de comportamento, um iterador. A iteração sobre esse objeto é feita com a função interna next(). Quando o iterador está esgotado o uso de next() retorna uma exceção.

yield pode ser usado para retornar qualquer valor no iterador, inclusive None, se um valor for omitido. yield substitui a instrução return que, se usada, interrompe o ciclo do iterador. Uma função geradora pode ser transformada, com todos os seus valores, em uma lista, com list(geradora).

» def geradora():
»     for i in range(10):
»         yield i

» g = geradora()
» while True:
»     try:
»         print(next(g), end=' ')
»     except:
»         break
↳ 0 1 2 3 4 5 6 7 8 9

» g = geradora() # para repopular o iterador
» for i in g:
»     print(i, end=' ')
↳ 0 1 2 3 4 5 6 7 8 9

» g = geradora()
» lista = list(g)
» print(lista)
↳ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

A função geradora só pode ser percorrida uma vez, ficando esgotada no final. Como mostrado no último bloco de código, da mesma forma que uma função geradora pode ser percorrida com next(), o iterador por ela gerado pode ser percorrido em um loop for.

Relembrando: Uma classe pode retornar um objeto iterável por meio dos métodos especiais __iter__() e __next__(). O código next(g) é equivalente à chamar o método interno do objeto iterável, g.__next__().

» # class iteradora
» class Quadrados:
»     def __init__(self, quantos):
»         self.quantos = quantos
»         self.atual = 0​

»     def __iter__(self):
»         return self

»     def __next__(self):
»         quad = self.atual ** 2
»         self.atual += 1
»         if self.atual > self.quantos:
»             raise StopIteration
»         return quad

» q = Quadrados(11)
» for s in q:
»     print(s, end=', ')
↳ 0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 

As funções geradoras podem conseguir o mesmo resultado de forma mais compacta. As operações de iteração, de obter o elemento seguinte e a parada por exceção são automaticamente fornecidas. Elas retornam um objeto gerador, que é um iterável.

» def quadrados(quantos):
»     for n in range(quantos):
»         yield n**2

» q = quadrados(11)
» for s in q:
»     print(s, end=', ')        
↳ 0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100,

Uma função geradora pode guardar o estado em que terminou a última chamada até ser chamada novamente. Por exemplo, podemos contruir uma geradora para exibir os elementos da sequência de Fibonacci, indefinidamente.

» # Ex.: geradora da sequência Fibonacci
» def fibonacci():
»     a, b = 1, 1
»     while True:
»         yield a
»         a, b = b, a + b
        
» # inicializamos um objeto da sequência
» f = fibonacci()
» # as 10 primeiras iterações resultam em
» for i in range(10):
»     print(next(f), end=' ')
↳ 1 1 2 3 5 8 13 21 34 55 

» # as 10 iterações seguintes
» for i in range(10):
»     print(next(f), end=' ')
↳ 89 144 233 377 610 987 1597 2584 4181 6765 

Podemos também calcular quantos números primos se desejar, desde que o computador tenha capacidade de processamento e memória para isso.

» def numerosPrimos():
»     num = 2
»     yield num
»     while True:
»         num += 1
»         primo = True
»         for i in range(2, int(num/2)):
»             if(num % i) == 0: # achou um divisor
»                 primo = False # logo não é primo
»                 break
»         if primo:
»             yield num

» for p in numerosPrimos():
»     print(p, end=' ')
»     if p > 100:
»         break
↳ 2 3 4 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 101                 

Os dois últimos exemplos ilustram o fato de que a função geradora armazena seu estado entre as chamadas e os retornos com yield. Como já vimos o loop for cuida das chamadas a next() e o encerramento do laço, quando existir.

Método send(): Outra característica importante dos geradores é a possibilidade de interagir com o código do iterador. Um valor pode ser passado para yield através do método send(). Em outras palavras, além de retornar um valor calculado dentro da função geradora yield recebe o valor passado por send(), que funciona como next() mas passa um valor para yield.

» # definimos uma função geradora com send
» def usandoSend():
»     while True:
»         recebido = yield
»         print('Recebido =', recebido)

» f = usandoSend()    # inicializamos um objeto gerador
» next(f)             # a primeira interação deve ser feita para a entrada no iterador

» # passando integer 0 para yield
» f.send(0)
↳ Recebido = 0
» # passando string para yield
» f.send('palavra')
↳ Recebido = palavra
» # uma iteração send envia None para yield
» next(f)
↳ Recebido = None

Com essa funcionalidade o código que chama o iterador pode modificar o comportamento da função e, portanto, dos valores retornados. No exemplo abaixo o gerador retorna inteiros, de 1 em 1, exceto se um incremento extra for enviado por meio de send().

» # definimos uma função geradora com send
» def usandoSend():
»     n = 0
»     while True:
»         n +=1
»         incremento = yield n
»         if recebido:
»             n += incremento

» # inicializamos o gerador
» f = usandoSend()    # inicializamos um objeto gerador
» # os 4 primeiros outputs são
» print(next(f), next(f), next(f), next(f))
↳ 1 2 3 4
» # incremento 4 primeiros outputs são
» p = f.send(10)
» print(p)
↳ 15
» # os 4 próximos outputs são
» print(next(f), next(f), next(f), next(f))
↳ 16 17 18 19

O exemplo seguinte usa send() e será usado na próxima seção para ilustrar o uso de throw().

» # definimos um gerador
» def gerador_letras(texto):
»     local = 0
»     while True:
»         mandou = yield texto[local]
»         if mandou:
»             local = mandou
»         else:
»             local += 1

» # inicializamos um gerador            
» let = gerador_letras('Apocalipse Zumbi')
» next(let)
↳ 'A'
» # enviamos via send
» let.send(11)
↳ 'Z'
» next(let)
↳ 'u'

Método throw(): throw() permite que se lance uma exceção no ponto onde o gerador foi interrompido, na última execução. Ele ativa a exceção e retorna o valor seguinte (como faria yield), ou uma StopIteration se o gerador estiver esgotado. A exceção deve ser tratada dentro da função geradora, caso contrária será repassada para o código que chamou a função. A função abaixo permite que se veja em que ponto está a execução do gerador, usando throw().

» # definindo gerador com tratamento para exceção
» def gerador_letras(texto):
»     local = 0
»     while True:
»         try:
»             mandou = yield texto[local]
»         except Exception:
»             print(f'Exceção atingida na posição {local}')
»         if mandou:
»             local = mandou
»         else:
»             local += 1

» let = gerador_letras('Sei que nada será como antes')
» print(next(let), next(let))
↳ S e

» # pula a posição para 4
» let.send(4)
↳ 'q'

» # levanta exceção (que, no caso, mostra posição atual no gerador)
» let.throw(Exception)
↳ Exceção atingida na posição 4
↳ 'q'

» print(next(let), next(let))
↳ u e
  • throw(exceção): permite que se envie para o iterador qualquer tipo de exceção,
  • close(): fecha o iterador e levanta a exceção GeneratorExit.
» # fechando o iterador
» f.close()
» f.send('palavra')   # uma exceção é lançada
↳ StopIteration

yield from: permite que um gerador chame outros, de forma sequencial. No exemplo abaixo o gerador_principal esgota primeiro o gerador1, depois o gerador2, em sequência.

» # geradores de geradores
» def gerador1():
»     yield 'Linha 1 do gerador 1'
»     yield 'Linha 2 do gerador 1'​

» def gerador2():
»     yield 'Linha 1 do gerador 2'
»     yield 'Linha 2 do gerador 2'

» def gerador_principal():
»     yield from gerador1()
»     yield from gerador2()

» delegando = gerador_principal()
» print(next(delegando))
» print(next(delegando))
» print(next(delegando))
» print(next(delegando))
↳ Linha 1 do gerador 1
↳ Linha 2 do gerador 1
↳ Linha 1 do gerador 2
↳ Linha 2 do gerador 2

» # o mesmo resultado seria obtido com
» delegando = gerador_principal()
» for i in delegando:
»     print(i)    # linhas de output omitidas

Observação: Alguns objetos built-in são geradores e têm internamente implementados os métodos yield, next, iter, StopIteration . São eles:
range, dict.items, zip, map e File Objects.

A conjectura de Collatz

O exemplo de função geradora a seguir ilustra um problema interessante na matemática, denominado conjectura de Collatz. Collatz se perguntou se uma operação aritmética simples repetida sobre números inteiros positivos produziria sempre uma sequência terminada em 1. A sequência geralmente considerada é a seguinte: iniciando com um inteiro n cada termo da sequência é gerado da seguinte forma. Se o número é par o seguinte é n/2; se o número é impar o seguinte é 3n + 1. Para todos os inteiros testados a conjectura é verificada. Por exemplo, começando com n=7 temos: {7, 22, 11, 34, 17, 52, 26, 13, 40, 20, 10, 5, 16, 8, 4, 2, 1}. No entanto até hoje não foi possível provar o caso geral e o problema permanece em aberto.

Para gerar a sequência vamos construir um iterador que recebe um parâmetro n, o número inicial da sequência. Observe que a sequência seria infinita sem um corte pois 1 ⟶ 4 ⟶ 2 ⟶ 1, um loop infinito. Portanto a interrompemos manualmente quando ela atinge 1. O iterador é usado pela função collatz(n) que retorna a sequência inteira e quantas iterações foram feitas (protanto quantos elementos existem na sequência).

» # sequência de Collatz
» def iterador(numero):
»     while True:
»         yield numero
»         numero = int(numero/2 if numero%2==0 else 3 * numero + 1) 

» def collatz(n):
»     itt = iterador(n)
»     quantos = 0

»     while True:
»         quantos += 1
»         val = next(itt)
»         print(val, end=' ')
»         if val == 1:
»             print(f'\nFinal alcançado em {quantos} iterações!')
»             break

» collatz(7)
↳ 7 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1 
↳ Final alcançado em 17 iterações!
Sequência de Collatz para n = 7

Geradores e gerenciamento de memória


Geradores são uma forma prática de interagir com grande volume de dados sem esgotar a memória do computador. Eles permitem que um grande volume de dados possam ser acessados em blocos, por partes. Cada bloco pode ser retornado sob demanda, enquanto o gerador armazena seu estado entre cada chamada.

Bibliografia

Matplotlib


Quando lidamos com dados precisamos, muitas vezes, visualizar de forma gráfica esses dados. Em muitas tarefas é útil, ou até mesmo essencial, que as conclusões das análises sejam mostradas através de gráficos. Mesmo nas fases iniciais de uma análise, na preparação e limpeza de dados, a visualização é importante visualizar para se compreender padrões, tendências e anomalias, tais como pontos fora da curva. Existem no Python inúmeras bibliotecas para visualização de dados e montagem de gráficos. Matplotlib é o módulo básico para uso em conjunto com o pandas.

A biblioteca é grande, com extensas possibilidades e tem sido usada como base para a elaboração de outros módulos gráficos, como o Seaborn. O aprendizado da biblioteca inteira pode demandar um esforço considerável mas o uso básico, suficiente para muitos projetos, não demanda tanto empenho. Além disso o pandas tem uma vinculação natural com a biblioteca, como veremos.

Instalação

Matplotlib é instalado junto com a distribuição do python via Anaconda. Se você não está usando Anaconda é possível encontrar maiores instruções de instalação no site de Matplotlib.

Um pouco de história

Matplotlib começou a ser desenvolvida em 2003 por John D. Hunter, um neurocientista que usava MATLAB e queria aperfeiçoar a visualização de dados obtidos por meio de EEGs (eletroencelografia) em sua pesquisa sobre epilepsia. Hoje uma a comunidade de desenvolvedores colaboram para manter e aperfeiçoar a biblioteca.

Como muitos usuários e desenvolvedores estavam acostumados ao ambiente do MATLAB, onde todas as funções estão disponíveis globalmente sem a necessidade de importações, o módulo pylab foi desenvolvido. Ele existe para trazer funções e classes do NumPy e matplotlib para o namespace global. Isso significa que o comando from pylab import * em uma sessão significa a importação desses módulos e é desnecessária para quem está acostumado com o estilo do python. Como já vimos a importação de muitos módulos, funções e classes pode provocar conflito entre as partes importadas e os métodos built-in do python.

De fato, o uso de ipython --pylab para quem usa o comando de linha, ou %pylab de dentro do Jupyter, simplesmente faz uma chamada interna à from pylab import *. Nesse sentido se recomenda, para quem trabalha com IPython e Jupiter Notebook, que se use a “mágica” %matplotlib.

Por todos esses motivos usaremos a abordagem usual do python:

» import matplotlib.pyplot as plt
» import numpy as np
» np.random.seed(444)
» # para exibição dos gráficos no ambiente do jupyter notebook usamos
» %matplotlib
» # para exibição incorporada dentro do notebook
» %matplotlib inline
» # ou, para exibição dentro do notebook, com controles de zoom e arraste
» %matplotlib notebook

Numpy será usado para as contruções de arrays e geração de dados aleatórios. A informação de np.random.seed() serve para que os geradores produzam os mesmos números em seções posteriores, para reproducibilidade. A mágica %matplotlib faz com que os gráficos sejam exibidos. Nesse caso uma nova janela é aberta com uma barra de menus com acesso à ampliação, arraste, gravação em vários formatos (como pdf, jpg, png), e parâmetros do gráfico. Já a inserção de %matplotlib inline faz com que os gráficos fiquem embutidos no próprio notebook e sejam gravados com ele. A janela de controle não aparece. Usando %matplotlib notebook temos o gráfico embutido com acesso à controles de zoom, arraste e gravação em arquivo.

Técnica Básica

O matplotlib pode receber como fonte de dados listas, arrays do numpy, Series e dataframes do pandas. Por exemplo, o código seguinte recebe listas gera as figuras 1 e 2 abaixo:

» %matplotlib inline
» plt.plot([0,3,0,5,0,7,0])
» # a figura 1 é gerada

» plt.plot([0,1,2,3,4,5,6],[0,3,0,5,0,7,0])
» # o mesmo que antes (figura 1 é gerada)

» x = np.arange(101)-50
» y = x**2
» plt.plot(x,y)
» # a figura 2 é gerada

Quando apenas uma lista é fornecida plot usa os índices como coordenada horizontal (abcissa) e os valores da lista como coordenadas verticais (ordenadas). Quando duas listas de mesmo tamanho são fornecidas a primeira é usada para os valores das abcissas, a segunda como ordenadas. No segundo exemplo, que gera a figura 2, foram usadas coordenadas (x, x2) (uma parábola) com x variando no intervalo [-50, 50].

Usando %matplotlib notebook o gráfico é exibido inline mas trazendo controles de ajuste da imagem. Ao se clicar no botão azul (pode ter outra cor na sua instalação) os controles desaparecem e a imagem fica estática.

» %matplotlib notebook
» x = np.arange(40)
» plt.plot(x,np.exp(x/10))    # exibe a figura 3

» # outro exemplo: seno
» # dados a plotar 
» x = np.arange(0.0, 2.0, 0.01)
» y = np.sin(2 * np.pi * x)

» fig, ax = plt.subplots()
» ax.plot(x, y)

» ax.set(xlabel='eixo x', ylabel='y = seno(x)', title='Gráfico de seno(2 pi x)')
» ax.grid()

» fig.savefig('seno.jpg')
» plt.show()                  # exibe a figura 4

No primeiro caso plotamos simplesmente o gráfico de y = exp(x/10). No segundo exemplo criamos um array no intervalo [0, 2) em passos de .01. A coordenada é y = sen(2πx). Usamos as funções exponencial e seno do numpy para lidar com a operação vetorializada (que pode ser aplicada sobre todo o array). Já veremos com maiores detalhes os métodos de matplotlib.pyplot.

Hierarquia de objetos do Matplotlib

Mesmo em exemplos simples, como os anteriores, Matplotlib usa uma hierarquia de objetos. Por hierarquia se entende que objetos dependem de outros, como em ramos de uma árvore. Repetindo o gráfico da figura 4 temos:

†: O termo axes do matplotplib não se refere a “eixos” e sim a “figuras” individuais, dentro de uma Figure.

Figure é o objeto básico ou o mais externo de um gráfico. Ele pode conter diversos Axes, que são gráficos ou plotagens individuais. Axes, por sua vêz, podem conter legendas, marcas gráficas, curvas e caixas de texto. Cada um desses elementos são objetos do python com métodos e propriedades que podem ser manipuladas individualmente.

Vamos verificar em código como esses objetos são criados e manipulados.

» # geramos os dados a imprimir
» x = np.arange(0.0, 2.0, 0.01)
» y = np.sin(2 * np.pi * x)

» # O objeto básico do matplotlib.pyplot (aliás plt) é figure
» fig = plt.figure()
» # De figure derivamos um axes (subplot) e plotamos 3 curvas
» ax = fig.add_subplot()

» ax.plot(x, y)
» ax.plot(x, x)
» ax.plot(x, x**2)

» # definimos os labels dos eixos x e y e o título do gráfico
» ax.set(xlabel='eixo x', ylabel='seno(x), x, x^2',
»        title='y = seno, reta e parábola')
» # acrescentamos um quadriculado (grid)
» ax.grid()

» # salvamos a figura no disco
» fig.savefig('seno.jpg')
» # exibimos o resultado
» plt.show()

Ao objeto ax acrescentamos 3 plots (y=sen(2πx), y=x, y=x2), os labels dos eixos, o título do gráfico e o quadriculado de fundo. Opcionalmente a figura pode ser salva. A figura só é exibida quando plt.show() é executado.

O método add_subplot admite diversos parâmetros: fig.add_subplot(m,n,r) significa criar um gráfico em m linhas, n colunas, na posição r. Um subplot significa dividir a região destinada ao gráfico em m×n partes onde se pode colocar sub-gráficos.

Por ex., vamos criar uma figura com 4 subplots, plotando uma curva diferente em cada uma delas.

» x = np.arange(0.0, 2.0, 0.01)-1
» fig = plt.figure()

» ax1 = fig.add_subplot(2,2,1)               # 2 linhas, 2 colunas: a 1ª figura
» ax2 = fig.add_subplot(2,2,2)               # a 2ª figura
» ax3 = fig.add_subplot(2,2,3)
» ax4 = fig.add_subplot(2,2,4)

» ax1.plot(x, np.sin(10*x))
» ax2.plot(x, x)
» ax3.plot(x, x**2)
» ax4.plot(x, x**3)
» fig.savefig('figura5.jpg')

» plt.show()

A mesma figura pode ser obtida fazendo os plots diretamente para os axes:

» fig = plt.figure()
» ax1 = fig.add_subplot(2, 2, 1)
» plt.plot([-1, 0, 1, 2])
» ax2 = fig.add_subplot(2, 2, 2)
» ax3 = fig.add_subplot(2, 2, 3)
» plt.plot(np.random.randn(50).cumsum(), 'k--')
» ax4 = fig.add_subplot(2, 2, 4)
» plt.plot([1.5, 3.5, -2, 1.6])

O método plt.plot(dados) se refere ao eixo ativo que é aquele criado ou usado por último. No caso acima nenhuma figura foi plotada no 2 º retângulo.

O procedimento de criar vários subplots dentro de um mesmo gráfico pode resumido por meio do método
fig, axes = plt.subplots().
subplots() retorna uma tupla onde o 1&orm; elemento é uma Figure, o objeto básico de um plot, e o 2º são os axes que recebem as curvas.

Esses axes podem ser referenciados individualmente pela notação de array. Por ex.: em
fig, axes = plt.subplots(2, 3)
temos axes[0,0] até axes[1,2].

np.random.randn(50).cumsum()
retorna a soma cumulativa dos elementos de um array de 50 elementos “aleatórios”.

pyplot.subplots possui as opções:

nrows número de linhas
ncols número de colunas
sharex todos os subplots devem ter os mesmos “ticks” no eixo x
sharey todos os subplots devem ter os mesmos “ticks” no eixo y
subplot_kw dicionário de chaves para criar cada subplot
**fig_kw chaves adicionais, como plt.subplots(2,2,figsize=(8,6))

Formatação dos gráficos

Tamanho

O tamanho de uma figura, por default dada em polegadas, é definido pelo parâmetro figsize em plt.figure(figsize=(largura, altura)).

» x = np.arange(.1, 10, 0.01)
» largura = 5; altura = 2
» plt.figure(figsize=(largura, altura))
» plt.plot(x, np.log(x))
» plt.show()                       # gerado o gráfico na figura 8

O mesmo gráfico é gerado usando esse parâmetro no construtor da figure, mas desenhado com linhas pontilhadas, devido ao parâmetro ‘k–‘ em ax.plot(x, np.log(x), 'k--').

» x = np.arange(.1, 10, 0.01)
» fig = plt.figure(figsize=(5, 2))
» ax = fig.add_subplot()
» ax.plot(x, np.log(x), 'k--')
» plt.show()                       # gerado o gráfico na figura 9

Espaçamento entre subplots, cores e marcadores

O espaçamento entre figuras de um gráfico com subplots, que por default é um espaço não nulo, pode ser ajustado por meio do método Figure.subplots_adjust(). Por conveniência o mesmo método pode ser acessado diretamente pela função:
subplots_adjust((left=None, bottom=None, right=None, top=None, wspace=None, hspace=None).
wspace e hspace indica quanto espaço percentual em relação à largura e altura da figura, respectivamente.
Por exemplo, para juntar os subplots fazemos ambos igual a zero.

» x = np.arange(.1, 10, 0.01)
» fig, axes = plt.subplots(2, 2)
» axes[0, 0].hist(np.random.randn(1000), bins=100, alpha=.5)
» axes[0, 0].hist(np.random.randn(500), bins=50, color='r', alpha=.5)
» axes[0, 1].hist(np.random.randn(500), bins=50, color='gold', alpha=1)
» axes[1, 0].hist(np.random.randn(500), bins=50, color='#ff0000')
» axes[1, 1].plot(x, 3*np.log(x), color='#55aaff')
» axes[1, 1].plot(x, x, color='#000000')
» plt.subplots_adjust(wspace=0, hspace=0)


Para usar os mesmos eixos em todos os 4 gráficos usamos os parâmetros sharex, sharey, em
fig, axes = plt.subplots(2, 2), sharex=True, sharey=True).

Em hist(np.random.randn(1000), bins=100) traçamos o histograma de dados aletórios (100 números) separados em 100 bins. No 1º axes traçamos 2 histogramas, o 1º com a cor azul default, o 2º com color=’r’, um atalho para ‘red’ ou vermelho, com transparência de 50%, alpha=.5. Uma lista de cores nomeadas, como color=’gold’ pode ser encontrada no site do matplotlib. Também podemos usar o código de cores html que consiste em 3 números hexadecimais de 0 até 255 (ou 00 até ff em hexadecimal), no sistema rgb (vermelho, verde, azul). Diversos editores de imagens ou de código disponilizam um seletor de cores que retorna a cor nesse sistema. O site HTML-COLOR.CODES também tem um seletor online.


O método axes.plot(), além de aceitar arrays para abcissas e coordenadas, pode receber também o string especificador de cor e tipo de linha. Para imprimir em verde (‘green’) com linha tracejada usamos
ax.plot(x, y, 'g--'),
que é uma forma resumida de passar parâmetros. Isso é o mesmo que:
ax.plot(x, y, linestyle='--', color='g').
Considerando que x e y são arrays de mesmo tamanho, alguns exemplos desses parâmetros são:

» plot(x, y)            # plot x, y com linha e estilo default
» plot(x, y, 'bo')      # plot x, y com marcadores azuis circulares
» plot(x, y, 'rv')      # plot x, y com marcadores vermelhos, triângulo para baixo
» plot(y)               # plot y usando seus índices como coordenadas-x
» plot(y, 'r+')         # idem, usando cruzes vermelhas

No Jupyter Notebook use plt.plot? para ver uma lista completa dos parâmetros desse método.

Ao desenhar um gráfico pode ser interessante marcar os pontos sobre as curvas contínuas. Isso é feito com markers ou marcadores.

» from numpy.random import randn
» plt.plot(randn(30).cumsum(), color='green', linestyle='dashed', marker='o')

Uma forma abreviada para o mesmo comando é: plt.plot(randn(30).cumsum(), 'go--'), onde os parâmetros são passados em uma única string, com g para green (verde), o para o marcador circular e -- para o estilo de linha tracejado.

Por default os pontos de um plot são ligados por linhas. Para outro estilo usamos drawstyle:

» data = np.random.randn(20).cumsum()
» plt.plot(data, 'b--', label='default', marker='v')                      # linha azul
» plt.plot(data, 'r-', drawstyle='steps-post', label='passos')            # linha vermelha
» plt.legend(loc='best')


A linha azul é tracejada (‘b–‘), no estilo default e com marcadores ‘v’ (triângulos). O parâmetro label cria legendas, nesse caso indicando o texto ‘defaul’. A linha vermelha tem estilo drawstyle=’steps-post’ (em passos) e é marcada na legenda como ‘passos’. plt.legend(loc='best') informa que o melhor local para colocar essa legenda seja encontrado automaticamente. Outros valores seriam: loc='right', 'center', 'upper right', etc.

Marcas, etiquetas e legendas (ticks, labels, legends)

» dados = np.random.randn(1000)
» cumulativo = dados.cumsum()

» fig = plt.figure()
» ax = fig.add_subplot(1,1,1)
» ax.plot(10*dados + 10)
» ax.plot(cumulativo)


As duas plotagens são sobrepostas no único axes criado. O operação 10*dados+10 serve apenas para efeito estético da apresentação dos dados aleatórios.

Vamos usar os mesmos dados para verificar as propriedades de ajuste do título global do gráfico, labels nos eixos x e y, label do gráfico e ticks para melhorar a apresentação do gráfico anterior.

» fig = plt.figure()
» ax = fig.add_subplot(1,1,1)
» ax.set_title('Alterando eixos com matplotlib')
» ticks = ax.set_xticks([0, 250, 500, 750, 1000])
» ticks = ax.set_yticks([-20, 0, 20, 40, 60, 80])

» labels = ax.set_xticklabels(['seg' ,'ter', 'qua', 'qui', 'sex'],
»                             rotation=45, fontsize='large')
» labels = ax.set_yticklabels(['-A' ,'O', 'A', 'B', 'C','D'],
»                             fontsize='large')

» ax.set_xlabel('Ao longo dos dias...')
» ax.set_ylabel('Observado')

» ax.plot(10*dados, color='#55aaff', alpha=.5, label='dados')
» ax.plot(cumulativo, color='red', label='cumulativo')
» ax.legend(loc='best')

Anotações e desenhos nos subplots.

Diversos tipos de anotações, setas e desenhos podem ser incluídos nos gráficos. Para traçar os gráficos seguintes vamos usar o arquivo .csv do Our World in Data, baixados para a pasta ./dados.

Primeiro importamos o arquivo baixado .csv para um dataframe. Esse arquivo contém dados dos países do mundo, nos anos de 1950 até 2099, contendo número de nascimentos verificados até 2020 e valores interpolados para os anos seguintes. O dataframe original tem o seguinte formato:

Em seguida selecionamos apenas os dados sobre o Brasil.

» dados = pd.read_csv('./dados/number-of-births-per-year.csv')
» dados=dados[(dados['Entity']=='Brazil')]
» dados.head(2)

» # as colunas 3 e 4 têm nomes longos, que vamos renomear
» dados.columns[3],dados.columns[4]
↳ ('Estimates, 1950 - 2020: Annually interpolated demographic indicators - Births (thousands)',
↳  'Medium fertility variant, 2020 - 2099: Annually interpolated demographic indicators - Births (thousands)')

» dados.rename(columns={'Year':'ano',
»                       dados.columns[3]:'nasc',
»                       dados.columns[4]:'inter'}, inplace=True)
» # copiamos os dados da coluna de interpolação, após 2020, para a colunas de nascimentos
» dados.loc[dados['nasc'].isna(), 'nasc'] = dados['inter']

» # vamos mantes apenas as colunas 'ano' e 'nasc'
» dados = dados[['ano', 'nasc']]
» dados.head()
↳         ano          nasc
↳ 4050    1950    2439820.0
↳ 4051    1951    2467186.0
↳ 4052    1952    2523577.0
↳ 4053    1953    2583285.0
↳ 4054    1954    2646311.0

Para usar como anotações no gráfico encontramos os anos em que  nascimentos foram máximo e mínimo, além do ano em que se inicia a interpolação, 2020.

» maior=dados[dados['nasc']==dados['nasc'].max()]
» menor=dados[dados['nasc']==dados['nasc'].min()]

» ano_maior = maior['ano'].values[0]
» nasc_maior = int(maior['nasc'].values[0])

» ano_menor = menor['ano'].values[0]
» nasc_menor = int(menor['nasc'].values[0])

» interX = 2020   # início da interpolação
» interY = int(dados[dados['ano']==2020]['nasc'].values[0])

» txt = ('Máximo de nascimentos:\t {} no ano {}.\n'
»         'Mínimo de nascimentos:\t {} no ano {}.\n'
»         'Início da interpolação:\t {} no ano {}.'
»       )

» print(txt.format(nasc_maior, ano_maior,nasc_menor, ano_menor, interY, interX))
↳ Máximo de nascimentos:	 3929646 no ano 1983.
↳ Mínimo de nascimentos:	 1504597 no ano 2099.
↳ Início da interpolação:	 2859135 no ano 2020.

Com esses dados imprimimos o gráfico (sem muita preocupação estética). Uma primeira curva é traçada em preto, incluindo os anos de 1950 até 2020. A segunda curve se inicia em 2021 até o final e é tracejada em vermelho, para indicar a interpolação. Uma terceira curva tem efeito decorativo, em azul e transparente.

» fig = plt.figure()
» ax = fig.add_subplot()
» ax.set_title('Nascimentos (em milhões), por ano', size=18)
» ax.plot(dados[dados['ano']<2021]['ano'], dados[dados['ano']<2021]['nasc'], color='black', alpha=1)
» ax.plot(dados[dados['ano']>2020]['ano'], dados[dados['ano']>2020]['nasc'], 'r--')
» ax.plot(dados['ano'], dados['nasc'], 'b', linewidth=5, alpha=.2)
» ax.grid(color='grey', alpha=.3 )
» ax.annotate('Máximo', xy=(ano_maior, nasc_maior), size=13)
» ax.annotate('Mínimo', xy=(ano_menor, nasc_menor), size=13)
» ax.annotate('Interpolado', xy=(interX, interY), size=13)
» ax.arrow(ano_maior-10, nasc_maior, 10, 0)
» ax.arrow(interX-10, interY-10, 10, 10)            # a figura 17 abaixo é plotada


Para dar um zoom na figura podemos limitar as faixas de valores no eixo x e eixo y com ax.set_xlim(a,b) e ax.set_xlim(m,n), respectivamente, em torno do ponto de interesse. No exemplo fazemos um zoom em torno do ponto de máximo, obtendo o gráfico 18 acima.

» ax.set_xlim([1980, 1990])
» ax.set_ylim([3.76e6, 3.95e6])
» fig.get_figure()                                  # a figura 18 acima é plotada

Figuras sobre o plot

Diversas formas mais comuns estão disponíveis para inserção nos plots, e são chamadas de patches no matplotlib. Algumas delas estão diretamente em matplotlib.pyplot como retângulos, círculos e polígonos. Muitas outras estão em matplotlib.patches. Para traçar figuras construimos os patches com os métodos apropriados e os acrescentamos ao subplot usando ax.add_patch().

retângulo: plt.Rectangle((x, y), largura, altura), onde (x, y) são as coordenadas do ponto inferior esquerdo,
círculo: plt.Circle((x_0, y_0), raio), onde (x_0, y_0) são as coordenadas do centro,
polígono: plt.Polygon([[x_0, y_0], [x_1>, y_1],…, [x_n, y_n]).

No caso do polígono a área interna às retas que ligam os pontos é colorida.

» fig = plt.figure()
» ax = fig.add_subplot()
» retangulo = plt.Rectangle((0.2, 0.2), 0.6, 0.4, color='#aabbcc')
» circulo = plt.Circle((0.4, 0.6), 0.3, color='plum', alpha=0.3)
» poligono1 = plt.Polygon([[0.1, 0.1], [0.8, 0.7], [.3,.7], [0.6, 0.1]], color='turquoise', alpha=0.8)
» poligono2 = plt.Polygon([[0.2, 0.2], [0.8, 0.8]], color='red', alpha=0.8)
» ax.add_patch(retangulo)
» ax.add_patch(circulo)
» ax.add_patch(poligono1)
» ax.add_patch(poligono2)

Para o segundo “polígono” apenas dois pontos foram fornecidos e ele é representado pela reta (vermelha) que liga esses pontos.

Claro que gráficos mais elaborados podem ser montados com uma combinação de figuras como retas, polígonos, círculos, etc. No caso abaixo uma cor é escolhida “aleatoriamente” para plotar um série de 2 triângulos, um com um vértice em (0,1), outro em (1,0). Os dois outros vértices dos triângulos são coincidentes, e se deslocam sobre a reta (t,t) com t de 0 a 1, com espaçamento .1. A cor tem transparência alpha=.6 para que as cores apareçam em tons pastéis.

A função cor() retorna uma tupla (r,b,g) onde cada componente representa as cores vermelho, verde e azul, com valores de 0 até 1.

» def cor():
»     return (np.random.random(), np.random.random(), np.random.random())

» fig = plt.figure()
» ax = fig.add_subplot()
» for t in np.linspace(0,1,100):
»     c = cor()
»     poligono1 = plt.Polygon([[0, 1], [t, t], [t+.1, t+.1]], color=c, alpha=.6)
»     poligono2 = plt.Polygon([[1, 0], [t, t], [t+.1, t+.1]], color=c, alpha=.6)
»     ax.add_patch(poligono1)
»     ax.add_patch(poligono2)                       # a figura 20 é gerada


O código abaixo gera círculos de raios aleatórios, espalhados em torno da reta (t,t), afastados dela por uma variacão também aleatória.

» fig = plt.figure()
» ax = fig.add_subplot()
» plt.axis('equal')
» for t in np.linspace(0,1,100):
»     circulo = plt.Circle((t*np.random.random(), t*np.random.random()), np.random.random()/10, color=cor(), alpha=0.5)
»     ax.add_patch(circulo)
» ax.set_xlim([0, 1])
» ax.set_ylim([0, 1])
» plt.savefig('circulos.pdf')                       # a figura 21 é gerada

Ao final a figura gerada é gravada em disco com o formato “pdf”. Outros formatos podem ser escolhidos, como “jpeg”, “png”, “svg”, assim como a resolução em dots per inches, (dpi ), que tem default = 100, além da cor de fundo e bordas.

Configuração do matplotlib

Por padrão o matplotlib possui um esquema de cores e outros parâmetros, como largura e tipo de linhas, previamente definidos e voltados para plotar figuras prontas para publicação. No entanto, vários destes parâmetros podem ser personalizados através de ajustes nos valores globais tais como tamanho, espaçamento entre subplots, cores, família e tamanhos de fonte, estilos de grade, etc.

Uma forma de alterar esses padrões está no método plt.rc (parametro, opcoes) onde parametro é uma string com o nome do parâmetro que se quer modificar, e opcoes é uma sequência de argumentos de palavras-chaves com os novos valores.

Entre outras opções parametro pode ser figure, axis, xtick, ytick, grid, legend. As opções podem ser passadas de várias formas. O exemplo mostra como ajustar todas as figuras de uma sessão para o tamanho 20×15. Depois fazemos ajustes às fontes, usando um dicionário.

» # ajustar tamanho da figura
» plt.rc ('figure', figsize = (20, 15))

» # parâmetros associados às fontes, em um dicionário
» font_options = {'family' : 'monospace', 'weight' : 'bold', 'size' : 'small'}
» plt.rc ('font', **font_options)

Uma personalização mais ampla pode ser feita no arquivo de configurações. Para encontrar esse arquivo use os comandos:

» # no prompt do sistema digite
» python -c "import matplotlib; print(matplotlib.matplotlib_fname())"

» # de dentro do jupyter notebook (ou de qualquer ambiente em que você trabalhe):
» import matplotlib
» print(matplotlib.matplotlib_fname())
↳ /home/usuario/.anaconda3/lib/python3.8/site-packages/matplotlib/mpl-data/matplotlibrc

Esse output é relativo ao sistema e à distribuição que está sendo usada, lembrando que é possível existir mais de uma instalação em um computador. No caso mostrado está em uso o anaconda e jupyter no linux mint. Cada usuário pode encontrar um local diferente. Esse arquivo não deve ser editado diretamente mas copiado para a pasta home do usuário (no linux) com o nome .matplotlibrc. Desta forma ele será carregado durante a inicialização do pacote. A análise desse arquivo é uma boa forma de se conhecer as possibilidades na personalização, sendo que as opções estão comentadas.

As atuais configurações globais podem ser vistas com o comando

» import matplotlib as mpl
» print(mpl.rcParams)

matplotlib.rcParams é usado para alterar esses parâmetros, um de cada vez. matplotlib.rc pode alterar os valores default para vários parâmetros de um grupo específico, como tipos de lihnes, fontes, textos, etc.

» matplotlib.rcParams['lines.markersize'] = 20
» matplotlib.rcParams['font.size'] = '15.0'

matplotlib.rcdefaults() reseta todos os parâmetros para seus valores originais.

Usando matplotlib com o pandas

A própria biblioteca do pandas embute diversas funcionalidades do matplotlib, sem que esse tenha que ser carregado explicitamente. Isso significa que podemos criar gráficos sem passar por todas as etapas de sua construção.

Por exemplo, uma instância de Series possui o método series.plot().

» s1 = pd.Series(np.random.randn(100).cumsum())
» s1.plot(use_index=False)                          # grafico 22-a é plotado

» s2 = pd.Series([x**2 for x in np.arange(-10,10,.1)], index=np.arange(-10,10,.1))
» s2.plot()                                         # grafico 22-b é plotado


Na figura 21 o eixo x recebe valores dos índices da série, que por default vai de 0 até 99. Na segunda o índice que foi declarado é usado. Para evitar o procedimento de usar o índice como ordenada passamos o parâmetro series.plot(use_index=False).

Para um dataframe cada series correspondente a cada coluna é plotada separadamente. Abaixo construimos e plotamos um dataframe de quatro colunas, cada uma delas representando valores de um seno com frequências diferentes.

» s1 = pd.Series([np.sin(x) for x in np.arange(0,10,.1)])
» s2 = pd.Series([np.sin(2*x) for x in np.arange(0,10,.1)])
» s3 = pd.Series([np.sin(3*x) for x in np.arange(0,10,.1)])
» s4 = pd.Series([np.sin(4*x) for x in np.arange(0,10,.1)])

» df=pd.concat({'A': s1, 'B': s2, 'C': s3, 'D': s4} , axis=1)

» df.plot()                                                        # a figura 23 é plotada
» df.plot(color=['k','r','b','y'], alpha=.6, logx=True, grid=True) # a figura 24 é plotada


O dataframe df contém 4 colunas, cada uma com os valores de seno(πx), seno(2πx), seno(3πx), seno(4πx), com x variando de 0 a 10 em passos de 0,1. No segundo gráfico, figura 24, alguns parâmetros foram passados, como uma lista de cores, a existência de quadriculado (grid) e a instrução para usar uma escala logarítmica em x.

A instrução dataframe.plot() é um atalho para dataframe.plot.line() que representa como curvas os pontos passados. Outros parâmetros podem ser passados, exatamente como no uso direto de matplotlib:

Argumento Descrição
label texto para a legenda
ax objeto subplot do matplotlib onde plotar. Se vazio os plots vão para o subplot ativo
style string de estilo, como ‘ko–‘, passado para o matplotlib
alpha opacidade do plot (de 0 to 1)
kind opções: ‘area’, ‘bar’, ‘barh’, ‘density’, ‘hist’, ‘kde’, ‘line’, ‘pie’
logy use escala logaritmica no eixo y
use_index use o index para os labels de x
rot rotação de texto nos labels (0 até 360)
xticks valores a usar para marcas no eixo x
yticks valores a usar para marcas no eixo y
xlim limites para o eixo x (ex.: [0, 10])
ylim limites para o eixo y
grid exibir grade quadriculada de fundo (grid), default=exibir

Alguns parâmetros são específicos para dataframes.

Argumento Descrição
subplots bool. Plota cada coluna em um subplot separado
sharex se subplots=True, use o mesmo eixo x, com marcas e limites
sharey se subplots=True, use o mesmo eixo y, com marcas e limites
figsize tupla indicando tamanho da figura
title texto para o título
legend bool. Inclui legenda do subplot (default=True)
sort_columns plot colunas em ordem alfabética no nome; default= ordem no dataframe

Plotagem em barras

A plotagem em barras (bar plots ) pode ser feita com plot.bar() e plot.barh() (com barras verticais e horizontais).

Para experimentar com esses tipos de plotagens vamos usar os mesmos dados importados anteriormente, que contém uma lista de países com o número de nascimentos por ano de 1950 até 2020, e a estimativa à partir de 2021. Importamos o arquivo .csv para um dataframe e selecionamos apenas as linhas relativas ao ano de 2020. Linhas relativas à continentes e outras partes do mundo que não países possuem coluna Code = NaN e são excluídas. Renomeamos as colunas para mais fácil manuseio e mantemos apenas as colunas relativas ao país, ano e número de nascimentos.

» # importação do csv em um dataframe
» dados = pd.read_csv('./dados/number-of-births-per-year.csv')
» # selecão do ano = 2020 e apenas países
» dados = dados[(dados['Code'].notnull()) & (dados['Year']==2020)]
» # renomeando colunas
» dados = dados.rename(columns={'Entity':'país','Year':'ano', dados.columns[3]:'nasc'})
» # mantendo apenas colunas relevantes
» dados = dados[['país', 'nasc']]

» # use a coluna 'país' como índice
» dados.set_index('país', inplace=True)

» # o dataframe final:
» dados.head(4)
↳                      nasc
↳        país
↳ Afghanistan     1215628.0
↳     Albania       32888.0
↳     Algeria      995368.0
↳      Angola     1311356.0

Imprimimos os 2 tipos de barplot com o código abaixo, usando 10 países para as barras verticais e 20 para as horizontais. Para isso inicializamos uma figura com 1 linha e 2 colunas. O parâmetro figsize=(15, 16) indica que nossa figura terá a largura 15 e altura 6 (em polegadas). Dados os nomes longos de países o gráfico ficou sobreposto, o que seria controlado aumentando-se a separação entre axes.

» fig, axes = plt.subplots(2, 1, figsize=(10, 10))
» dados[:10].plot.bar(ax=axes[0], color=['r','b','g'], rot=30, grid=True)
» dados[:20].plot.barh(ax=axes[1], color='g', alpha=0.7, grid=True)


Gráficos desse tipo são desenhados para series e dataframes com apenas uma coluna. Se o dataframe possui várias colunas o gráfico de barras plota uma barra para cada coluna.

Para o próximo exemplo usaremos os dados disponibilizados no GapMinder, já usados e descritos nesse site. Desses dados manteremos apenas as colunas que renomearemos como “pais”, “ano” e “pop” (população), ficando com um dataframe com os países de mundo e suas populações nos anos listados abaixo.

Dessa coleta de dado separamos apenas os 5 países com maior população no último ano, 2007.

» # baixar dados do gapminder
» url =(
»       'https://raw.githubusercontent.com/jennybc/'
»       'gapminder/master/data-raw/08_gap-every-five-years.tsv'
»        )

» # criamos o dataframe dfPaises. O arquivo importado tem campos separados por tabs
» dfPaises = pd.read_csv(url, sep='\t')

» # para ver colunas e forma geral usamos
» dfPaises.head(2)
↳         country   continent    year    lifeExp        pop     gdpPercap
↳ 0   Afghanistan        Asia    1952     28.801    8425333    779.445314
↳ 1   Afghanistan        Asia    1957     30.332    9240934    820.853030

» # usamos apenas 3 colunas
» dfPaises = dfPaises[['country','year','pop']]
» # e as renomeamos
» dfPaises.rename(columns={'country':'pais', 'year':'ano'}, inplace=True)

» # o resultado é
» dfPaises
↳             pais     ano        pop
↳ 0    Afghanistan    1952    8425333
↳ 1    Afghanistan    1957    9240934
↳ 2    Afghanistan    1962   10267083

» # para ver os anos registrados examinamos o conjunto (set)
» set(dfPaises['ano'])
↳ {1952, 1957, 1962, 1967, 1972, 1977, 1982, 1987, 1992, 1997, 2002, 2007}

» # países mais populosos, em 2007
» dfPaises[dfPaises['ano']==2007] \
»          .sort_values(by=['pop'], axis=0, \
»           ascending=False, inplace=False)['pais'] \
»          .head(5)
↳ 299             China
↳ 707             India
↳ 1619    United States
↳ 719         Indonesia
↳ 179            Brazil

O último comando, para selecionar os países mais populosos, está quebrada pelo caracter \ (back slash) que é ignorado (a linha é executada por inteiro). Essa linha pode ser compreendida assim:

dfPaises[dfPaises['ano']==2007]                         : seleção só de linhas com ano = 2007
.sort_values(by=['pop'], axis=0, ascending=False)       : ordena pela coluna 'pop' em ordem inversa
['pais'].head(5)                                        : só a coluna 'pais', 5 primeiros valores

Em seguida montamos um dataframe para cada desses países e os concatenamos para um dataframe mais geral que contém linhas indexadas pela ano e colunas com o nome do país. (Outras técnicas de agrupamento serão vistas mais tarde.)

» china = dfPaises[dfPaises['pais']=='China'][['ano', 'pop']]\
»         .set_index('ano').rename(columns={'pop':'china'})
» india = dfPaises[dfPaises['pais']=='India'][['ano', 'pop']]\
»         .set_index('ano').rename(columns={'pop':'india'})
» usa = dfPaises[dfPaises['pais']=='United States'][['ano', 'pop']]\
»       .set_index('ano').rename(columns={'pop':'usa'})
» indonesia = dfPaises[dfPaises['pais']=='Indonesia'][['ano', 'pop']]\
»             .set_index('ano').rename(columns={'pop':'indonesia'})
» brasil = dfPaises[dfPaises['pais']=='Brazil'][['ano', 'pop']]\
»          .set_index('ano').rename(columns={'pop':'brasil'})

As linhas de seleção de dados do país podem ser compreendidas assim:

  dfPaises[dfPaises['pais']=='Brazil']      : seleciona apenas linhas relativas ao país 'Brazil'
  [['ano', 'pop']]                          : desse df copia apenas as colunas 'ano' e 'pop'
  .set_index('ano')                         : use a coluna 'ano' como índice
  .rename(columns={'pop':'brasil'})         : renomeie coluna 'pop' para 'brasil'
» # os paises são concatenados em um único df
» df = pd.concat([china, india, usa, indonesia, brasil],  axis=1)
» # o nome da lista de colunas será usado no plot
» df.columns.name = 'População'

» # e o resultado é
» df.head(3)
↳ População     china        india          usa   indonesia      brasil
↳ ano
↳ 1952      556263527    372000000    157553000    82052000    56602560
↳ 1957      637408000    409000000    171984000    90124000    65551171
↳ 1962      665770000    454000000    186538000    99028000    76039390

» # esse dataframe pode ser exibido em gráfico de barras
» cor = ['salmon','gold','teal','plum','powderblue']
» df.plot.bar(figsize=(10,5), grid=True, color = cor, title='Países mais populosos (de 1952 até 2007)', rot=45)

Na plotagem acima uma paleta de cores foi passada para o parâmetro color. Cada uma delas é usada para um país. O nome das colunas, df.columns.name = 'População' é usado como título da legenda.

Para gerar gráficos de barras empilhadas (stacked bar ) passamos o valor stacked=True. Nos exemplos plotamos a versão horizontal e vertical do mesmo gráfico acima.

» df.plot.barh(stacked=True, alpha=.7)         # a figura 27 é plotada

» df.plot.bar(stacked=True, alpha=.7)          # a figura 28 é plotada

Histogramas

Um histograma é uma representação gráfica, similar a um gráfico de barras, de uma distribuição de pontos. Os pontos são distribuídos em faixas igualmente divididas e o gráfico é o conjunto de retângulos com base de tamanho igual à largura das faixas e altura correspondente ao número de pontos em cada faixa.

Criamos uma série com 1000 números aleatórios, multiplicados por 100. O resultado é, no caso dessa execução, um conjunto distribuído entre -315 e 325 (aproximadamente). A partir desses dados traçamos o histograma e o gráfico de densidade ou density plot.

» ser = pd.Series((np.random.randn(1000)*100))
» ser.describe()
↳ count    1000.000000
↳ mean        3.747373
↳ std       102.637489
↳ min      -314.443835
↳ 25%       -65.343082
↳ 50%         4.835384
↳ 75%        73.314604
↳ max       324.011000

» ser.plot.hist(bins=10, grid=True, color='b', alpha=.4) # figura 19

» ser.plot.kde() # o mesmo que ser.plot.density()        # figura 30


O gráfico de densidade consiste na plotagem de uma função de distribuição de probabilidade que poderia ter gerado os dados na series. A técnica usual consiste em usar uma mistura de “núcleos” ou “kernels”. Esses gráficos são também chamados de estimativa de núcleos de densidade (kernel density estimate, KDE ).

Seaborn

Seaborn é outra biblioteca do Python voltada para a visualização de dados, baseada no matplotlib. Ela apresenta uma interface de mais alto nível e aprimoramento da qualidade estética dos gráficos. Com o Seaborn se pode conseguir gráficos bem elaborados e de boa aparência com um número menor de linhas de código.

Para os exemplos com o seaborn vamos usar os dados do Gapminder já descritos. O dataframe importado tem 1704 linhas com dados sobre os países, 6 colunas ‘country’, ‘continent’, ‘year’, ‘lifeExp’, ‘pop’, ‘gdpPercap’, respectivamente ‘pais’, ‘continente’, ‘ano’, ‘Expectativa de vida’, ‘população’, ‘PIB percapita’.

» url =(
»       'https://raw.githubusercontent.com/jennybc/'
»       'gapminder/master/data-raw/08_gap-every-five-years.tsv'
»        )
» # criamos o dataframe dfPaises. O arquivo importado tem campos separados por tabs
» dfPaises = pd.read_csv(url, sep='\t')

» # para restringir o volume dos dados armazenamos as fatias
» df2007 = dfPaises[dfPaises['year']==2007]
» dfBrasil = dfPaises[dfPaises['country']=='Brazil']

O seaborn deve ser importado. Para uma gráfico de barras mais simples informamos e base de dados e os nomes de colunas a usadas como valores para os eixos. Para não congestionar o gráfico usamos apenas os 10 primeiros países.

» import seaborn as sns
» sns.barplot(data=df2007[:10], x='lifeExp', y='gdpPercap')
» # é plotada a figura 31 abaixo

Muitas configuarações podem ser aplicadas sobre esse gráfico básico. Algumas são usadas abaixo, como a orientação das barras, textos e rotação nos eixos x e y.

» sns.set_style('darkgrid')
» graf = sns.barplot(data=df2007[:10], x='lifeExp', y='gdpPercap', orient='h')
» graf.set(xlabel = 'Expectativa de vida (anos)',
                     ylabel = 'PIB percapita', title ='Expectativa de vida x PIB')
» graf.set_xticklabels(labels=df2007[:10]['lifeExp'].round(1))
» graf.set_yticklabels(labels=df2007[:10]['country'], rotation=30)
» # é plotada a figura 32 acima

Valores válidos para sns.set_style() são 'white', 'dark', 'whitegrid', 'darkgrid', 'ticks'.

Esses gráficos plotados não sugerem qualquer relação entre a renda percapita e a expectativa de vida, o que é natural uma vez que escolhemos apenas os primeiros 10 países, em ordem alfabética. Claro que barplots não são apropriados para exibir um número muito grande de dados. Para isso podemos usar seaborn.regplot que plota o gráficos de dispersão (scatter plots ) e uma reta correspondente a um ajuste do modelo de regressão linear. Esse último gráfico mostra que existe correlação entre expectativa de vida e renda percapita.

» sns.regplot(x='lifeExp', y='gdpPercap', data=df2007) # figura 33


É comum em análise de dados que se queira ter uma visão geral de relacionamentos entre as variáveis (ou colunas de um dataframe. Para isso um pairplot faz o cruzamento entre todas as variáveis. O método seaborn.pairplot(), por default, cria uma matriz de Axes comparando aos pares as variáveis numéricas do dataframe usado como fonte de dados. Na diagonal dessa matriz uma distribuição univariada é exibida para mostrar a distribuição dos dados em cada coluna.

» sns.pairplot(df2007[['gdpPercap', 'lifeExp']],
»              diag_kind='kde', plot_kws={'color':'r','alpha': .9})


O parâmetro plot_kws recebe um dicionário de propriedades com valores.

Outro método é seaborn.catplot() que traz diversas funcionalidades para representar relações entre variáveis numéricas ou categóricas. Para uma amostra criamos um dataframe com valores de uma parábola e um seno.

» dfGraf=pd.DataFrame(np.arange(20), columns = ['x'])
» dfGraf['quadrado']=dfGraf['x']**2
» dfGraf['seno']=np.sin(dfGraf['x'])

» sns.catplot(x='x', y='quadrado', kind='strip', data=dfGraf)      # plota a figura 35
» sns.catplot(x='x', y='seno', kind='bar', data=dfGraf)            # plota a figura 36

Bibliografia

Todos os sites acessados em setembro de 2021.

Consulte bibliografia completa em Pandas, Introdução neste site.

Dataframes: multi-índices e concatenção

Índices Hierárquicos

É possível criar series e dataframes com índices e subíndices. Esse processo de indexação hierárquica é importante para a reformatação (reshaping ), formação de tabelas pivot e outras operações de agrupamento de dados.

» import pandas as pd
» import numpy as np

» # formamos uma series com índices duplos 
» sr = pd.Series([11, 12, 21, 22, 23, 31, 32, 41, 42],
                 index=[['A', 'A', 'B', 'B', 'B', 'C', 'C', 'D', 'D'],
                 [1, 2, 1, 2, 3, 1, 2, 1, 2]])
» sr
↳ A  1    11
     2    12
  B  1    21
     2    22
     3    23
  C  1    31
     2    32
  D  1    41
     2    42

» # essa series possui índices
» sr.index
↳ MultiIndex([('A', 1), ('A', 2),
              ('B', 1), ('B', 2), ('B', 3),
              ('C', 1), ('C', 2),
              ('D', 1), ('D', 2)],)

» # da mesma forma podemos transformar essa series um um dataframe
» df = pd.DataFrame(sr)

» # Os índices do dataframe são os mesmos: df.index

» # o índice B corresponde à 3 linhas
» df.loc['B']
↳        0
  1     21
  2     22
  3     23

» df.loc['B'].loc[2]
↳ 0    22

» # idem para a series
» sr['C']
↳ 1    31
  2    32

» sr['C'][1]
↳ 31

» # podemos listar as linhas de 'A' até 'C'
» sr['A':'C']
↳ A  1    11
     2    12
  B  1    21
     2    22
     3    23
  C  1    31
     2    32

» # ou as linhas correspondentes à 'A' e 'C'
» sr.loc[['A','C']]
↳ A  1    11
     2    12
  C  1    31
     2    32

» # seleção pelo índice interno pode feita diretamente
» sr.loc[:, 2]
↳ A    12
  B    22
  C    32
  D    42

» sr.loc[:, 3]
↳ B    23

stack() e unstack()

Os dados de uma series com índices hierárquicos podem ser rearranjados em um DataFrame com o uso de método Series.unstack(). Os índices internos se tornam nomes das colunas. Valores não existentes, como o correspondende aos índices A, 3, são preenchidos com NaN.

» df = sr.unstack()
» df
↳          1         2       3
  A     11.0     12.0      NaN
  B     21.0     22.0     23.0
  C     31.0     32.0      NaN
  D     41.0     42.0      NaN

» # para retornar à uma series
» df.unstack()
↳ 1  A    11.0
     B    21.0
     C    31.0
     D    41.0
  2  A    12.0
     B    22.0
     C    32.0
     D    42.0
  3  A     NaN
     B    23.0
     C     NaN
     D     NaN

No processo de desempilhar o dataframe (unstack ) os nomes das colunas foram usados como índices primários.

Um dataframe pode ter índices hierarquizados para linhas e colunas.

» clima = np.array([[25,20,30],[20,16,15],[15,25,27],[40,60,78]])
» dfClima = pd.DataFrame(clima,
»                       index=[['Temperatura','Temperatura','Umidade','Umidade'],
»                              ['dia','noite','dia','noite']],
»                       columns=[['Paraná','Paraná','Amazonas'],['Cascavel','Curitiba','Manaus']]
»                       )

» # inserindo nomes para as linhas e colunas
» dfClima.index.names = ['Característica', 'D/N']        # D/N = dia/noite
» dfClima.columns.names = ['Estado', 'Cidade']

» # o resultado é
» dfClima
↳                  Estado                   Paraná     Amazonas
                   Cidade     Cascavel     Curitiba      Manaus
  Característica      D/N             
  Temperatura         dia           25           20          30
                    noite           20           16          15
  Umidade             dia           15           25          27
                    noite           40           60          78

Se o processo de criação de dataframes com os mesmos índices será repetido várias vezes ,pode ser útil definir previamente os objetos multindexes.

» colunas = pd.MultiIndex.from_arrays([['Paraná', 'Paraná', 'Amazonas'],
»                                      ['Cascavel', 'Curitiba', 'Manaus']],
»                                      names=['Estado', 'Cidade'])

» linhas = pd.MultiIndex.from_arrays([['Temperatura','Temperatura','Umidade','Umidade'],
»                                     ['dia','noite','dia','noite']],
»                                     names=['Característica', 'D/N'])

» linhas
↳ MultiIndex([('Temperatura',   'dia'),
              ('Temperatura', 'noite'),
              (    'Umidade',   'dia'),
              (    'Umidade', 'noite')],
             names=['Característica', 'D/N'])

» pd.DataFrame(clima, index=linhas, columns=colunas)
↳                 Estado     Paraná             Amazonas
                  Cidade   Cascavel    Curitiba   Manaus
  Característica     D/N
  Temperatura        dia         25          20       30
                   noite         20          16       15
  Umidade            dia         15          25       27
                   noite         40          60       78

swaplevel() e groupby()

O ordenamento dos níveis nos dataframes pode ser alterado com o método dataframe.swaplevel(indice1, indice2). Índices primários podem ser permutados com índice secundários. Com dataframe.sort_index(level=n) podemos ordenar as linhas do dataframe segundo os nomes dos índices do nível n.

» dfClima.swaplevel('D/N', 'Característica')
↳                 Estado     Paraná               Amazonas
                  Cidade   Cascavel    Curitiba     Manaus
  D/N     Característica
  dia        Temperatura         25          20         30
  noite      Temperatura         20          16         15
  dia            Umidade         15          25         27
  noite          Umidade         40          60         78

» # ordenando as linhas pelos labels do nível 1 (D/N)
» dfClima.sort_index(level=1)

↳                  Estado                    Paraná    Amazonas
                   Cidade     Cascavel     Curitiba      Manaus
  Característica      D/N
  Temperatura         dia           25           20          30
  Umidade             dia           15           25          27
  Temperatura       noite           20           16          15
  Umidade           noite           40           60          78

» # alternativamente podemos inverter a ordem dos níveis e ordenar pelo nivel 0
» dfClima.swaplevel(0, 1).sort_index(level=0)
↳                 Estado                    Paraná    Amazonas
                  Cidade     Cascavel     Curitiba      Manaus
  D/N     Característica
  dia        Temperatura           25           20          30
                 Umidade           15           25          27
  noite      Temperatura           20           16          15
                 Umidade           40           60          78

» # soma dos valores agrupados pelo nível 1 (D/N)
» dfClima.groupby(level=1).sum()
 
↳ Estado                    Paraná    Amazonas
  Cidade     Cascavel     Curitiba      Manaus
  D/N 
  dia              40           45          57
  noite            60           76          93

O método dataframe.groupby(), que veremos mais tarde com maiores detalhes, permite o agrupamento dos dados de um determinado índice (ou nível de índices). Por ex., dataframe.groupby(level=n).sum() faz o agrupamento dos dados segundo o n-ésimo nível de índice e depois soma esses valores. Muitas outras funções estatísticas ficam disponíveis com agrupamentos por groupby.

» # soma dos valores agrupados pelo nível 0
» dfClima.groupby(level='Característica').mean()
↳                  Estado               Paraná    Amazonas
                   Cidade  Cascavel   Curitiba      Manaus
  Característica
  Temperatura                  22.5       18.0        22.5
  Umidade                      27.5       42.5        52.5

» # a média dos valores agrupados pelo índice D/N
» dfClima.groupby(level=0).mean()

↳                   Estado              Paraná   Amazonas
                    Cidade  Cascavel  Curitiba     Manaus
  Característica
  Temperatura                   22.5      18.0       22.5
  Umidade                       27.5      42.5       52.5

» # o valor máximo agrupado pelo nível 'Característica'
» dfClima.groupby(level='Característica').max()
↳                  Estado              Paraná    Amazonas
  Cidade                   Cascavel   Curitiba     Manaus
  Característica
  Temperatura                    25         20         30
  Umidade                        40         60         78

Vimos previamente que qualquer coluna pode ser transformada em índice do dataframe. Mais de uma coluna pode também ser usada: para isso usamos dataframe.set_index([coluna1, coluna2]). Por default essa operação coloca coluna1, coluna2 como índices e descarta as colunas usadas. Para alterar esse comportamento (e manter as colunas) usamos o parâmetro drop=False. O método dataframe.reset_index() remove os índices colocando-os como colunas e criando um novo conjunto de índices.

» # criamos um dataframe arbitrário
» dfNums = pd.DataFrame({'a': range(1,6),
»                        'texto-a': ['um','dois','três','quatro','cinco'],
»                        'b': range(5, 0, -1),
»                        'texto-b': ['cinco', 'quatro','três','dois','um']
»                       })

» # dataframe inicial
» dfNums
↳      a     texto-a     b     texto-b
  0    1          um     5       cinco
  1    2        dois     4      quatro
  2    3        três     3        três
  3    4      quatro     2        dois
  4    5       cinco     1          um

» # usamos as colunas 'a' e 'b' como índices
» dfNums2 = dfNums.set_index(['a', 'b'])

» dfNums2
↳          texto-a    texto-b
  a    b         
  1    5        um      cinco
  2    4      dois     quatro
  3    3      três       três
  4    2    quatro       dois
  5    1     cinco         um

» # para descartar os índices (e recuperar as colunas)
» dfNums2.reset_index()

↳       a     b     texto-a     texto-b
  0     1     5     um          cinco
  1     2     4     dois        quatro
  2     3     3     três        três
  3     4     2     quatro      dois
  4     5     1     cinco       um

» # podemos usar as colunas 'a' e 'texto-a' como índices sem descartar essas colunas
» dfNums.set_index(['a', 'texto-a'], drop=False)
↳                 a     texto-a    b    texto-b
  a     texto-a                 
  1     um        1     um         5      cinco
  2     dois      2     dois       4     quatro
  3     três      3     três       3       três
  4     quatro    4     quatro     2       dois
  5     cinco     5     cinco      1         um

Uma exceção é lançada se já existem colunas com os mesmos nomes recuperados por reset_index.

Combinando dataframes

Podemos juntar dataframes de várias formas. pandas.merge() junta dataframes usando um ou mais índices, em operações semelhantes àquelas de bancos de dados relacionais usando-se as operações de join do SQL. pandas.concat() faz a concatenação ou empilhamento dos dataframes ao longo do eixo escolhido. pandas.combine_first() permite a junção de dados que se superpõe (existem em mais de uma tabela), preenchendo valores ausentes um uma tabela com aqueles em outra tabela fornecida.

merge()

df1.merge(df2) retorna outro dataframe que é a junção dos dois dataframes. O método possui a seguinte assinatura:
df1.merge(df2, how='inner', on=None, left_on=None, right_on=None, left_index=False, right_index=False, sort=False, suffixes=('_x', '_y'), copy=True, indicator=False, validate=None)

A junção pode ser feita sobre nomes das colunas ou índices. Uma Series nomeada é tratada como um dataframe de coluna única. São parâmetros:

df1, df2 dataframe ou Series nomeada. Junção de df1 com df2
how tipo de junção: left, right, outer, inner, cross:
inner: usa apenas combinações de chaves existentes em ambas as tabelas preserva ordem das chaves.
outer: usa todas as combinações de chaves em cada uma das tabelas,
left: usa todas as combinações de chaves existentes na tabela à esquerda,
right: usa todas as combinações de chaves existentes na tabela à direita,
cross: cria o produto cartesiano das tabelas, preserva ordem dos índices.
on coluna ou índice para a junção. Deve existir em ambos os dataframes
left_on nome da coluna ou índice (ou lista) em df1.
right_on nome da coluna ou índice (ou lista) em df2.
left_index False/True: use o índice de df1 como chave.
right_index False/True: use o índice de df2 como chave.
sort False/True: Ordena os índices no resultado.
suffixes lista: default = (“_x”, “_y”). Sufixos para índices de mesmo nome
copy False/True: Se False evita a cópia, se possível
indicator False/True ou str: Se True acrescenta coluna “_merge” com informações sobre as linhas.
validate str, opcional. Se especificada verifica se a junção é do tipo:
one_to_one ou 1:1 : se chave da fusão é única nos dois dataframes,
one_to_many ou 1:m : se chave da fusão é única em df1 (lado esquerdo),
many_to_one ou m:1 : se chave da fusão é única em df2 (lado direito),
many_to_many ou m:m : embora permitida não resulta em nenhuma verificação.

Comparação de how='' com comandos SQL: (Pandas e SQL comparados).

how= similar ao SQL
left left outer join. Preserva ordem das chaves.
right right outer join. Preserva ordem das chaves.
outer full outer join. Ordena por nomes das chaves.
inner inner join. Preserva ordem das chaves à esquerda.
» # criando dataframes 
» df1 = pd.DataFrame({'chave': ['a', 'a', 'a', 'b', 'b', 'b', 'c'], 'data1': range(7)})
» df2 = pd.DataFrame({'chave': ['a', 'b', 'd'], 'data2': range(3)})

» # exibindo df1, df2 e sua junção com merge
» display(df1, df2, pd.merge(df1, df2))

↳    chave   data1
  0      a       0
  1      a       1
  2      a       2
  3      b       3
  4      b       4
  5      b       5
  6      c       6

↳    chave   data2
  0      a      0
  1      b      1
  2      d      2

↳    chave   data1   data2
  0      a       0       0
  1      a       1       0
  2      a       2       0
  3      b       3       1
  4      b       4       1
  5      b       5       1


Como os dois dataframes possuem uma coluna com nome comum a junção foi feita com base nos valores da coluna com esse nome. Essa informação pode ser explicitada com pd.merge(df1, df2, on='chave').

Se os nomes das colunas de cada dataframe for diferente eles devem ser definidos com os parâmetros left_on, right_on.

» df3 = pd.DataFrame({'chave1': ['a', 'a', 'a', 'b', 'b', 'c', 'd'], 'data1': range(7)})
» df4 = pd.DataFrame({'chave2': ['a', 'b', 'd'], 'data2': range(3)})

» display(df3, df4, pd.merge(df3, df4, left_on='chave1', right_on='chave2'))
↳    chave1     data1
  0       a     0
  1       a     1
  2       a     2
  3       b     3
  4       b     4
  5       c     5
  6       d     6

↳    chave2     data2
  0       a     0
  1       b     1
  2       d     2

↳    chave1     data1     chave2     data2
  0       a     0         a          0
  1       a     1         a          0
  2       a     2         a          0
  3       b     3         b          1
  4       b     4         b          1
  5       d     6         d          2

Vemos na concatenação acima que o método usado reune apenas valores existentes nas duas tabelas. Isso é equivalente a passar o parâmetro how=’inner’ (um inner join ). Outra opção consiste em fazer o ligamento externo.

» # para conseguir um outer join    
» pd.merge(df1, df2, how='outer')

↳     chave   data1   data2
  0     a       0.0     0.0
  1     a       1.0     0.0
  2     a       2.0     0.0
  3     b       3.0     1.0
  4     b       4.0     1.0
  5     b       5.0     1.0
  6     c       6.0     NaN
  7     d       NaN     2.0

» dd.merge(df1, df2, how='left')

↳   chave   data1   data2
  0     a       0     0.0
  1     a       1     0.0
  2     a       2     0.0
  3     b       3     1.0
  4     b       4     1.0
  5     b       5     1.0
  6     c       6     NaN

» pd.merge(df1, df2, how='right')

↳   chave     data1   data2
  0     a       0.0       0
  1     a       1.0       0
  2     a       2.0       0
  3     b       3.0       1
  4     b       4.0       1
  5     b       5.0       1
  6     d       NaN       2

Tabelas podem ser ligadas por mais de uma chave, quando os dataframes possuem índices hierarquizados. As chaves são usadas como se fossem uma única chave concatenada.

» df1 = pd.DataFrame({'chave_1': ['rato', 'rato', 'gato'],
»                      'chave_2': ['Jones', 'Jerry', 'Tom'],
»                      'valor_A': [10, 20, 30]})
» df2 = pd.DataFrame({'chave_1': ['rato', 'rato', 'gato', 'gato'],
»                       'chave_2': ['Jones', 'Jerry', 'Tom', 'Tim'],
»                       'valor_B': [40, 50, 60, 70]})
                      
» # exibindo os dataframes e a junção externa em duas chaves
» display(df1, df2, pd.merge(df1, df2, on=['chave_1','chave_2'], how='outer'))

↳     chave_1     chave_2    valor_A
  0      rato       Jones         10
  1      rato       Jerry         20
  2      gato         Tom         30

↳    chave_1     chave_2   valor_B
  0     rato       Jones        40
  1     rato       Jerry        50
  2     gato         Tom        60
  3     gato         Tim        70

↳     chave_1    chave_2    valor_A    valor_B
  0     rato       Jones       10.0         40
  1     rato       Jerry       20.0         50
  2     gato        Tom        30.0         60
  3     gato        Tim         NaN         70

» # a junção interna em duas chaves
» pd.merge(df1, df2, on=['chave_1','chave_2'], how='inner')

↳     chave_1   chave_2    valor_A    valor_B
  0      rato     Jones         10         40
  1      rato     Jerry         20         50
  2      gato      Tom          30         60

Se a junção for feita sobre campos (nomes de colunas) com o mesmo nome estes serão alterados para continuar a representar suas colunas de origem. No caso do exemplo as colunas com nome valor foram renomeadas para valor_x e valor_y.

» df1 = pd.DataFrame({'chave': ['a', 'b', 'c'], 'valor': [1,2,3]})
» df2 = pd.DataFrame({'chave': ['a', 'b', 'c'], 'valor': [10,20,30]})

» mrg = pd.merge(df1, df2, on='chave')

» display(df1, df2, mrg)

↳    chave  valor
  0      a      1
  1      b      2
  2      c      3

↳    chave  valor
  0      a     10
  1      b     20
  2      c     30

↳    chave  valor_x  valor_y
  0      a        1       10
  1      b        2       20
  2      c        3       30

A chave usada na fusão (merge) pode estar no índice de um ou ambas as tabelas. No exemplo usamos pd.merge(esquerda, direita, left_on='chave', right_index=True) que faz a junção de esquerda.chave com direita.index

» esquerda = pd.DataFrame({'chave': ['a1', 'a1', 'a2', 'a1', 'a2', 'a3'], 'valor_1': range(6)})
» direita = pd.DataFrame({'valor_2': [50, 70]}, index=['a1', 'a2'])

» mrg = pd.merge(esquerda, direita, left_on='chave', right_index=True)

» # exibindo dataframes e sua junção, usando o índice da tabela à direita
» display(esquerda, direita, mrg)

↳    chave   valor_1
  0     a1         0
  1     a1         1
  2     a2         2
  3     a1         3
  4     a2         4
  5     a3         5

↳    valor_2
  a1      50
  a2      70

↳    chave   valor_1    valor_2
  0     a1         0         50
  1     a1         1         50
  3     a1         3         50
  2     a2         2         70
  4     a2         4         70

» # se os dataframes forem invertidos conseguiríamos o
» # mesmo resultado, exceto pela ordem das colunas, usando:
» # pd.merge(direita, esquerda, right_on='chave', left_index=True)

Junções com join()

Junções podem ser feitas com dataframe.join(dfOutro) que, por default, faz a união outer join usando o índice como chave. Esse método tem a seguinte assinatura, onde os parâmetros são
dataframe.join(dfOutro, on, how, lsuffix, rsuffix, sort),
Todos os parâmetros são opcionais exceto dfOutro. Os defaults estão em negrito.

dfOutro DataFrame, Series ou lista de DataFrames.
on string, especifica em que chave(s) fazer a junção
how strings: left, right, outer, inner. Especifica o tipo de junção.
lsuffix/rsuffix Default = ”. String a concatenar à esquerda/direita em colunas com mesmo nome.
sort False/True. Se True ordena o dataframe pela chave de junção.
» # dataframe join
» df1 = pd.DataFrame({'nome': ['Paulo', 'Maria', 'Julio','Marta'],
                       'idade': [35, 43, 31, 56]})
» df2 = pd.DataFrame({'profissao': ['médico', 'engenheiro', 'advogado']})

» df1
↳      nome      idade
  0    Paulo     35
  1    Maria     43
  2    Julio     31
  3    Marta     56

» df2
↳      profissao
  0    médico
  1    engenheiro
  2    advogado

» df1.join(df2, on=df1.index,  lsuffix='_1', rsuffix='_2') # , how = 'left' (default)
↳      nome   idade_1    profissao    idade_2
  0   Paulo        35       médico       35.0
  1   Maria        43   engenheiro       40.0
  2   Julio        31     advogado       31.0
  3   Marta        56          NaN        NaN

» # um inner join
» df1.join(df2, lsuffix='_', how='inner')
↳      nome    idade_    profissao   idade
  0   Paulo       35        médico      35
  1   Maria       43    engenheiro      40
  2   Julio       31      advogado      31

Vários dataframes podem ser concatenados de uma vez. Para isso eles devem ter dimensões compatíveis.

» # Vários dataframes podem ser concatenados
» df1 = pd.DataFrame([[23, 83], [93, 10], [73, 89], [68, 90]],
»                    index=['a', 'b', 'e', 'f'],
»                    columns=['A', 'B'])

» df2 = pd.DataFrame([[2, 8], [9, 1], [7, 8], [6, 9]],
»                    index=['a', 'b', 'c', 'd'],
»                    columns=['C', 'D'])

» df3 = pd.DataFrame([[3, 3], [3, 0], [3, 9], [8, 0]],
»                    index=['a', 'c', 'd', 'e'],
»                    columns=['E', 'F'])

» # exibe os 3 dataframes
» display(df1, df2, df3)

↳ A    B
  a    23    83
  b    93    10
  e    73    89
  f    68    90

↳ C    D
  a    2    8
  b    9    1
  c    7    8
  d    6    9
  
↳ E    F
  a    3    3
  c    3    0
  d    3    9
  e    8    0

» # exibe a junção dos dataframes
» df1.join([df2, df3])

↳         A       B      C      D      E      F
  a    23.0    83.0    2.0    8.0    3.0    3.0
  b    93.0    10.0    9.0    1.0    NaN    NaN
  e    73.0    89.0    NaN    NaN    8.0    0.0
  f    68.0    90.0    NaN    NaN    NaN    NaN

Como sempre, campos não fornecidos são preenchidos por NaN. Por ex.: df1.join([df2, df3]).loc['f', 'F'] = NaN.

concatenate()

Podemos concatenar numpy.arrays, Series e dataframes ao longo do eixo desejado.

» # Concatenando um array ao longo de um eixo
» # criamos 2 arrays
» arr1 = np.arange(6).reshape((3, 2))

» arr1
↳ array([[0, 1],
         [2, 3],
         [4, 5]])

» # concatenando arr1 consigo mesmo, ao longo de colunas
» np.concatenate([arr1, arr1], axis=1)
↳ array([[0, 1, 0, 1],
         [2, 3, 2, 3],
         [4, 5, 4, 5]])

» # concatenando arr1 consigo mesmo, ao longo de linhas
» np.concatenate([arr1, arr1], axis=0)
↳ array([[0, 1],
         [2, 3],
         [4, 5],
         [0, 1],
         [2, 3],
         [4, 5]])

» # defina outro array, com shape (3, 1)
» arr2 = np.array([[0], [1], [2]])

» arr2
↳ array([[0],
         [1],
         [2]])

» # concatenando arr1 2 arr2 pelas colunas
» np.concatenate([arr1, arr2], axis=1)

↳ array([[0, 1, 0],
         [2, 3, 1],
         [4, 5, 2]])

» # (tentando) concatenar arr1 2 arr2 pelas linhas
» np.concatenate([arr1, arr2], axis=0)
↳ ValueError: all the input array dimensions for the concatenation axis must match exactly,
  but along dimension 1, the array at index 0 has size 2 and the array at index 1 has size 1


Vemos que podemos concatenar uma matriz coluna (3 × 1) com outra matriz (3 × 2) pelas colunas, mas não pelas linhas pois as dimensãos são incompatíveis.

combine() e combine_first()

O método df1.combine(df2, func, fill_value=None, overwrite=True) combina df1 e df2, coluna a coluna, aplicando func para decidir qual valor será usado.

Podemos criar uma função que receba duas colunas e realize alguma operação entre elas, retornando outra coluna. No ex., a função f faz a soma dos elementos de duas colunas e retorna aquela com menor soma. A função g seleciona, a cada linha, qual é o maior elemento. Quando o parâmetro fill_value=r é usado todos os valores NaN são substituídos por r antes de serem submetidos à função func, exceto se ambos os valores forem nulos, quando não existirá substituição.

» df1 = pd.DataFrame({'A': [0, 3], 'B': [7, 2]})
» df2 = pd.DataFrame({'A': [2, 6], 'B': [1, 3]})

» df1
↳      A    B
  0    0    7
  1    3    2

» df2
↳      A    B
  0    2    1
  1    6    3

» # a função de comparação pode ser
» def f(x,y):
»     if x.sum() < y.sum():
»         return x
»     else:
»         return y

» # a combinação, usando essa função
» df1.combine(df2, f)
↳      A    B
  0    0    1
  1    3    3

» # O mesmo resultado pode ser obtido com uma função lambda
» df1.combine(df2, lambda x, y: x if x.sum() < y.sum() else y)

» # funções mais complexas podem ser usadas
» df1.combine(df2, lambda x, y: (x+y)*(y-x))
↳       A     B
  0     4   -48
  1    27     5

» # outro exemplo, selecionar o maior elemento de cada df
» def g(x,y):
»     a = x[0] if x[0] > y[0] else y[0]
»     b = x[1] if x[1] > y[1] else y[1]
»     return pd.Series([a,b])

» df1.combine(df2,g)
↳      A    B
  0    2    7
  1    6    3

» # o mesmo poderia ser feito com uma funlão lambda
» maior = lambda x,y: pd.Series([x[0] if x[0] > y[0] else y[0],
                                x[1] if x[1] > y[1] else y[1]])
» df1.combine(df2,maior) # mesmo output
 
» # uso de fill_value
» df1 = pd.DataFrame({'A': [0, 0], 'B': [np.NaN, 4]})
» df2 = pd.DataFrame({'A': [1, 1], 'B': [3, 3]})

» df1.combine(df2, maior, fill_value=6)
↳      A      B
  0    1    6.0
  1    1    4.0

Já o método dataframe.combine_first(dfOutro) substitui os valores NaN no dataframe com os valores de dfOutro, quando esses valores existirem.

» df1 = pd.DataFrame({'a': [1, np.nan, 5, np.nan],
»                     'b': [np.nan, 2, np.nan, 6],
»                     'c': range(2, 18, 4)})
» df2 = pd.DataFrame({'a': [5, 4, np.nan, 3, 7],
»                     'b': [np.nan, 3, 4, 6, 8]})
» display(df1, df2)
↳        a      b     c
  0    1.0    NaN     2
  1    NaN    2.0     6
  2    5.0    NaN    10
  3    NaN    6.0    14
  
↳        a      b
  0    5.0    NaN
  1    4.0    3.0
  2    NaN    4.0
  3    3.0    6.0

» df1.combine_first(df2)
↳        a      b      c
  0    1.0    NaN    2.0
  1    4.0    2.0    6.0
  2    5.0    4.0   10.0
  3    3.0    6.0   14.0
  4    7.0    8.0    NaN

Bibliografia

Consulte bibliografia completa em Pandas, Introdução neste site.

Nesse site:

Dataframes, preparação de dados


Preparação de dados

Programadores que lidam com análise de dados passam grande parte do tempo dedicado a um projeto preparando esses dados, antes mesmo de começar qualquer análise. Normalmente os dados são importados de uma fonte externa, tal como um arquivo em forma tabular em html, pdf, texto puro ou csv. Eles precisam ser convertidos para um formato legível e muitas vezes contém erros e valores ausentes. A vezes o próprio processo de conversão introduz perda de dados, tal como acontece em textos impressos transformados em texto digital por OCR (optical character recognition ). Seja qual for a origem dos dados algum trabalho de depuração deve ser feito. Em seguida eles devem passar por formatação adequada, a quebra de tabelas, o estabelecimento de vínculos entre elas, etc. Pandas oferece boas ferramentas para todas essas etapas.

Dados ausentes

Já vimos que dados não presentes em alguma tabela são representados por NaN (not a number). O objeto None do python também é tratado como um valor ausente ou NA (not available). O método dropna() descarta linhas (se axis=0, default) ou colunas (se axis=1) contendo campos nulos. dropna(how='all') descarta linhas ou colunas se todos os campos forem nulos. Também podemos determinar que apenas linhas ou colunas com um número mínimo de elementos não nulos sejam mantidas, com df.dropna(thresh=n).

» import pandas as pd
» import numpy as np

» dados = pd.Series([121.45, np.nan ,32.12,42.21,51.56])
» dados
↳ 0    121.45
  1       NaN
  2     32.12
  3     42.21
  4     51.56

» dados[3]= None
» dados.isnull()
↳ 0    False
  1     True
  2    False
  3     True
  4    False

» dados.dropna()                   # o mesmo que dados[dados.notnull()]
↳ 0    121.45
  2     32.12
  4     51.56

» from numpy import nan as NA     # para estabelecer um alias curto para np.nan
» data = pd.DataFrame([[1., 6.5, 3.9], [1.3, NA, NA], [NA, NA, NA], [NA, 5.8, 6.7]])
» data
↳        0      1      2
  0    1.0    6.5    3.9
  1    1.3    NaN    NaN
  2    NaN    NaN    NaN
  3    NaN    5.8    6.7

» data.dropna()
↳        0      1      2
  0    1.0    6.5    3.9

» data.dropna(how='all')
↳        0      1      2
  0    1.0    6.5    3.9
  1    1.3    NaN    NaN
  3    NaN    5.8    6.7

» data[4] = NA
» data
↳        0      1      2      4
  0    1.0    6.5    3.9    NaN
  1    1.3    NaN    NaN    NaN
  2    NaN    NaN    NaN    NaN
  3    NaN    5.8    6.7    NaN

» data.dropna(axis=1, how='all')
↳        0      1      2
  0    1.0    6.5    3.9
  1    1.3    NaN    NaN
  2    NaN    NaN    NaN
  3    NaN    5.8    6.7

Preenchendo valores ausentes

A invés de descartar linhas e colunas com campos ausentes podemos preencher estas lacunas. df.fillna(const) substitui campos NA com o valor único const. Um dicionário {coluna:valor} pode ser passado contendo constantes diferentes para cada coluna. Observando que df.mean() retorna uma Series com as médias de cada colunas, podemos usar df.fillna(df.mean()) para preencer NAs de cada coluna com essa média. Também podemos passar o parâmetro df.fillna(method='ffill') para preencher cada NA com o valor que o antecede na coluna. df.fillna(method='bfill') preenche NAs com o valor que o segue.

» # criando um df de teste com campos NA
» df = pd.DataFrame(np.random.randn(4, 3))
» df.iloc[0:3, 1] = NA
» df.iloc[1:3, 2] = NA

» df
↳             0           1            2
  0    0.615016         NaN    -0.860821
  1    1.195041         NaN          NaN
  2   -0.110482         NaN          NaN
  3    1.837690    1.569459     0.891858

» # preenche NAs com 0
» df.fillna(0)
↳             0           1           2
  0    0.615016    0.000000   -0.860821
  1    1.195041    0.000000    0.000000
  2   -0.110482    0.000000    0.000000
  3    1.837690    1.569459    0.891858

» # preenche coluna 1 com 10, coluna 2 com 20
» df.fillna({1:10, 2:20})
↳             0            1            2
  0    0.615016    10.000000    -0.860821
  1    1.195041    10.000000    20.000000
  2   -0.110482    10.000000    20.000000
  3    1.837690     1.569459     0.891858

» df.fillna(method='ffill')
↳             0          1            2
  0    0.615016        NaN    -0.860821
  1    1.195041        NaN    -0.860821
  2   -0.110482        NaN    -0.860821
  3    1.837690   1.569459     0.891858

» df.fillna(method='bfill')
↳             0           1           2
  0    0.615016    1.569459   -0.860821
  1    1.195041    1.569459    0.891858
  2   -0.110482    1.569459    0.891858
  3    1.837690    1.569459    0.891858

» df.fillna(method='bfill', limit=2)
» df.mean()
↳ 0    0.884316
  1    1.569459
  2    0.015519

» df.fillna(df.mean())
↳             0           1           2
  0    0.615016         NaN   -0.860821
  1    1.195041    1.569459    0.891858
  2   -0.110482    1.569459    0.891858
  3    1.837690    1.569459    0.891858

Vemos que df.fillna(method='ffill') não substituiu valores nas linhas 0, 1, 2 da coluna 1 pois nenhum valor os antecede. Nesse caso teríamos que usar method='bfill', ou outra forma de preencher o campo vazio.

Substituições com dataframe.replace()

O método df.replace() substitui valores específicos em uma Series ou dataframe. Por ex., suponha que temos uma Series de valores positivos e a inserção de negativos foi convencionada para indicar valores ausentes. Podemos alterar esses valores usando df.replace(), lembrando que nenhuma das formas abaixo altera a Serie original, a menos que inplace=True seja usado.

» serie = pd.Series([12,-2, 34, -1])
» serie
↳ 0    12
  1    -2
  2    34
  3    -1

» serie.replace(-2, -90)
↳ 0    12
  1   -90
  2    34
  3    -1

» serie.replace([-2,-1], [20,10])
↳ 0    12
  1    20
  2    34
  3    10

» serie.replace(-1, NA)
↳ 0    12.0
  1    -2.0
  2    34.0
  3     NaN

Claro que df.replace() pode ser usado para substituir um valor específico por valores calculados, usando métodos mais sofisticados de avaliação.

Em um dataframe df.replace(lista1, lista2) pode ser usado para substituir valores da lista1 pelos da lista2 (que deve ter o mesmo tamanho). df.replace(lista, escalar) substitui todos os valores em lista pelo escalar e df.replace(dicionario) substitui as chaves pelas valores no dicionário.

» df = pd.DataFrame({'a':[9,56,67], 'b':[33,55,66], 'c':[63,69,67], 'd':[2,3,9]})
» df
↳       a     b     c    d
  0     9    33    63    2
  1    56    55    69    3
  2    67    66    67    9

» df.replace(9, 100)
↳        a     b     c     d
  0    100    33    63     2
  1     56    55    69     3
  2     67    66    67   100

» df.replace([9, 55, 67], 0)
↳      a     b     c    d
  0    0    33    63    2
  1   56     0    69    3
  2    0    66     0    0

» df.replace([9, 55, 67], [1,2,3])
↳      a     b     c    d
  0    1    33    63    2
  1   56     2    69    3
  2    3    66     3    1

» df.replace({9:-9, 33:-33})
↳       a      b     c    d
  0    -9    -33    63    2
  1    56     55    69    3
  2    67     66    67   -9

Análise de outliers

Em qualquer processo de tomada de medidas ou coleta de dados existem restrições à precisão obtida. Mas, além da precisão restrita, é frequente existirem dados muito fora de qualquer curva esperada. Esses são os chamados pontos fora da curva ou outliers e geralmente são descartados. Os critérios de decisão sobre quais pontos são outliers dependem do modelo que se quer tratar.

No pandas podemos encontrar valores que estão acima ou abaixo de um certo limite.

Lembrando que np.random.randn(M, p) retorna um array de p colunas, cada uma com M valores, retirados aleatoriamente de uma distribuição normal com média 0 e variância 1, começamos por coletar um dataframe para testes.

Considerando os máximos e mínimos exibidos, vamos estabelecer arbitrariamente que valores afastados acima de 3 da média do conjunto são outliers. Isso quer dizer que consideraremos os pontos com |x| > 3 como outliers (onde |x| significa valor absoluto de x). Uma das possibilidades consiste em substituir valores não aceitáveis por np.nan e depois usar uma das formas de fill para preencher esses campos.

» dados = pd.DataFrame(np.random.randn(1000, 4))
» # são os valores mínimo e máximo desse dataframe
» dados.min().min(), dados.max().max()
↳ (-3.7113843289590496, 3.480659301328407)

» # substituimos |x| > 3 por np.nan
» dados[np.abs(dados) > 3] = np.nan
» dados.describe()    # (1) visualização do dataframe (alguns campos exibidos)
↳                   0             1             2            3
  count    996.000000    997.000000    998.000000   996.000000
  mean       0.086548      0.021479     -0.046291     0.019611
  min       -2.772219     -2.860741     -2.763174    -2.644022
  max        2.763864      2.849207      2.955914     2.905516

» dados = dados.fillna(method='bfill')
» dados.describe()    # (2) visualização do dataframe (alguns campos exibidos)
↳                    0              1              2              3
  count    1000.000000    1000.000000    1000.000000    1000.000000
  mean        0.089292       0.021845      -0.046568       0.020410
  min        -2.772219      -2.860741      -2.763174      -2.644022
  max         2.763864       2.849207       2.955914       2.905516

No primeiro uso de describe a contagem count mostra que existem linhas com campos nulos para cada coluna. Após a operação de fill todos os campos são numéricos.

Removendo linhas duplicadas

Para remover linhas duplicadas em um dataframe usamos df.drop_duplicates(). Valores duplicados em apenas uma coluna podem ser removidos com df.drop_duplicates('nomeColuna'), ou em várias colunas, passando-se uma lista df.drop_duplicates(['col1',..., 'coln']). Por default a primeira linhas, entre as duplicadas é mantida. Para manter a última usamos df.drop_duplicates('coluna', keep='last').

» # remoção de linhas duplicadas
» dic ={'col1': ['vaca', 'vaca', 'pato','pato'], 'col2': [1, 3, 4, 4]} 
» df = pd.dfFrame(dic)
» df
↳      col1    col2
  0    vaca    1
  1    vaca    3
  2    pato    4
  3    pato    4

» df.duplicated()           # retorna uma Series mostrando linhas duplicadas
↳ 0    False
  1    False
  2    False
  3     True

» df.drop_duplicates()
↳      col1   col2
  0    vaca      1
  1    vaca      3
  2    pato      4

» df.drop_duplicates('col1')
↳      col1  col2
  0    vaca     1
  2    pato     4

» df.drop_duplicates('col1', keep='last')
↳      col1   col2
  1    vaca     3
  3    pato     4

No atual estado de Pandas não é possível fazer a remoção de duplicadas sobre colunas. Para isso obtenha a transposta do dataframe, remova linhas duplicadas e o transponha novamente.

Transformações sobre elementos de um dataframe

Um restaurante faz uma lista de aquisição de produtos, descrevendo o ítem e quantas unidades devem se adquiridas.

» compra = {'produto':['leite', 'manteiga', 'laranja', 'arroz'],
            'quantos':[15, 40,50, 30]} 
» dfComprar = pd.DataFrame(compra)
» dfComprar
↳      produto  quantos
  0      leite       15
  1   manteiga       40
  2    laranja       50
  3      arroz       30

Mais tarde o gerente pede que os produtos sejam classificados como veganos ou não. Para isso podemos usar o método Series.map(dict) que transforma cada elemento usando-o como chave e retornando o valor no dicionário. Construímos um mapeamento entre produto e S/N, conforme o produto seja ou não vegano.

» veg = {'leite':'N', 'manteiga':'N', 'laranja':'S', 'arroz':'S'}
» # dfComprar['produto'] é uma Series e
» dfComprar['produto'].map(lambda x: vegano[x])
↳ 0    N
  1    N
  2    S
  3    S

» # inserindo esse serie em uma nova coluna do df
» dfComprar['vegano'] = dfComprar['produto'].map(veg)
» dfComprar
↳      produto   quantos   vegano
  0      leite        15        N
  1   manteiga        40        N
  2    laranja        50        S
  3      arroz        30        S

» # o mesmo resultado seria obtido com a função lambda 
» dfComprar['vegano']=dfComprar['produto'].map(lambda x: vegano[x])

Compartimentação e discretização

Compartimentação e discretização, (Binning e Discretization ) é o processo de particionamento de dados em faixas especificadas. Os compartimentos (faixas ou bins) são representados por variáveis categóricas, que são variáveis que podem assumir apenas um número discreto e limitado de valores, geralmente fixo. Elas estão associadas à propriedades qualitivas do sistema que se observa e podem satisfazer ou não algum critério de ordenamento.

Por ex., suponha que temos um estudo de qualquer natureza centrada sobre indivíduos onde o sexo e a faixa etária são relevantes para as conclusões que se procura obter. O sexo dos indivíduos (digamos que divididos em F = feminino, M = masculino, O = outros) não pode ser ordenado. Mas as faixas etárias são ordenáveis. Dividimos a população estudada em faixas ou bins. Sabendo que todos os participantes são maiores de idade e nenhum tem mais de 98 anos de idade usamos as faixas separadas pelas idades: 18, 34, 50, 66, 82, 98 anos.

» faixas = [18, 34, 50, 66, 82, 98]                            # definição dos intervalos de idade
» idades = [25, 18, 59, 39, 68, 26, 73, 63, 56, 84]            # idade dos indivíduos no estudo
» categorias = pd.cut(idades, faixas)
» categorias
↳ [(18.0, 34.0], NaN, (50.0, 66.0], (34.0, 50.0], (66.0, 82.0], (18.0, 34.0],
   (66.0, 82.0], (50.0, 66.0], (50.0, 66.0], (82.0, 98.0]]
  Categories (5, interval[int64]): [(18, 34] < (34, 50] < (50, 66] < (66, 82] < (82, 98]]

» # o objeto categorias é do tipo Categorical
» type(categorias)
↳ pandas.core.arrays.categorical.Categorical

» categorias.categories
↳ IntervalIndex([(18, 34], (34, 50], (50, 66], (66, 82], (82, 98]],
                closed='right', dtype='interval[int64]')

# O método pd.value_counts(categorias) fornece uma contagem para cada valor existente:
» pd.value_counts(categorias)
↳ (50, 66]    3
  (18, 34]    2
  (66, 82]    2
  (34, 50]    1
  (82, 98]    1

» # as colunas são formadas por
» pd.value_counts(categorias).index[0],  pd.value_counts(categorias)[0]
↳ (Interval(50, 66, closed='right'), 3)

» nomes_faixas = ['garoto','adulto','semi-novo','vô','matusa']
» categorias = pd.cut(idades, faixas, labels=nomes_faixas)
» categorias
↳  ['garoto', NaN, 'semi-novo', 'adulto', 'vô', 'garoto', 'vô', 'semi-novo', 'semi-novo', 'matusa']
   Categories (5, object): ['garoto' < 'adulto' < 'semi-novo' < 'vô' < 'matusa']
» pd.value_counts(categorias)
↳ semi-novo    3
  garoto       2
  vô           2
  adulto       1
  matusa       1

» # podemos transformar esse objeto em um dataframe
» dfCont = pd.DataFrame(pd.value_counts(categorias))

» # reordenar índices
» dfConf = dfCont.reindex(['garoto', 'adulto', 'semi-novo', 'vô', 'matusa'])
» dfConf
↳             0
  garoto      2
  adulto      1
  semi-novo   3
  vô          2
  matusa      1

As faixas numéricas são estabelecidas em intervalos do tipo (a, b] < (b, c] … representando intervalos abertos no limite inferior e fechados no superior. Isso significa que a não está no primeiro intervalo, mas b está. Para alterar esse comportamento usamos o parâmetro pandas.cut(...,right=False).

Podemos informar em quantas faixas queremos dividir os dados, ao invés de passar explicitamente essas faixas. Nesse caso o método pandas.cut(dados, n, precision=p) calculará n intervalos iguais baseados nos valores máximos e mínimos dos dados. precision=p determina a precisão decimal das faixas.

» # array com 20 numeros aleatórios    
» dados = np.random.rand(20)*10

» dados.min(), dados.max()         # valores mínimo e máximo
↳ (1.0012658194039414, 9.799331139583924)

» # 3 faixas (bins)
» picado = pd.cut(dados, 3, precision=2)
» pd.value_counts(picado)
↳ (6.87, 9.8]     10
  (0.99, 3.93]     7
  (3.93, 6.87]     3

Para distribuir dados em faixas baseadas em quantis usamos o método pandas.qcut(dados, n), onde n é o número de partes na partição. Intervalos de quantis customizados podem ser conseguidos passando-se uma lista em pandas.qcut(dados, lista).

» data = np.random.randn(1000)          # 1000 números aleatórios
» categorias = pd.qcut(data, 4)         # distribui em quartis
» pd.value_counts(categorias)
↳ (-3.0309999999999997, -0.683]    250
  (-0.683, 0.0106]                 250
  (0.0106, 0.702]                  250
  (0.702, 3.196]                   250

» # intervalos de quantis customizados
» pd.value_counts(pd.qcut(data, [0, 0.1, 0.5, 0.9, 1.]))
↳ (-1.223, 0.0106]                 400
  (0.0106, 1.301]                  400
  (-3.0309999999999997, -1.223]    100
  (1.301, 3.196]                   100

Permutações aleatórias

Permutações entre as linhas (ou colunas) de um dataframe são obtidas com dataframe.take(arr), onde arr é um array com a ordem dos índices desejada. Se essa ordem for “sorteada” o dataframe fica com linhas em ordem “aleatoria”. Para reordenar colunas usamos axis=1. dataframe.sample(n) seleciona n linhas do dataframe, sem repetições (n < dataframe.shape[0]) e dataframe.sample(n, replace=True) retorna n linhas do dataframe que podem ser repetidas (como em um sorteio com reposição dos elementos sorteados).

» # dataframe de teste    
» df = pd.DataFrame(np.arange(16).reshape((4, 4)))
» df
↳      0    1    2    3
  0    0    1    2    3
  1    4    5    6    7
  2    8    9   10   11
  3   12   13   14   15

» sorteio = np.random.permutation(4)   # permutação aleatória de 0, 1, 2 e 3
» sorteio
↳ array([3, 2, 0, 1])

» df.take(sorteio)                     # dataframe na ordem de linhas sorteadas
↳       0     1     2     3
  3    12    13    14    15
  2     8     9    10    11
  0     0     1     2     3
  1     4     5     6     7

» df.take(sorteio, axis=1)             # dataframe na ordem de colunas sorteadas
↳       3     2     0     1
  0     3     2     0     1
  1     7     6     4     5
  2    11    10     8     9
  3    15    14    12    13

» df.sample(n=2)                       # 2 linhas selecionadas aleatoriamente
↳       0     1     2     3
  1     4     5     6     7
  3    12    13    14    15

» df.sample(n=2, axis=1)               # 2 colunas selecionadas aleatoriamente
↳      3    1
  0    3    1
  1    7    5
  2   11    9
  3   15   13

» df.sample(n=4, replace=True)        # 4 linhas selecionadas aleatoriamente, com reposição
↳      0    1    2    3
  0    0    1    2    3
  0    0    1    2    3
  1    4    5    6    7
  0    0    1    2    3

O mesmo dataframe obtido com df.take(sorteio) poderia ser conseguido com df.iloc[sorteio].

Indicador de computação, variáveis fictícias

Na estatística, econometria e aprendizado de máquina uma variável fictícia (variável dummy ) é uma representação de um efeito categórigo assumindo apenas os valores 0 ou 1 para indicar presença ou ausência de alguma forma de caracterização. Elas podem ser consideradas como representações numéricas de aspectos qualitativos. Um exemplo simples seria a representação da classificação de uma conta bancária como poupança (0) ou conta corrente (1).

Uma variável categórica pode ser transformada em uma matriz dummy ou de indicadores. Se uma series (uma coluna de um dataframe) possui p valores distintos podemos obter um dataframe com o mesmo número de colunas, cada uma contendo apenas 0 ou 1. Para isso usamos o método pandas.get_dummies(Series) que retorna um dataframe marcando as posições onde cada um dos p valores ocorrem. Um prefixo pode ser acrescentado aos nomes das colunas com pandas.get_dummies(Series, prefix='p').

Por ex., em uma pesquisa foi marcado, para cada indivíduo participante, o campo sexo = F (feminino), M (masculino), O (outros).

» df = pd.DataFrame({'individuo': ['Fulano', 'Beltrano', 'Cicrano', 'Deltrano', 'Cruciano', 'Marciano'],
                     'sexo': ['H','H','H','F','O','F']})
» df
↳      individuo   sexo
  0       Fulano      H
  1     Beltrano      H
  2      Cicrano      H
  3     Deltrano      F
  4     Cruciano      O
  5     Marciano      F

» # categorizando a coluna 'sexo'
» pd.get_dummies(df['sexo'])
↳      F    H    O
  0    0    1    0
  1    0    1    0
  2    0    1    0
  3    1    0    0
  4    0    0    1
  5    1    0    0

» # inserindo um prefixo (no nome das colunas)
» pd.get_dummies(df['sexo'], prefix='sexo').head(2)
↳    sexo_F  sexo_H  sexo_O
   0      0       1       0
   1      0       1       0

Muitas vezes os dados devem ser manipulados e preparados para uma devida categorização. Suponha que temos uma lista de autores, cada um associado a um ou mais gêneros literários separados por |. Queremos uma listagem de autores versus gêneros, marcando em qual gênero cada um escreve.

» # importamos de qualquer fonte o seguinte dataframe:
» dfAutores
↳       autor                genero
  0   Antonio          poesia|conto
  1      José               romance
  2     Marco      ficção|biografia
  3     Pedro          poesia|conto

» # cada autor está associado a um ou mais gêneros
» genero = dfAutores.genero
» autores = dfAutores.autor
» # as duas séries têm o mesmo comprimento (len(autores) = len(generos) = 4, 4

Criamos uma lista vazia e a preenchemos com todos os gêneros, quebrando os campos em |. Depois usamos pandas.unique(lista) para conseguir um array com os gêneros, sem repetições, como em um conjunto (set).

» lista = []
» for t in genero:
»     lista.extend(t.split('|'))
» unicos = pd.unique(lista)
» unicos
↳ array(['poesia', 'conto', 'romance', 'ficção', 'biografia'], dtype=object)

Em seguida criamos um dataframe de zeros com os autores nas colunas e gêneros nas linhas.

» dfZero = pd.DataFrame(np.zeros((len(unicos),len(autores))), index=unicos, columns=autores).astype(int)
» dfZero          # estado inicial de dfZero
↳ autor    Antonio    José    Marco    Pedro
  poesia         0       0        0        0
  conto          0       0        0        0
  romance        0       0        0        0
  ficção         0       0        0        0
  biografia      0       0        0        0

# preenchemos esse dataframe
» for i in range(len(unicos)):
»     for k in range(len(genero)):
»         if unicos[i] in genero[k]:
»             dfZero.iloc[i,k] = 1
            
» dfZero    # estado final de dfZero
↳ autor    Antonio    José    Marco    Pedro
  poesia         1       0        0        1
  conto          1       0        0        1
  romance        0       1        0        0
  ficção         0       0        1        0
  biografia      0       0        1        0

O duplo loop sobre a lista de gêneros únicos, unicos, e a lista original de gêneros genero faz a verificação se um dos generos está em genero1|genero2…. Por exemplo, na linha 3, coluna 2 temos:

» unicos[3], genero[2],  unicos[3] in genero[2]
↳ ('ficção', 'ficção|biografia', True)

Se o resultado é verdadeiro o dataframe terá o campo correspondente trocado para 1. Os demais permanecem com o valor 0. O dataframe final é o resultado desejado.

Tratamento de campos de texto

Operações com strings são também vetorializadas no pandas. No ex. usamos os códigos telefones dos países: 55-Brasil, 47-Noruega, 52-México. Construímos duas séries e as concatenamos em um dataframe, df = pd.concat([serie, srPais], axis=1).

» lista = ['055-11-12345678', '047-21-87654321', '055-11-13579135', '052-78-45665412']
» serie = pd.Series(lista)
» serie
↳ 0    055-11-12345678
  1    047-21-87654321
  2    055-11-13579135
  3    052-78-45665412

» # booleano, linhas que contém '-11-'
» serie.str.contains('-11-')
↳ 0     True
  1    False
  2     True
  3    False

» # linhas que contém '-11-'
» serie[serie.str.contains('-11-')]
↳ 0    055-11-12345678
  2    055-11-13579135

» # lista com os códigos dos países
» codigos = [x.split('-')[0] for x in lista]
» codigos
↳ ['055', '047', '055', '052']

» # dicionário para conversão código ⇒ país
» pais = {'055':'Brasil', '047':'Noruega', '052':'México'}
» srPais = pd.Series([pais[x] for x in codigos])      # veja comentário †
» srPais
↳ 0     Brasil
  1    Noruega
  2     Brasil
  3     México

» # juntamos as duas series em um dataframe
» df = pd.concat([serie, srPais], axis=1)
» df = df.rename(columns = {0:'telefone', 1:'pais'})

» df
↳             telefone       pais
  0    055-11-12345678     Brasil
  1    047-21-87654321    Noruega
  2    055-11-13579135     Brasil
  3    052-78-45665412     México

» # nome do país começado com 'No'
» df[df['pais'].str.startswith('No')]
↳             telefone       pais
  1    047-21-87654321    Noruega

» # acrescenta campo com 3 primeiras letras do nome
» df['abreviado'] = df['pais'].str[:3]
» df
↳             telefone      pais    abreviado
  0    055-11-12345678    Brasil          Bra
  1    047-21-87654321   Noruega          Nor
  2    055-11-13579135    Brasil          Bra
  3    052-78-45665412    México          Méx

(): A linha srPais = pd.Series([pais[x] for x in codigos]) (uma compreensão de lista) percorre os valores em codigos e os usa como chaves no dicionário pais, retornando seus valores.

🔺Início do artigo

Bibliografia

  • McKinney, Wes: Python for Data Analysis, Data Wrangling with Pandas, NumPy,and IPython
    O’Reilly Media, 2018.

Consulte bibliografia completa em Pandas, Introdução neste site.

Nesse site: