sexta-feira, 30 de outubro de 2015

Usando Anaconda Python para Computação Científica

Durante o processo de pesquisa, frequentemente utilizo máquinas remotas de grande porte para executar meus códigos. Eventualmente também uso Windows e é necessário que todo meu código não só funcione como seja fácil de instalar. Uma das soluções padrão seria utilizar virtualenv para instalar pacotes somente para o meu usuário, mas além de isto não ajudar muito no caso Windows, tive diversos problemas com virtualenvs quebrando quando utilizados em máquinas diferentes. Recentemente resolvi testar uma distribuição chamada Anaconda Python que é voltada para computação científica. O Anaconda Python possui diversas vantagens em relação aos pacotes Python "padrão":

  1. A instalação padrão do Anaconda contém a maioria das bibliotecas científicas comumente usadas;
  2. Não é necessário possuir privilégios de administrador para instalar pacotes nem mexer com virtualenvs;
  3. O comando conda, incluso na distribuição, é de fácil utilização e permite instalar pacotes pré-compilados (muito útil em Windows) e diversas versões de Python ao mesmo tempo. Em muitas situações substitui ambos pip e virtualenv.
  4. Em Windows, ele já vem configurado para compilar extensões em C (ou Cython) usando o mingw (somente 32 bits, por enquanto).

É claro que também podem existir desvantagens, sendo que na minha opinião a principal é que pacotes comerciais instalados no sistema podem não funcionar imediatamente.

Instalação

A instalação é bem simples e não acho que valha muito a pena perder com ela aqui. Em Windows, é um instalador Next -> Next -> Next como todos os outros. Em Linux basta baixar o instalador e executá-lo no terminal. Tudo muito simples. Nas minhas instalações, mesmo em Windows, eu sempre escolho como destino uma pasta dentro do meu home e deixo o instalador sobrescrever o PATH para que o python padrão nos meus terminais seja o Anaconda.

Instalação de pacotes

Primeiramente, o comando pip funciona normalmente, assim como todos os outros módulos relacionados à instalação de pacotes (easy_install, setuptools, etc). O Anaconda Python vem com uma ferramenta adicional chamada conda, que permite a instalação de pacotes pré-compilados. A sintaxe do comando é a mais simples possível:

\$ conda install nome_do_pacote

Nem todos os pacotes do PyPI estão disponíveis, mas a quantidade que está é bem grande mesmo em Windows. Existem duas grandes vantagens em utilizar o conda:

  1. Pacotes enormes, como Numpy, Scipy ou Scikit-learn, ou cheios de dependências e complicados de instalar, como o Numba, podem ser instalados rapidamente, sem esperar um bom tempo pela sua compilação ou sem precisar caçar dependências manualmente;
  2. É muito mais fácil instalar pacotes que usam extensões em Windows, já que compilar pacotes nesta plataforma é sempre complicado;

Lembrando que como a instalação é normalmente feita na área do usuário, nem pip nem conda necessitam de privilégios especiais para instalar novos pacotes.

Ambientes virtuais com conda

O conda também suporta a instalação de diversas versões de Python utilizando ambientes virtuais. Um ambiente virtual é uma instalação Python feita inteiramente em um diretório. Podemos ter diversos ambientes instalados e cada um terá sua versão do Python e seus pacotes armazenados separadamente. Ao ativarmos um ambiente virtual, os comandos python, pip, etc e o caminho de busca dos pacotes são substituídos pelos caminhos do ambiente virtual. A partir da ativação, qualquer comando Python usará as bibliotecas e o interpretador do ambiente virtual. A utilização de ambientes virtuais é comum quando desenvolvemos vários projetos paralelamente, de modo que cada projeto tem seu ambiente separado com somente os pacotes necessários instalados. É prático e evita inconvenientes como versões de pacotes incompatíveis ou diferentes para cada projeto.

Para criar um ambiente virtual utilizamos

\$ conda create -n nome python=X.Y lista de pacotes desejados

E para ativá-lo

  • \$ source activate nome (Linux, MacOSX)
  • \$ activate nome (Windows)

Também podemos listar os ambientes instalados:

\$ conda info --envs

O Anaconda também disponibiliza um resumão de todos os comandos do conda que é muito útil.

Minha experiência pessoal

Trocar os meus virtualenvs pelo Anaconda Python me ajudou a migrar definitivamente para Python 3, além de acabar com o inferno de virtualenvs quebrando sozinhos quando usados em máquinas usando NFS. Também é muito conveniente instalar os pacotes rapidamente sem precisar esperar o tempo de compilação e ter a grande maioria dos pacotes que uso disponíveis na instalação padrão. E a grande vantagem de tudo é que é tudo Python, não existe nenhuma extensão ou diferença em usar o Anaconda ou a distribuição padrão. Particularmente, eu recomendo a todos que precisam de um ambiente completo rapidamente.

Bônus - IDE Spyder

A instalação padrão do Anaconda contém a IDE Spyder que é muito útil para desenvolvimento de aplicações científicas. De certa maneira, Spyder tem várias características em comum com o Matlab, mas usa Python e é Open Source. Acho que a imagem abaixo mostra claramente o poder deste ambiente. Vale a pena dar uma conferida ;)

domingo, 25 de outubro de 2015

Programação paralela em Python - parte 2

No último texto sobre programação, demos os primeiros passos na utilização do módulo multiprocessing, que permite a execução paralela de código em Python. Neste post iremos estudar mais a fundo as funcionalidades deste módulo usando como exemplo um pequeno programa de detecção de bordas em imagens.

Imagens digitais são normalmente representadas utilizando matrizes, onde a posição $(i, j)$ contém a "cor" presente no pixel correspondente da imagem. Neste texto trataremos somente de imagens em níveis de cinza. Portanto, nossas imagens serão matrizes de inteiros entre $0$ (preto) e $255$ (branco). O gradiente morfológico é uma operação muito simples que realça as bordas de uma imagem, seja ela binária ou em níveis de cinza. Para cada ponto da imagem de saída, analisamos os valores presentes em uma vizinhança de tamanho $3\times 3$ ao seu redor. O valor de saída é a diferença entre o pixel de maior valor e o de menor valor.

O código abaixo calcula o gradiente morfológico sequencialmente e mostra a imagem resultado.

In [1]:
%matplotlib inline
import numpy as np
import scipy as sp
import scipy.ndimage
import time
import matplotlib.pyplot as plt

def morpho_gradient(img, out, p):
    pi, pj = p
    minv = 255
    maxv = 0
    
    for i in range(-1, 2):
        for j in range(-1, 2):
            if img[pi+i, pj+j] < minv:
                minv = img[pi+i, pj+j]
            if img[pi+i, pj+j] > maxv:
                maxv = img[pi+i, pj+j]
    out[pi, pj] = maxv - minv

def versao_serial(img):
    out = np.zeros(img.shape, img.dtype)
    for i in range(1, img.shape[0]-1):
        for j in range(1, img.shape[1]-1):
            morpho_gradient(img, out, (i, j))
    return out

img = sp.misc.lena()
start = time.clock()
out = versao_serial(img)
print('Versao serial:', time.clock() - start)
plt.figure(figsize=(10, 10))
plt.subplot(121)
plt.gray()
plt.imshow(img)
plt.subplot(122)
plt.gray()
plt.imshow(out)
Versao serial: 2.75
Out[1]:
<matplotlib.image.AxesImage at 0x7f52d78bbda0>

Seguindo a nossa receita anterior, podemos paralelizar os dois for da versao_serial. Para facilitar a passagem de argumentos para nossa função iremos usar os módulos functools e itertools.

Como vimos anteriormente, a função chamada pelo Pool.map só recebe um argumento. Podemos usar a função partial do módulo functools para criar uma versão de morpho_gradient cujos parâmetros img e out são fixos. A função chama-se partial pois ela aplica parcialmente a função.

Para criar a lista de pontos a serem processados usamos o módulo itertools, que permite criar diversos tipos de iteradores. No nosso caso, queremos o produto cartesiano entre os pontos horizontais e verticais da imagem. A função itertools.product realiza exatamente este trabalho. Este módulo contém diversas funções úteis e vale a pena uma olhada na documentação para conhecê-lo melhor.

A nossa primeira tentativa de paralelização está abaixo.

In [2]:
import multiprocessing as mp
import functools
import itertools

def versao_paralela(img):
    out = np.zeros(img.shape, img.dtype)
    p = mp.Pool()
    pi = range(1, img.shape[0]-1)
    pj = range(1, img.shape[1]-1)
    point_list = itertools.product(pi, pj)
    par = functools.partial(morpho_gradient, img, out)
    p.map(par, point_list)
    p.close()
    p.join()
    
    return out

start = time.clock()
out2 = versao_paralela(img)
print('Versao paralela:', time.clock() - start)
plt.figure(figsize=(10, 10))
plt.subplot(121)
plt.gray()
plt.imshow(img)
plt.subplot(122)
plt.gray()
plt.imshow(out)
Versao paralela: 1.3299999999999992
Out[2]:
<matplotlib.image.AxesImage at 0x7f5328a5ce80>

Surpreendentemente (ou não), esta versão não é muito mais rápida que a serial e dependendo do ambiente de execução pode ser até mais lenta. A razão disto é que, da mesma maneira do exemplo anterior, fazemos uma cópia de img e out para cada processo do Pool. No exemplo anterior, cada elemento da lista era diferente e esta cópia realmente é necessária, mas neste caso todos os processos leem img e cada processo escreve em uma posição $(i,j)$ diferente de out.

Objetos "normais" de Python, como listas, dicionários e até arrays do Numpy, não são "compartilháveis" automaticamente. Todo objeto contém informações específicas do processo em que foram criadas e que podem se tornar inválidas se manipuladas paralelamente por outros processos. É preciso, portanto, utilizar versões destes objetos que suportem a utilização por mais de um processo.

O módulo multiprocessing.sharedctypes contém, basicamente, dois tipos de objetos com nomes bastante descritivos: Array e Value. Quando criamos um objeto destes tipos alocamos sua memória em uma área especial que pode ser acessada por mais de um processo ao mesmo tempo. Porém, é necessário especificar o tipo dos objetos armazenados e, para o Array, o número de elementos desejados. Também é necessário deletá-los manualmente. Ou seja, é praticamente uma chamada às funções malloc()/free() usadas em C.

A utilização de memória compartilhada deixa o código um pouco mais longo, mas as mudanças são mais mecânicas que complexas. Primeiramente, precisamos copiar nossas imagens para a memória compartilhada (variáveis img_shared e out_shared) e criar uma nova função morpho_grad_map que cria arrays do numpy utilizando a memória compartilhada que alocamos para então chamar morpho_gradient. Note que usamos variáveis globais chamadas img e out em morpho_grad_map. Essas variáveis globais são criadas na função init_pool, que é chamada ao inicializar cada processo do Pool e que recebe as nossas variáveis compartilhadas img_shared e out_shared. As variáveis globais só existem nos processo do Pool e não irão vazar para processo principal que estamos executando. Desta maneira, todos os processos contém uma referência à nossa área de memória compartilhada e evitamos copiar dados entre os processos.

Novamente, o código da versão paralela2 está abaixo.

In [3]:
import multiprocessing.sharedctypes
from ctypes import c_int32

def morpho_grad_map(p):
    img_np = np.frombuffer(img, dtype=np.int32).reshape(shape)
    out_np = np.frombuffer(out, dtype=np.int32).reshape(shape)
    morpho_gradient(img_np, out_np, p)

def init_pool(img_shared, out_shared, shape_):
    global img, out, shape
    img = img_shared
    out = out_shared
    shape = shape_

def versao_paralela2(img):
    out_shared = mp.sharedctypes.RawArray(c_int32, np.zeros(img.shape, np.int32).flat)
    img_shared = mp.sharedctypes.RawArray(c_int32, img.flat)

    p = mp.Pool(initializer=init_pool, initargs=(img_shared, out_shared, img.shape))

    pi = range(1, img.shape[0]-1)
    pj = range(1, img.shape[1]-1)
    point_list = itertools.product(pi, pj)
    p.map(morpho_grad_map, point_list)
    p.close()
    p.join()
    
    out_np = np.zeros(img.shape, np.int32)
    out_np.flat[:] = out_shared # usar um view aqui para copiar os dados
    
    del img_shared
    del out_shared

    return out_np


start = time.clock()
out2 = versao_paralela2(img)
print('Versao paralela 2:', time.clock() - start)
plt.figure(figsize=(10, 10))
plt.subplot(121)
plt.gray()
plt.imshow(img)
plt.subplot(122)
plt.gray()
plt.imshow(out)
Versao paralela 2: 0.40000000000000036
Out[3]:
<matplotlib.image.AxesImage at 0x7f5328548d30>

Os ganhos de desempenho desta versão são significativos e mostram a capacidade do módulo multiprocessing em acelerar nosso código. Os benefícios da computação paralela, porém, não são exatamente proporcionais ao número de processadores usados e muitas vezes um código paralelo que funciona bem em um sistema pode não ser tão eficiente em outros. Por exemplo, ao rodar este post em um computador com 4 processadores a diferença entre as duas versões paralelas foi bem menor, pois o tempo gasto para criar 4 cópias é menor que o tempo necessário para criar 24.

É importante notar, porém, que usar memória compartilhada é um trabalho mais delicado. No nosso caso cada processo usou uma matriz somente leitura e escreveu em uma posição diferente da matriz de saída. Esta condição não é sempre verdadeira. Nos próximos textos veremos como utilizar travas para compartilhar objetos em situações em que pode haver conflito entre os processos.