NumPy e Vetorização

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 . O tamanho de um vetor chama , escrito pra um vetor de 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 com 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.
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 (), com shape . 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 features, cada linha da matriz vira um vetor de shape , pronto pra fazer produto escalar direto com o vetor de pesos , também de shape . A previsão do exemplo vira np.dot(w, X[i]) + b, uma linha de código só, sem laço nenhum, não importa se é 1 ou 100.
Fechando
| Tópico | O que ficou estabelecido |
|---|---|
| Por que NumPy | memória contígua e tipada, sem embrulho por elemento, é isso que dá velocidade |
| Shape | é vetor, é matriz, e a vírgula solta em importa |
| Indexação | começa em zero, e um índice numa matriz devolve uma dimensão a menos |
| Fatiamento | fim é sempre exclusivo, igual range() do Python |
| Produto escalar | np.dot(a, b), multiplica par a par e soma, devolve um escalar |
| Vetorização | ganho 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 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 é só uma matriz, linha por exemplo, coluna por feature? Escolhe você mesmo quais duas colunas viram os eixos e 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...