Otimizando códigos de Data Science em Python com Vetorização

Flavio Loss
geleia
Published in
7 min readSep 28, 2020

Quando rodamos um código usando um dataset, o que estamos fazendo na verdade é iterar por todas as linhas ou colunas daqueles dados, aplicando seja lá qual função ou operação que vamos aplicar. Muito da análise de datasets no Python é feita com o Pandas, uma excelente biblioteca desenvolvida por Wes McKinney. Porém, quando estamos tratando de um volume muito alto de dados, nossas operações podem se tornar um pouco demoradas, talvez pelo alto número de linhas do conjunto de dados ou então a complexidade da operação ou função que estamos tentando aplicar.

Apesar de não ser tão rápido quanto um código otimizado em C, uma aplicação com Pandas pode ser rápida o suficiente para processar funções em um alto volume de dados, se codificada da maneira correta e utilizando algumas dicas que iremos listar aqui.

Estruturas de dados do Numpy

Primeiro precisamos compreender que quando vemos cada coluna de um data frame ao longo das linhas, estamos observando um numpy ND-array de uma dimensão. Podemos afirmar então que um vetor é um 1D-array, uma matriz seria um 2D-array, e assim por diante, alcançando dimensões além de nossa compreensão. Utilizando operações simples em ND-arrays, percebemos que os cálculos são realizados para cada elemento do vetor.

Elevando o array ao quadrado
[[ 0. 1. 4.]
[ 9. 16. 25.]
[36. 49. 64.]]
Tirando a raiz quadrada do array
[[0. 1. 1.41421356]
[1.73205081 2. 2.23606798]
[2.44948974 2.64575131 2.82842712]]

Em uma operação entre dois vetores, as operações ocorrem entre os pares equivalentes de cada parâmetro da operação.

Multiplicando os arrays
[[ 0. 10. 22.]
[ 36. 52. 70.]
[ 90. 112. 136.]]
Dividindo os arrays
[[0. 0.1 0.18181818]
[0.25 0.30769231 0.35714286]
[0.4 0.4375 0.47058824]]

Vetorização com Numpy

Entendemos a vetorização como o uso de código otimizado, pré-compilado e escrito em uma linguagem de nível baixo (C, por exemplo), que é usado para performar operações matemáticas em uma determinada sequência de dados. Para utilizar vetorização no Python, iremos substituir a iteração do for loop por operações mais rápidas que utilizam essa forma de cálculo vetorizado.

Temos que lembrar que as ND-arrays do numpy se diferem de vetores do Python na sua homogeneidade, ou seja: os arrays do numpy podem conter apenas um tipo de dado. Por exemplo, um array pode conter inteiros 8-bits ou flutuadores 32-bits, mas não a mistura dos dois. Isso contrapõe as listas do Python, onde são permitidos diferentes tipos de dados em uma mesma lista ou tupla, ou seja: pode conter um inteiro, um caractere, uma palavra e outros objetos em uma mesma lista. Isso traz um grande benefício para os arrays do numpy: sabendo com que tipo homogêneo de dado está tratando, o numpy pode delegar operações matemáticas nos elementos do array com código compilado e otimizado em C. Esse é o processo que chamamos de vetorização, e o resultado é um grande aumento na velocidade em que o código é processado, quando comparado a mesma operação em uma lista de código em Python, que por sua vez precisa analogamente verificar o tipo de dado em cada iteração na lista, já que trabalha sem restrição do conteúdo desses dados.

No código abaixo, vamos criar um array de 1 até 1 milhão e somar todos os valores dentro desse array, de duas formas diferentes: uma com vetorização e outra do modo tradicional, contabilizando o tempo de cada processo. Usando o método sum, fazemos uma operação vetorizada dentro desse array.

499999500000Tempo de execução:
--- 0.00671 segundos ---

Agora vamos usar o método de soma dos elementos da lista tradicional, iterando por cada elemento e calculando a soma.

499999500000Tempo de execução:
--- 0.33828 segundos ---

Podemos ver que a soma é processada 50 vezes mais rápido na operação vetorizada, quando comparada com a iteração tradicional. Esse experimento não deixa dúvidas que nossos cálculos vetorizados com numpy serão mais rápidos na maioria das vezes, e que devemos usar os métodos otimizados do numpy nas operações sempre que possível. Abaixo podemos ver outras funções do numpy que utilizam vetorização.

Porém, olhando para uma parte mais prática, muitas vezes estamos trabalhando com dados em estruturas mais complexas que um array do numpy. Uma grande parte da análise exploratória, manipulação e pré-processamento de tabelas e criação de features é feita nos DataFrames e series do Pandas, e seria interessante observar como otimizar códigos e iterações nessas estruturas. Vamos analisar essa parte a seguir.

Entendendo as estruturas do Pandas

Temos que lembrar, primeiramente, que as principais estruturas do Pandas são baseadas em numpy arrays. Isso já nos indica que é possível reduzir tempo de iteração de nossas estruturas, ainda mais quando somamos isso ao fato de existirem funções no Pandas construídas para operar em arrays inteiros, em vez de realizar operações por elemento.

Vamos então analisar o que são essas estruturas fundamentais do Pandas: DataFrame e Series. Um DataFrame é um array de duas dimensões com rótulos em cada eixo, ou seja, uma matriz onde cada linha tem um índice, e cada coluna possui um nome indicador. Uma única coluna dessa matriz é um Pandas Series: um array unidimensional com rótulos para as linhas e a coluna única.

Otimizando códigos no Pandas

Passando para uma parte mais prática, vamos carregar um dataset e tentar aplicar uma função customizada em colunas deste dataset, comparando o tempo de carregamento do código para diferentes métodos de aplicação dessa função. O dataset utilizado será o California Housing Prices, disponível no Kaggle. Para a função de exemplo, usaremos a fórmula de distância de Haversine. Ela pega a latitude e longitude de dois pontos, ajusta para a curvatura da Terra, e calcula uma distância em linha reta entre eles. Escrita em Python, usaremos a função da seguinte forma:

Um loop simples e ineficiente pelo DataFrame

Este é o tipo de abordagem que nunca devemos utilizar. Nessa aplicação da fórmula, simplesmente estaremos criando uma função que irá iterar sobre todos os elementos de DataFrame, localizando as colunas ‘latitude’ e ‘longitude’, aplicando a fórmula, e então adicionando o valor retornado á uma lista anteriormente vazia. Veja no código abaixo:

Tempo de execução:
--- 4.05864 segundos ---

Este é o método mais lento de aplicar uma função em colunas em um DataFrame, qualquer outro método daqui pra frente é preferível ao loop cru nessa estrutura.

Utilizando métodos do DataFrame

Alguns métodos dos objetos DataFrame e series podem nos ajudar a criar novas features usando colunas existentes, ou modificar essas colunas propriamente ditas. Por exemplo, podemos manter o for loop com o método iterrows(), que vai iterar sobre todas as linhas do DataFrame, retornando o índice da linha e um objeto contendo o conteúdo presente nesta linha. Usando iterrows() para calcular a função de Haversine, temos o código a seguir:

Tempo de execução:
--- 1.86050 segundos ---

Não é um grande avanço comparado ao loop cru pelo dataset, mas já é alguma evolução.

Com o método apply(), temos a grande vantagem de eliminar o for loop, já que esse método aplica automaticamente uma função em determinado eixo do DataFrame. Apesar desse método ainda utilizar loops, ele faz isso internamente, e de maneira mais otimizada que os métodos anteriores. Ainda nos permite adicionar modificar vários parâmetros do método, e até mesmo utilizar uma função lambda para personalizar ainda mais o código e as operações.

Tempo de execução:
--- 0.79190 segundos ---

Aqui já temos uma grande vantagem quando comparamos o tempo de execução com as soluções anteriores(com o bônus de um código mais simples e com menos linhas!), mas ainda não estamos usando a vantagem da vetorização nos arrays do Pandas. Veremos outra estratégia a seguir que utiliza esse poder.

Vetorização no Pandas

A vetorização em uma series do Pandas é a mesma que a de um array do numpy. Vamos realizar operações otimizadas no array inteiro ao invés de iterar pelos elementos e carregar cada tipo e cada cálculo por valor. O Pandas nos oferece uma quantidade bem grande de operações vetorizadas, portanto recomendo ler a documentação, que é bem completa nesse quesito. No caso da nossa função de Haversine, vetorizar a criação da nossa feature “distância” se torna bem simples, já que nossos processos dentro da função são capazes de operar em arrays. O que vamos fazer é simplesmente passar como parâmetros as duas series necessárias para calcular a distância(“latitude” e “longitude”), e isso vai permitir que o Pandas use suas operações vetorizadas, calculando simultaneamente as operações envolvendo os dois arrays.

Tempo de execução:
--- 0.01211 segundos ---

Com este último método, conseguimos uma operação 50 vezes mais rápida que com o método apply, e comparando com a iteração simples pelo dataset, temos uma performance quase 400 vezes melhor.

Pode não parecer muita coisa com este dataset, que contém apenas 17000 observações, mas com datasets cada vez maiores e funções mais complexas que a utilizada aqui, temos que começar a pensar em otimizar o código sempre que possível, tentando sempre eliminar os loops desnecessários, e criando um código mais organizado.

Em conclusão

Muitos dizem que o Pandas é uma biblioteca ineficiente quando tratamos de datasets muito grandes, e isso pode até ser verdade quando começamos a pensar em análise de Big Data. Mas na maioria dos datasets disponíveis hoje em sites como Kaggle, o Pandas pode muito bem dar conta, se utilizado de forma eficiente e aproveitando todas as suas funcionalidades. O objetivo da vetorização vai além do Pandas e do Numpy, procuramos aqui criar um código que atenda as necessidades de rápido processamento do alto volume de dados que são e serão gerados.

É preciso entender que não devemos extinguir o for loop, é uma funcionalidade incrível que ainda será muito utilizada em códigos escritos em Python(e inúmeras outras linguagens de programação), mas temos que saber quando utilizá-la, e quando deixá-la de no banco para a entrada de operações mais eficientes para o problema em questão.

--

--