← Voltar pra playlist

NumPy e Vetorização

Meme "o protetor silencioso": um soldado rotulado como Álgebra Linear, Estatística, Cálculo e Teoria da Probabilidade toma tiro atrás de tiro no lugar de um "ML Newbie" que dorme tranquilo numa cama, sem fazer ideia de nada disso

Bora fazer uma pausa na trilogia. Esse post não tem modelo novo pra treinar, não tem custo pra calcular, não tem ladeira pra descer. É um post de ferramenta, tipo parar no posto pra trocar o pneu carequinha antes dele estourar na estrada. Ele existe porque a partir da Semana 2 do curso o modelo ganha várias features de uma vez (tamanho, número de quartos, idade da casa, tudo junto), e a essa altura fazer conta com laço for deixa de ser viável. Bora entender a ferramenta que resolve isso antes de precisar dela de verdade.

Lista Python funciona, então por que trocar

Python já vem com listas, que guardam número igual a array. Então por que a comunidade inteira de ML usa uma biblioteca à parte pra isso? Resposta curta: memória.

Uma lista Python guarda cada número como um objeto separado, espalhado pela memória, com um monte de informação extra grudada nele (tipo, contador de referência, etc). Fazer conta em cima disso significa o processador ficar pulando de endereço em endereço, desembrulhando cada objeto pra pegar o valor de dentro. Já um array guarda os números crus, do mesmo tipo, um do lado do outro, num bloco contínuo de memória. O processador consegue processar um monte deles de uma vez, com instruções especiais que existem exatamente pra isso.

Essa diferença de layout de memória é o motivo de verdade por trás da velocidade do NumPy. Não é mágica, é engenharia de baixo nível.

Vetor: a peça básica

Um vetor é um monte de números organizados em ordem, com todos do mesmo tipo. Na notação do curso, vetor é letra minúscula em negrito, tipo x\mathbf{x}. O tamanho de um vetor chama , escrito (n,)(n,) pra um vetor de nn elementos. Repara na vírgula sozinha ali dentro dos parênteses: ela é o jeito do Python dizer "isso é uma tupla de um elemento só", e é fácil confundir (n,)(n,) com (n,1)(n,1) mais pra frente, então já fica de olho nisso.

import numpy as np

a = np.zeros(4)
print(f"np.zeros(4): a = {a}, shape = {a.shape}, dtype = {a.dtype}")
# a = [0. 0. 0. 0.], shape = (4,), dtype = float64

a = np.array([5, 4, 3, 2])
print(f"np.array: a = {a}, shape = {a.shape}, dtype = {a.dtype}")
# a = [5 4 3 2], shape = (4,), dtype = int64

Um detalhe que me pegou de primeira viagem: um único valor com ponto decimal na lista já é suficiente pra o array inteiro virar float64. Faz sentido, já que todo elemento do array precisa ser do mesmo tipo.

Indexação e fatiamento

Indexar (a[2]) pega um elemento, enquanto fatiar (a[2:7]) pega um pedaço. As regras são as mesmas que listas Python: conta começa em zero, índice negativo conta do fim (a[-1] é o último elemento), e o fim do fatiamento não entra (a[2:7] pega os índices 2, 3, 4, 5 e 6, cinco elementos, não seis).

a = np.arange(10)          # [0 1 2 3 4 5 6 7 8 9]

print(a[2])                 # 2, um elemento pego = um escalar
print(a[-1])                 # 9, último elemento
print(a[2:7:1])              # [2 3 4 5 6], do índice 2 até o 6
print(a[3:])                  # [3 4 5 6 7 8 9], do índice 3 até o fim
print(a[:3])                   # [0 1 2], do início até o índice 2

Operações sem laço nenhum

Aqui mora a parte boa. Operações de vetor rodam em cima do array inteiro de uma vez, sem você escrever nenhum for:

a = np.array([1, 2, 3, 4])

print(-a)          # [-1 -2 -3 -4], troca o sinal de todo mundo de uma vez
print(np.sum(a))   # 10, soma tudo
print(a ** 2)       # [1 4 9 16], eleva cada elemento ao quadrado
print(5 * a)          # [5 10 15 20], multiplica cada elemento por 5

Repara no último exemplo: o 5 era só um número solto, mas o NumPy "esticou" ele sozinho pra combinar com os 4 elementos do array. Isso tem nome, , e vai aparecer bastante daqui pra frente.

Isso é a ideia central desse lab inteiro: você descreve a operação sobre o array inteiro, e deixa o NumPy decidir como aplicar em cada elemento por baixo do capô. Se você reparar que tá escrevendo um for pra percorrer um array, provavelmente existe uma versão vetorizada equivalente esperando pra ser usada.

Produto escalar: a operação que sustenta a regressão linear

O (dot product) é a operação mais importante desse lab, porque é literalmente o que vai substituir aquele w * x[i] + b que a gente escreveu com laço nos posts anteriores, quando o modelo ganhar várias features.

ab=i=0n1aibi\mathbf{a} \cdot \mathbf{b} = \sum_{i=0}^{n-1} a_i b_i

Multiplica par a par, soma tudo. Simples assim. Antes de usar a versão pronta do NumPy, escrevi a minha, só pra deixar claro o que tá rolando por dentro:

def my_dot(a, b):
    x = 0
    for i in range(a.shape[0]):
        x = x + a[i] * b[i]
    return x

a = np.array([1, 2, 3, 4])
b = np.array([-1, 4, 3, 2])

print(my_dot(a, b))       # 24
print(np.dot(a, b))       # 24, mesmo resultado, função pronta do NumPy

A diferença de velocidade, ao vivo no seu navegador

O notebook original testa isso com arrays de 10 milhões de elementos, comparando np.dot (vetorizado) contra um laço for escrito à mão, em Python. O ganho lá é de dezenas a centenas de vezes.

Aqui embaixo você pode rodar um teste parecido, só que no seu próprio navegador, agora, com JavaScript. Só que preciso ser honesto com você sobre uma coisa antes: o navegador não vai mostrar um ganho de 100 vezes. E não é porque o teste tá quebrado.

O motor JavaScript do seu navegador (o V8, se você usa Chrome ou Edge) já otimiza um laço for simples de um jeito bem parecido com o que ele faz pra métodos prontos tipo .reduce(). Quando eu comparei "laço" contra ".reduce()" no mesmo tipo de array, deu um empate quase técnico, e isso era esperado, não bug do teste.

A comparação que de fato mostra uma diferença real e honesta é outra: Array comum do JavaScript (que guarda os números meio "embrulhados", parecido com a lista Python que a gente comentou lá em cima) contra Float64Array (memória contígua, tipo único, sem embrulho). Essa é a mesma razão de fundo que faz o NumPy ser rápido, só que reproduzida aqui no navegador com um ganho menor e honesto (geralmente de 2 a 5 vezes, às vezes mais), em vez de eu fingir que dava pra recriar o número gigante do Python num ambiente que não foi desenhado pra isso.

Roda algumas vezes com tamanhos diferentes. Repara que "Array comum + laço" costuma ser visivelmente mais lento que as outras duas, e que "Float64Array + laço" e "Float64Array + .reduce()" ficam bem próximos entre si. É exatamente esse padrão que a explicação acima previu.

Matrizes: quando um vetor não basta

Matriz é um array de duas dimensões, escrito com letra maiúscula em negrito (X\mathbf{X}), com shape (m,n)(m, n). No contexto do curso a convenção é sempre a mesma: linha é exemplo de treino, coluna é feature. Uma casa por linha, um atributo (tamanho, quartos, idade) por coluna.

X = np.array([[1, 5], [2, 3], [3, 1]])   # shape (3, 2): 3 exemplos, 2 features cada

print(X.shape)      # (3, 2)
print(X[1])          # [2 3], a linha 1 inteira, vira um vetor 1-D
print(X[1, 0])         # 2, um elemento específico, vira escalar
print(X[:, 0])           # [1 2 3], a coluna 0 inteira

O detalhe que mais confunde gente nova aqui: indexar uma matriz com um índice só (X[1]) devolve um array com uma dimensão a menos, não uma matriz de uma linha só. X[1] tem shape (2,), não (1, 2). Essa pegadinha específica é a origem de boa parte dos erros de dimensão que você vai encontrar daqui pra frente no curso, e eu aprendi a grifar isso mentalmente sempre que mexo com shape.

E é justamente por causa disso que o produto escalar entra em cena de novo: quando o modelo tiver nn features, cada linha da matriz X\mathbf{X} vira um vetor de shape (n,)(n,), pronto pra fazer produto escalar direto com o vetor de pesos w\mathbf{w}, também de shape (n,)(n,). A previsão do exemplo ii vira np.dot(w, X[i]) + b, uma linha de código só, sem laço nenhum, não importa se nn é 1 ou 100.

Fechando

TópicoO que ficou estabelecido
Por que NumPymemória contígua e tipada, sem embrulho por elemento, é isso que dá velocidade
Shape(n,)(n,) é vetor, (m,n)(m,n) é matriz, e a vírgula solta em (n,)(n,) importa
Indexaçãocomeça em zero, e um índice numa matriz devolve uma dimensão a menos
Fatiamentofim é sempre exclusivo, igual range() do Python
Produto escalarnp.dot(a, b), multiplica par a par e soma, devolve um escalar
Vetorizaçãoganho real vem de memória contígua e tipo único, não de "mágica de biblioteca"

Como isso conecta com o resto da playlist: com essas ferramentas na mão, o modelo que você construiu no Lab 02 com um w * x + b escrito à mão vira np.dot(w, x) + b, funcionando pra qualquer número de features sem reescrever nada. É exatamente essa base que o curso usa pra estender tudo que você já viu (modelo, custo, gradiente descendente) pra regressão linear com várias variáveis.

Aplicação prática

Mesmo dataset real de imóveis dos posts anteriores (Housing Prices Regression, Kaggle). Esse post não treina nada, então aqui a ideia é só aplicar a mecânica de matriz/vetor em cima de nomes de coluna reais, em vez dos exemplos de brinquedo a e b.

import pandas as pd
import numpy as np

df = pd.read_csv("real_estate_dataset.csv")

X = df[["Square_Feet", "Num_Bedrooms"]].to_numpy()[:3]   # 3 primeiras casas, 2 features

print("X.shape:", X.shape)        # (3, 2): 3 exemplos, 2 features
print("X[1]:", X[1])              # a segunda casa inteira, vira vetor 1-D
print("X[:, 0]:", X[:, 0])        # a coluna Square_Feet inteira, todas as casas

Saída: X.shape: (3, 2) / X[1]: [55.15 5.] / X[:, 0]: [143.64 55.15 202.96]

# Produto escalar de verdade, com pesos arbitrários só pra ilustrar a mecânica
# (não treinamos nada nesse post, isso não é o ajuste "certo"):
w = np.array([2000, 10000])

for i in range(X.shape[0]):
    print(f"np.dot(w, X[{i}]) = {np.dot(w, X[i]):,.0f}")

Saída: np.dot(w, X[0]) = 297,280 / np.dot(w, X[1]) = 160,300 / np.dot(w, X[2]) = 455,920

Mesma conta, mesma mecânica do resto do post, só que rodando em cima de Square_Feet e Num_Bedrooms de verdade em vez de [1, 2, 3, 4]. Treinar esses pesos de verdade (achar o w\mathbf{w} que faz sentido pros dados) é conteúdo de mais pra frente no curso, com múltiplas features de uma vez.

Escolhendo as colunas da matriz, ao vivo

Lembra que X\mathbf{X} é só uma matriz, linha por exemplo, coluna por feature? Escolhe você mesmo quais duas colunas viram os eixos xx e zz do gráfico abaixo (a altura fica sempre com o preço) e gira pra sentir como cada combinação de colunas se relaciona com o preço das 50 casas:

Carregando dados reais...