Pular para o conteúdo
Curso de NumPy

Curso de NumPy

Do primeiro array à rede neural, em três níveis, com projetos integradores e um arquivo executável por capítulo.

Por Alexsander Valente

Para quem é este material

O NumPy é a base de quase tudo o que se faz com dados em Python: o pandas, o matplotlib, o scikit-learn, o PyTorch e o JAX falam a língua dos arrays. Eu organizei estas notas em seis partes, da instalação até uma rede neural escrita só com NumPy, e cada ideia aparece primeiro como intuição, depois como código que você pode executar.

Parte Para quem O que você sabe fazer no final
Ambiente Quem nunca usou NumPy Instalar, importar e entender por que o array é mais rápido que a lista
Básico Quem está começando com dados Criar, indexar, remodelar e resumir arrays, e pensar em vetores em vez de laços
Intermediário Quem já manipula arrays e quer ir além Juntar, ordenar, tratar dados ausentes, usar álgebra linear e ler e gravar arquivos
Avançado Quem escreve código numérico para produção Entender memória, einsum, desempenho, aleatoriedade reproduzível e testes numéricos
Ecossistema Quem vai usar NumPy com outras bibliotecas Passar dados entre NumPy, pandas, matplotlib e scikit-learn sem perder o controle
Projetos Quem quer provar que aprendeu Entregar três projetos completos, com testes, um por nível

Se você nunca programou em Python, faça antes o livro curso de Python, ao menos até o capítulo 25. Aqui eu assumo que você sabe o que é uma lista, uma função e um import.

Como cada capítulo funciona

Todo capítulo segue o mesmo desenho: uma explicação curta, um trecho de código e a saída real dele. Cada bloco mostra, no topo, o arquivo e as linhas do repositório onde ele está.

  • A saída é real. Eu executei cada capítulo e copiei o que o Python imprimiu. Isso vale para o NumPy 2.x, a versão que o livro assume. Em versões 1.x algumas representações mudam, como a forma de imprimir escalares.
  • Os exercícios têm solução. Tente primeiro. A solução fica recolhida e termina com assert, então você descobre sozinho se acertou.
  • O que depende da sua máquina está marcado. Tempos de execução variam muito entre computadores, então eu mostro comparações (qual foi mais rápido) em vez de números absolutos.

Como rodar o código

O repositório tem uma pasta por parte e um arquivo por capítulo, com o nome capNN_assunto.py.

Estrutura do repositório
curso-numpy/
  README.md
  pyproject.toml
  docs/
    numpy.html
  ambiente/
  básico/
  intermediario/
  avancado/
  ecossistema/
  projetos/
    estudantes/
    imagens/
    rede_neural/
  exemplos/

Cada arquivo roda sozinho, a partir da raiz do repositório. Com o uv, sem instalar nada antes, o NumPy vem junto:

Terminal
uv run básico/cap05_criando_arrays.py

Os capítulos da parte Ecossistema usam mais bibliotecas, que o grupo ecossistema instala de uma vez:

Terminal
uv sync --group ecossistema

Qual versão este livro assume

O livro assume Python 3.12 ou superior e NumPy 2.x. Os exemplos foram executados com o NumPy 2.4. O capítulo 37 lista o que mudou da série 1.x para a 2.x, porque muito material na internet ainda usa nomes antigos.

Uma regra que eu repito neste livro

Quando você escrever um for sobre os elementos de um array, pare e pergunte: existe uma forma vetorizada? Quase sempre existe, e ela é mais curta e muito mais rápida. Boa parte do que você vai aprender aqui são as ferramentas para responder "sim, existe, e é esta".

Logo de Alexsander Valente

Alexsander Valente

Software & AI Architecture, Product Engineering e Data Engineering.

Foto de Alexsander Valente

Trabalho com Engenharia de Software, Arquitetura e Inteligência Artificial, participando de decisões técnicas que definem como sistemas são projetados, integrados, evoluídos e operados em produção.

Atuo com arquitetura de software, APIs, backend, integrações, sistemas distribuídos e plataformas de dados, além de soluções com GenAI, RAG, agentes de IA e MCP. Também trabalho com AI-Assisted Development e Harness Engineering, estruturando contexto, ferramentas e validações para tornar o desenvolvimento assistido por IA mais consistente e confiável.

Sou formado em Análise e Desenvolvimento de Sistemas, com pós-graduações em Arquitetura de Software, Inteligência Artificial e Machine Learning, Gestão da Qualidade de Software e Arquitetura de Soluções.

Mantenho uma atuação hands-on, conectando arquitetura e implementação na construção de sistemas escaláveis, resilientes e preparados para produção.

“Arquitetura e engenharia para sistemas que precisam funcionar, evoluir e permanecer confiáveis em produção.”

Capítulo 1, parte Ambiente

Por que Python virou a linguagem dos dados

Antes de escrever uma linha de NumPy, vale entender por que ele existe e por que vive em Python. A resposta é menos sobre a linguagem e mais sobre as pessoas que construíram as bibliotecas em volta dela.

Código deste capítulo: ambiente/cap01_ecossistema_python.py

Python é pequeno, e o ecossistema é grande

O Python em si não sabe treinar uma rede neural, desenhar um gráfico nem ler uma planilha. Nada disso vem embutido. O que o Python tem é uma sintaxe fácil de ler e uma facilidade enorme de chamar código escrito em outras linguagens. Foi isso que atraiu quem trabalhava com cálculo numérico: dava para escrever o miolo pesado em C ou Fortran e usá-lo por uma interface simples em Python.

O NumPy nasceu em meados dos anos 2000, da unificação de dois projetos anteriores (o Numeric e o Numarray). Todo o resto da pilha de dados foi construído em cima dele:

Biblioteca O que oferece Por que depende de arrays
NumPy O array ndarray e a matemática sobre ele É a base
pandas Tabelas com rótulos (DataFrame) Cada coluna é, por baixo, um array
Matplotlib Gráficos Recebe arrays e desenha
scikit-learn Aprendizado de máquina clássico Dados e modelos são arrays
PyTorch, JAX, TensorFlow Aprendizado profundo O "tensor" é um array com GPU e derivadas

Nenhuma dessas bibliotecas faz parte do Python. Cada uma foi escrita por pessoas, publicada de graça e mantida por uma comunidade. Dá para conferir isso no próprio interpretador:

ambiente/cap01_ecossistema_python.pylinhas 10 a 17
import importlib.util
import sys

import numpy as np

print("numpy faz parte da biblioteca padrão?", "numpy" in sys.stdlib_module_names)
print("numpy está instalado aqui?", importlib.util.find_spec("numpy") is not None)
print(type(np.add).__name__, type(np.ndarray).__name__)

Saída

numpy faz parte da biblioteca padrão? False
numpy está instalado aqui? True
ufunc type

O np.add é um objeto do tipo ufunc, uma função compilada que opera sobre arrays inteiros. O trabalho pesado acontece nesse código compilado, e o Python só orquestra. Essa é a forma saudável de pensar em toda a pilha: Python é a cola, e a velocidade vem das camadas de baixo.

Quanto Python eu preciso saber antes?

Menos do que você imagina. Variáveis, listas, laços, funções e import bastam para começar, e o resto você aprende usando. Ninguém memoriza uma biblioteca inteira. Quem trabalha com dados consulta a documentação o tempo todo, e o que importa é saber o que é possível, para saber o que procurar.

Exercício 1

Quais destes módulos vêm com o Python?

Dada a lista ["math", "numpy", "json", "pandas"], devolva só os que fazem parte da biblioteca padrão.

Ver solução
ambiente/cap01_ecossistema_python.pylinhas 22 a 25
modulos = ["math", "numpy", "json", "pandas"]
da_biblioteca_padrao = [m for m in modulos if m in sys.stdlib_module_names]
assert da_biblioteca_padrao == ["math", "json"]
print("ok")

Saída

ok

Capítulo 2, parte Ambiente

Instalando e importando o NumPy

Duas linhas deixam o NumPy pronto, e a maioria dos problemas de iniciantes vem de a instalação ter ido parar em um Python diferente do que você está usando.

Código deste capítulo: ambiente/cap02_instalacao.py

Instalar

O NumPy se instala como qualquer pacote Python, dentro de um ambiente virtual, como o livro curso de Python mostrou. Escolha a ferramenta que você usa:

Terminal
uv init meu-projeto
cd meu-projeto
uv add numpy

O uv add cria o ambiente, instala o pacote e o registra no pyproject.toml. Para executar um arquivo no ambiente do projeto, use uv run arquivo.py.

O NumPy distribui versões já compiladas (wheels) para os sistemas e versões do Python mais comuns. Por isso a instalação leva segundos e não exige compilador.

Importar, e a convenção `np`

ambiente/cap02_instalacao.pylinhas 10 a 17
import sys
from pathlib import Path

import numpy as np

print("versão 2 ou superior:", int(np.__version__.split(".")[0]) >= 2)
print("onde está o NumPy:", Path(np.__file__).parent.name)
print("este Python existe no disco:", Path(sys.executable).exists())

Saída

versão 2 ou superior: True
onde está o NumPy: numpy
este Python existe no disco: True

O apelido np não é uma regra da linguagem, mas é uma convenção tão forte que toda documentação, todo tutorial e toda resposta na internet a assumem. Usar outro apelido só faz você traduzir o que lê.

Para ver como o seu NumPy foi construído (quais bibliotecas de álgebra linear ele usa, por exemplo), existe o show_config:

ambiente/cap02_instalacao.pylinha 19
np.show_config()

Quando o import falha

O erro mais comum é instalar com o pip em um terminal e receber No module named 'numpy' no editor. Quase sempre isso significa que são dois Pythons diferentes: o do terminal recebeu o pacote, e o do editor (ou do notebook) não. Para descobrir qual Python está rodando e se ele enxerga o NumPy:

Terminal
python -c "import sys; print(sys.executable)"
python -m pip show numpy
python -c "import numpy; print(numpy.__version__, numpy.__file__)"
Sintoma Causa provável O que fazer
No module named 'numpy' no editor O editor usa outro interpretador Selecione o interpretador do .venv do projeto
No module named 'numpy' só no notebook O kernel é de outro ambiente Instale ipykernel no ambiente e escolha esse kernel
Versão inesperada Dois ambientes com versões diferentes Compare sys.executable nos dois lugares
Erro de instalação por falta de compilador Versão do Python sem wheel disponível Use uma versão do Python suportada pelo pacote

A regra que me poupa tempo

Sempre use python -m pip em vez de pip puro. Assim o pip é obrigatoriamente o do interpretador que você chamou, e o pacote cai no lugar certo.

Exercício 1

Um diagnóstico do ambiente

Escreva diagnostico() que devolva um dicionário com a versão do NumPy, a versão do Python (como tupla de dois números) e se o executável do Python existe no disco.

Ver solução
ambiente/cap02_instalacao.pylinhas 24 a 36
def diagnostico():
    return {
        "numpy": np.__version__,
        "python": sys.version_info[:2],
        "executavel_existe": Path(sys.executable).exists(),
    }


resultado = diagnostico()
assert resultado["executavel_existe"]
assert resultado["python"] >= (3, 12)
assert resultado["numpy"].split(".")[0].isdigit()
print("ok")

Saída

ok

Capítulo 3, parte Ambiente

Notebooks, scripts e o fluxo de trabalho

Análise de dados é exploração: você testa uma ideia, olha o resultado e decide o próximo passo. Os notebooks existem para esse ciclo, e têm uma armadilha que todo mundo cai uma vez.

Código deste capítulo: ambiente/cap03_notebooks.py

Script ou notebook

Um script roda de cima para baixo, do começo ao fim, toda vez. Isso é ótimo para um programa que você vai executar no servidor, mas ruim para explorar dados, porque reprocessar tudo para olhar uma coluna é lento. Um notebook é dividido em células que você executa uma de cada vez, e o resultado fica visível logo abaixo, como em um caderno de laboratório.

Opção Quando eu uso
JupyterLab Exploração local, o padrão da área
Jupyter no VS Code Notebook dentro do mesmo editor onde escrevo o resto do código
Google Colab Quando não quero instalar nada, ou preciso de GPU
Script comum Quando o código vai virar rotina, teste ou serviço

Para usar o JupyterLab em um projeto com uv, instale-o como dependência de desenvolvimento e abra:

Terminal
uv add --dev jupyterlab ipykernel
uv run jupyter lab

O kernel e a armadilha do estado escondido

Cada notebook é ligado a um kernel, o processo Python que lembra de todas as variáveis que você criou. As células não precisam rodar em ordem: você pode executar a quinta, voltar e reexecutar a segunda. É exatamente isso que causa o problema. O notebook pode mostrar um resultado que só existe por causa de uma célula que você editou depois, e o texto que você lê na tela não reproduz mais o resultado:

ambiente/cap03_notebooks.pylinhas 10 a 23
celulas = ["total = 10", "dobro = total * 2"]

kernel = {}
for celula in celulas:
    exec(celula, kernel)

celulas[0] = "total = 99"
exec(celulas[0], kernel)
print("o que a tela mostra:", kernel["dobro"])

novo_kernel = {}
for celula in celulas:
    exec(celula, novo_kernel)
print("depois de reiniciar e rodar tudo:", novo_kernel["dobro"])

Saída

o que a tela mostra: 20
depois de reiniciar e rodar tudo: 198

Eu editei a primeira célula para 99 e reexecutei só ela. O dobro na tela continuou 20, um valor que não corresponde mais ao código visível. Só ao reiniciar o kernel e rodar tudo em ordem aparece o valor verdadeiro, 198.

A regra antes de chamar um notebook de pronto

Reinicie o kernel e execute todas as células, em ordem. Se algo quebrar ou mudar, o notebook dependia de um estado escondido. Eu faço isso antes de compartilhar qualquer notebook.

O melhor dos dois mundos: células em um arquivo `.py`

O VS Code e o JupyterLab (com a extensão jupytext) entendem um arquivo Python comum com marcadores # %%: cada marcador abre uma célula. O arquivo continua sendo um script que roda inteiro, com a vantagem de diferenças legíveis no Git, o que o formato .ipynb (um JSON gigante com saídas embutidas) não dá:

exemplos/celulas.py
# %% [markdown]
# # Notas de vendas
# Cada bloco `# %%` é uma célula no VS Code e no JupyterLab (com jupytext).

# %%
import numpy as np

vendas = np.array([120.0, 80.5, 99.9, 150.0])

# %%
print(vendas.mean().round(2))

# %%
print(vendas.max())

Medir tempo no notebook

No Jupyter, a "mágica" %timeit roda o código várias vezes e devolve uma média confiável, muito melhor do que um time.time() isolado. Em um script comum, o equivalente é o módulo timeit, que o capítulo 18 usa.

Capítulo 4, parte Ambiente

Por que o NumPy é rápido

A resposta é a memória. Uma lista do Python guarda ponteiros para objetos espalhados. Um array do NumPy guarda os números lado a lado, em um bloco contínuo.

Código deste capítulo: ambiente/cap04_por_que_rapido.py

Cada elemento de uma lista é um objeto

Em Python, 7 não é só o número sete. É um objeto inteiro, com cabeçalho, contagem de referências e tipo. A lista guarda um ponteiro para cada um desses objetos, e cada objeto mora em um lugar diferente da memória. Isso permite misturar tipos em uma mesma lista, ao custo de espaço e de velocidade. O array renuncia a essa flexibilidade de propósito:

ambiente/cap04_por_que_rapido.pylinhas 10 a 21
import sys

import numpy as np

lista = list(range(1_000))
array = np.arange(1_000)

print(type(lista[0]).__name__, "ocupa", sys.getsizeof(lista[0]), "bytes cada")
print(array.dtype, "ocupa", array.itemsize, "bytes por elemento")

bytes_lista = sys.getsizeof(lista) + sum(sys.getsizeof(x) for x in lista)
print("a lista gasta mais de 3 vezes a memória do array:", bytes_lista > 3 * array.nbytes)

Saída

int ocupa 28 bytes cada
int64 ocupa 8 bytes por elemento
a lista gasta mais de 3 vezes a memória do array: True

Cada inteiro Python ocupa 28 bytes, enquanto cada elemento do array ocupa 8. E o array não tem ponteiros: os mil números estão em um bloco contínuo de 8 mil bytes.

Um tipo só, um bloco só

Como todos os elementos têm o mesmo tipo e o mesmo tamanho, o NumPy não precisa verificar o tipo de cada um nem seguir ponteiros. O processador lê a memória em sequência, o que aproveita o cache, e consegue operar em vários números por instrução. É a combinação "mesmo tipo, bloco contínuo" que permite entregar a operação inteira a código compilado.

O preço é que o array não aceita misturar. Se você tentar, o NumPy não reclama: converte tudo para um tipo comum, silenciosamente:

ambiente/cap04_por_que_rapido.pylinhas 26 a 27
misturado = np.array([7, "a", 3.2, 9])
print(misturado.dtype, misturado)

Saída

<U32 ['7' 'a' '3.2' '9']

O <U32 quer dizer "texto Unicode de até 32 caracteres". Os números viraram texto, e você não poderia mais somá-los. Esse é um erro clássico: ele não faz barulho, e só aparece mais tarde, quando uma conta dá errado.

O bloco contínuo também é visível nas propriedades do array:

ambiente/cap04_por_que_rapido.pylinha 29
print(array.flags["C_CONTIGUOUS"], array.strides)

Saída

True (8,)

O strides diz quantos bytes o NumPy avança para chegar ao próximo elemento: 8, exatamente o tamanho de um int64. O capítulo 29 explica esse detalhe, que é a base de como o NumPy fatia arrays sem copiar nada.

Medir, em vez de acreditar

A diferença de velocidade é grande o bastante para uma medição simples a mostrar. Eu repito cada medida algumas vezes e fico com a melhor, que é a menos afetada pelo ruído da máquina. Como os tempos absolutos variam de computador para computador, o que eu mostro é a comparação:

ambiente/cap04_por_que_rapido.pylinhas 34 a 51
import time


def medir(funcao, repeticoes=5):
    melhor = float("inf")
    for _ in range(repeticoes):
        inicio = time.perf_counter()
        funcao()
        melhor = min(melhor, time.perf_counter() - inicio)
    return melhor


grande_lista = list(range(1_000_000))
grande_array = np.arange(1_000_000)
t_lista = medir(lambda: [x * 2 for x in grande_lista])
t_array = medir(lambda: grande_array * 2)
print("o array foi mais rápido:", t_array < t_lista)
print("pelo menos 10 vezes mais rápido:", t_lista > 10 * t_array)

Saída

o array foi mais rápido: True
pelo menos 10 vezes mais rápido: True

Na prática, a diferença costuma ficar em dezenas de vezes. E ela cresce com o tamanho: com poucos elementos, o custo fixo de chamar o NumPy domina e o ganho some. O capítulo 18 volta a esse ponto.

Lista Python Array NumPy
Tipos Qualquer mistura Um só, fixo
Memória Ponteiros para objetos espalhados Um bloco contínuo
Operação em massa Laço do interpretador Código compilado
Redimensionar Fácil (append) Caro (cria outro array)
Quando usar Poucos itens, tipos variados Muitos números do mesmo tipo

O que "vetorizar" quer dizer

Operar sobre o array inteiro de uma vez, sem escrever o laço, é o que chamamos de vetorização. O grande_array * 2 do exemplo acima é isso: o laço existe, mas roda em código compilado. O capítulo 12 trata disso em profundidade.

Exercício 1

Quanto ocupa um array?

Escreva bytes_do_array(n, dtype) que devolva quantos bytes ocupa um array de n elementos de um tipo, usando np.dtype(...).itemsize.

Ver solução
ambiente/cap04_por_que_rapido.pylinhas 56 a 63
def bytes_do_array(n, dtype):
    return n * np.dtype(dtype).itemsize


assert bytes_do_array(1_000_000, "float64") == 8_000_000
assert bytes_do_array(1_000_000, "float32") == 4_000_000
assert bytes_do_array(1_000_000, "int8") == 1_000_000
print("ok")

Saída

ok

Capítulo 5, parte Básico

Criando arrays

Todo array nasce de duas formas: a partir de dados que você já tem, ou gerado na hora, com a forma que você pedir. O NumPy tem uma função para quase toda situação.

Código deste capítulo: básico/cap05_criando_arrays.py

A partir dos seus dados

O np.array converte listas (e listas de listas) em um array. Listas aninhadas viram dimensões:

básico/cap05_criando_arrays.pylinhas 10 a 13
import numpy as np

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

Saída

[1 2 3]
[[1 2]
 [3 4]]

Todas as linhas precisam ter o mesmo tamanho. Um array é uma grade retangular, e o NumPy recusa uma estrutura "irregular" em vez de adivinhar o que você quis dizer:

básico/cap05_criando_arrays.pylinhas 15 a 18
try:
    np.array([[1, 2], [3]])
except ValueError as erro:
    print("erro de forma irregular:", "inhomogeneous" in str(erro))

Saída

erro de forma irregular: True

Arrays de preenchimento: você dá a forma

Quando você ainda não tem os valores, mas sabe a forma, os arrays de preenchimento reservam o espaço. A forma é uma tupla, e é aí que mora o erro mais comum:

básico/cap05_criando_arrays.pylinhas 23 a 25
print(np.zeros((2, 3)))
print(np.ones((2, 3), dtype=int))
print(np.full((2, 3), 7))

Saída

[[0. 0. 0.]
 [0. 0. 0.]]
[[1 1 1]
 [1 1 1]]
[[7 7 7]
 [7 7 7]]
básico/cap05_criando_arrays.pylinhas 27 a 30
try:
    np.zeros(2, 3)
except TypeError as erro:
    print(erro)

Saída

Cannot interpret '3' as a data type

Escrevendo np.zeros(2, 3), o NumPy entende o 3 como o tipo de dado (o segundo parâmetro), e por isso a mensagem fala em "data type". A forma é um único argumento: np.zeros((2, 3)), com parênteses duplos.

O np.empty também reserva a forma, mas não inicializa a memória: o conteúdo é o que já estava lá, e não zero. Ele é um pouco mais rápido que o zeros, e só serve quando você vai sobrescrever todos os valores logo em seguida. Eu raramente o uso, porque o ganho é pequeno e o risco de ler lixo é real.

Matrizes especiais

básico/cap05_criando_arrays.pylinhas 35 a 36
print(np.eye(3))
print(np.diag([1, 2, 3]))

Saída

[[1. 0. 0.]
 [0. 1. 0.]
 [0. 0. 1.]]
[[1 0 0]
 [0 2 0]
 [0 0 3]]

O np.eye(3) é a matriz identidade (uns na diagonal). O np.diag monta uma matriz diagonal a partir de uma lista, e, se receber uma matriz, faz o caminho inverso e extrai a diagonal.

Sequências

básico/cap05_criando_arrays.pylinhas 41 a 43
print(np.arange(0, 10, 2))
print(np.linspace(0, 1, 5))
print(np.logspace(0, 2, 3))

Saída

[0 2 4 6 8]
[0.   0.25 0.5  0.75 1.  ]
[  1.  10. 100.]

O arange funciona como o range do Python: início, fim (excluído) e passo. O linspace recebe o número de pontos e inclui os dois extremos. O logspace espaça os pontos em escala logarítmica, de 10⁰ a 10², três pontos.

arange com passo decimal

Com passos decimais, o arange acumula erro de ponto flutuante e pode surpreender no tamanho do resultado. Quando você sabe quantos pontos quer, o linspace é mais previsível:

básico/cap05_criando_arrays.pylinha 45
print(len(np.arange(0, 1, 0.1)), len(np.linspace(0, 1, 11)))

Saída

10 11

O arange produz 10 pontos e exclui o 1. O linspace(0, 1, 11) produz 11 e inclui os dois extremos. Em dúvida, use linspace.

Arrays com a forma de outro

As funções *_like criam um array com a mesma forma (e o mesmo tipo, a menos que você diga o contrário) de um array existente:

básico/cap05_criando_arrays.pylinhas 50 a 52
base = np.array([[1, 2, 3], [4, 5, 6]])
print(np.zeros_like(base))
print(np.ones_like(base, dtype=float).shape)

Saída

[[0 0 0]
 [0 0 0]]
(2, 3)

O capítulo 16 apresenta os arrays aleatórios, que também são uma forma de criação.

Exercício 1

Múltiplos e matrizes constantes

Escreva multiplos_de(passo, limite) que devolva os múltiplos de passo de 0 até limite (inclusive), e matriz_constante(linhas, colunas, valor).

Ver solução
básico/cap05_criando_arrays.pylinhas 57 a 68
def multiplos_de(passo, limite):
    return np.arange(0, limite + 1, passo)


def matriz_constante(linhas, colunas, valor):
    return np.full((linhas, colunas), valor)


assert multiplos_de(5, 50).tolist() == [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50]
assert matriz_constante(2, 3, 7).shape == (2, 3)
assert (matriz_constante(2, 3, 7) == 7).all()
print("ok")

Saída

ok

Capítulo 6, parte Básico

Anatomia de um array

Antes de transformar um array, aprenda a ler o que ele diz sobre si mesmo. Toda a informação importante está em meia dúzia de atributos.

Código deste capítulo: básico/cap06_anatomia.py

Os atributos

básico/cap06_anatomia.pylinhas 10 a 16
import numpy as np

arr = np.array([[1, 2, 3], [4, 5, 6]])
print(arr.shape, arr.ndim, arr.size)
print(arr.dtype, arr.itemsize, arr.nbytes)
print(arr.T)
print(arr.strides)

Saída

(2, 3) 2 6
int64 8 48
[[1 4]
 [2 5]
 [3 6]]
(24, 8)
Atributo Significa No exemplo
shape As dimensões, como tupla (2, 3): 2 linhas e 3 colunas
ndim Quantas dimensões 2
size Quantos elementos no total 6
dtype O tipo de todos os elementos int64
itemsize Bytes por elemento 8
nbytes Bytes no total (size * itemsize) 48
T A transposta (linhas viram colunas) Uma visão, sem copiar
strides Bytes a pular para ir ao próximo elemento em cada eixo (24, 8)

O strides é o detalhe interno que torna o fatiamento tão rápido: para andar uma linha, o NumPy pula 24 bytes (3 elementos de 8 bytes), e para andar uma coluna, pula 8. Você não precisa calculá-lo, mas o capítulo 29 mostra como ele permite criar "visões" sem copiar dados.

Forma não é tamanho

O erro mais frequente de quem começa é trocar shape por size. Eles respondem a perguntas diferentes: o shape diz como os elementos estão organizados, e o size diz quantos são. Um array de forma (4, 5) tem tamanho 20.

Um array de uma dimensão tem forma de um elemento, e é preciso a vírgula na tupla. E há arrays de zero dimensões (escalares), cuja forma é a tupla vazia:

básico/cap06_anatomia.pylinha 21
print(len(arr), np.array([1, 2, 3]).shape, np.array(5).shape, np.array(5).ndim)

Saída

2 (3,) () 0

O len(arr) devolve apenas o tamanho da primeira dimensão (2 linhas). Para o total de elementos, use size.

Por que o dtype importa mais do que parece

Um milhão de float64 ocupa o dobro da memória do mesmo milhão em float32. Em um conjunto de dados de brinquedo isso é irrelevante, mas quando o conjunto cresce para centenas de milhões de valores, escolher o tipo certo decide se ele cabe na memória. O próximo capítulo trata disso.

Exercício 1

Um resumo de qualquer array

Escreva resumo(a) que devolva um dicionário com forma, dimensoes, tamanho e bytes de um array. Teste com np.zeros((4, 5)).

Ver solução
básico/cap06_anatomia.pylinhas 26 a 32
def resumo(a):
    return {"forma": a.shape, "dimensoes": a.ndim, "tamanho": a.size, "bytes": a.nbytes}


r = resumo(np.zeros((4, 5)))
assert r == {"forma": (4, 5), "dimensoes": 2, "tamanho": 20, "bytes": 160}
print("ok")

Saída

ok

Capítulo 7, parte Básico

Tipos de dados: dtype

Todo elemento de um array tem o mesmo tipo, e esse tipo decide a memória, a precisão e o que acontece quando um número não cabe. Aqui moram as surpresas mais caras do NumPy.

Código deste capítulo: básico/cap07_tipos_dtype.py

Os tipos mais comuns

O NumPy descobre o tipo a partir dos valores, mas você pode (e muitas vezes deve) escolher:

básico/cap07_tipos_dtype.pylinhas 10 a 15
import numpy as np

print(np.array([1, 2, 3]).dtype)
print(np.array([1.0, 2, 3]).dtype)
print(np.array([True, False]).dtype)
print(np.array([1 + 2j]).dtype)

Saída

int64
float64
bool
complex128
Tipo Bytes Faixa ou uso
int8, int16, int32, int64 1, 2, 4, 8 Inteiros com sinal. O int64 é o padrão
uint8, uint16, uint32, uint64 1, 2, 4, 8 Inteiros sem sinal (o uint8 vai de 0 a 255, como os pixels)
float32, float64 4, 8 Decimais. O float64 é o padrão
bool 1 Verdadeiro ou falso
complex128 16 Números complexos

Converter com astype

O astype cria um array novo com outro tipo. A conversão de decimal para inteiro trunca (descarta a parte decimal), em vez de arredondar:

básico/cap07_tipos_dtype.pylinhas 20 a 22
notas = np.array([7.9, 8.5, 9.99])
print(notas.astype(int))
print(np.round(notas).astype(int))

Saída

[7 8 9]
[ 8  8 10]

O np.round usa o arredondamento "para o par mais próximo" quando o valor está exatamente no meio: o 8.5 virou 8. É o mesmo comportamento do round do Python.

Quando o número não cabe

Cada tipo tem uma faixa fixa. Dentro de uma conta, passar do limite dá a volta (wraparound), sem erro e sem aviso. Um uint8 que chega a 255 e recebe mais 10 volta ao começo:

básico/cap07_tipos_dtype.pylinhas 27 a 28
pequeno = np.array([250, 5], dtype=np.uint8)
print(pequeno + 10)

Saída

[ 4 15]

O 250 + 10 deu 4, e não 260. É um erro silencioso, e por isso um dos mais perigosos: nenhum aviso, e o resultado parece plausível. Já na criação, o NumPy 2 protege você quando o valor é impossível:

básico/cap07_tipos_dtype.pylinhas 30 a 33
try:
    np.array([300], dtype=np.uint8)
except OverflowError as erro:
    print("OverflowError:", erro)

Saída

OverflowError: Python integer 300 out of bounds for uint8

Precisão de decimais

Os decimais binários não representam a maioria das frações exatamente, e o float32 tem ainda menos precisão do que o float64. A comparação por igualdade é uma armadilha, e a comparação com tolerância é o remédio:

básico/cap07_tipos_dtype.pylinhas 38 a 40
print(0.1 + 0.2 == 0.3, np.isclose(0.1 + 0.2, 0.3))
print(np.finfo(np.float64).eps)
print(np.float32(16_777_216) + np.float32(1))

Saída

False True
2.220446049250313e-16
1.6777216e+07

O eps é a menor diferença que o float64 distingue perto de 1. E o último resultado (1.6777216e+07, ou seja, 16.777.216) mostra o limite do float32: a partir de 2²⁴ ele não consegue mais representar todos os inteiros, e somar 1 a 16.777.216 devolve o mesmo número.

O que acontece quando tipos diferentes se encontram

Quando você combina tipos em uma conta, o NumPy decide o tipo do resultado por regras de promoção. O NumPy 2 mudou uma regra importante (a NEP 50): um número Python puro ("escalar fraco") não promove o tipo do array, enquanto um escalar NumPy promove:

básico/cap07_tipos_dtype.pylinhas 45 a 49
a = np.array([1, 2, 3], dtype=np.int8)
print((a + 1).dtype)
print((a + np.int64(1)).dtype)
print((np.float32(3) + 3.0).dtype)
print(np.result_type(np.int8, np.float32), np.result_type(np.int64, np.float32))

Saída

int8
int64
float32
float32 float64

O a + 1 continua int8, porque o 1 é um inteiro Python e "se adapta" ao array. Mas o a + np.int64(1) virou int64. E o np.result_type responde, sem fazer conta, qual seria o tipo do resultado.

Dados misturados e tipos pequenos

Duas fontes de bug silencioso: usar tipos pequenos (uint8, int16) em contas que passam da faixa, e misturar números com texto em np.array, que converte tudo para texto (capítulo 4). Quando um resultado parece estranho, imprima o dtype primeiro.

Exercício 1

Economizar memória com o menor tipo possível

Escreva economizar(idades) que converta um array de idades (inteiros positivos) para o menor tipo sem sinal que comporta o maior valor, usando np.min_scalar_type.

Ver solução
básico/cap07_tipos_dtype.pylinhas 54 a 63
def economizar(idades):
    return idades.astype(np.min_scalar_type(idades.max()))


idades = np.array([34, 71, 12, 98, 45, 120], dtype=np.int64)
menor = economizar(idades)
assert menor.dtype == np.uint8
assert menor.nbytes < idades.nbytes
assert menor.tolist() == idades.tolist()
print("ok")

Saída

ok

Capítulo 8, parte Básico

Indexação e fatiamento

Boa parte do trabalho com arrays é pegar exatamente o pedaço que você quer: um valor, uma linha, uma coluna, uma região.

Código deste capítulo: básico/cap08_indexacao.py

Um elemento, uma linha, uma coluna

Em arrays de várias dimensões, você separa os índices de cada eixo por vírgula, dentro de um único colchete: grade[linha, coluna]. Os dois pontos (:) significam "todos os elementos desse eixo":

básico/cap08_indexacao.pylinhas 10 a 17
import numpy as np

grade = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(grade[1, 2])
print(grade[:, 1])
print(grade[0, ::-1])
print(grade[1:, :2])
print(grade[-1])

Saída

6
[2 5 8]
[3 2 1]
[[4 5]
 [7 8]]
[7 8 9]

O grade[1][2] também funciona, mas cria um array intermediário (a linha 1) antes de pegar o elemento, o que é mais lento e menos claro. Prefira sempre grade[1, 2].

Fatias: `inicio:fim:passo`

Como nas listas, o fim é excluído, os índices negativos contam do fim e o passo pode ser negativo. A diferença é que, no array, a fatia pode combinar um eixo por vez:

básico/cap08_indexacao.pylinha 22
print(np.arange(10)[::3])

Saída

[0 3 6 9]

Atribuir a uma fatia

Uma fatia pode receber um valor (ou outro array), e a mudança acontece no array original. Para o exemplo não alterar a grade, eu trabalho em uma cópia:

básico/cap08_indexacao.pylinhas 27 a 30
copia = grade.copy()
copia[0, :] = 0
copia[:, 2] += 100
print(copia)

Saída

[[  0   0 100]
 [  4   5 106]
 [  7   8 109]]

Três dimensões e a reticência

Em arrays de 3 ou mais dimensões, a reticência (...) significa "todos os eixos que eu não mencionei". É muito útil para escrever código que funciona em qualquer número de dimensões:

básico/cap08_indexacao.pylinhas 35 a 36
cubo = np.arange(24).reshape(2, 3, 4)
print(cubo[1, 2, 3], cubo[:, 0, :].shape, cubo[..., 0].shape)

Saída

23 (2, 4) (2, 3)

O cubo[..., 0] pega o primeiro elemento do último eixo, de todos os outros, e por isso o resultado tem forma (2, 3).

Índice fora dos limites

Diferente das fatias, que são tolerantes, um índice isolado fora do limite levanta erro, e a mensagem diz exatamente qual eixo e qual tamanho:

básico/cap08_indexacao.pylinhas 41 a 44
try:
    grade[5]
except IndexError as erro:
    print(erro)

Saída

index 5 is out of bounds for axis 0 with size 3

Uma fatia é uma visão, não uma cópia

Ao contrário das listas do Python, a fatia de um array não copia os dados. Ela é uma "janela" para a mesma memória, e alterar a fatia altera o original. O capítulo 10 é inteiro sobre isso, e é o que mais surpreende quem vem das listas.

Exercício 1

O miolo de uma matriz

Escreva centro(a) que devolva a matriz sem as bordas (sem a primeira e a última linha e coluna). Para np.arange(16).reshape(4, 4), o resultado é [[5, 6], [9, 10]].

Ver solução
básico/cap08_indexacao.pylinhas 49 a 55
def centro(a):
    return a[1:-1, 1:-1]


quadro = np.arange(16).reshape(4, 4)
assert np.array_equal(centro(quadro), np.array([[5, 6], [9, 10]]))
print("ok")

Saída

ok

Capítulo 9, parte Básico

Indexação booleana e fancy

É a técnica que substitui o laço com `if`. Em vez de percorrer os elementos perguntando "este serve?", você descreve a condição e o NumPy devolve só os que servem.

Código deste capítulo: básico/cap09_indexacao_booleana.py

Uma condição vira uma máscara

Comparar um array com um valor produz outro array, de booleanos, chamado de máscara. Usá-la como índice filtra os elementos verdadeiros:

básico/cap09_indexacao_booleana.pylinhas 10 a 17
import numpy as np

notas = np.array([78, 92, 45, 88, 91, 30, 85])
mascara = notas > 75
print(mascara)
print(notas[mascara])
print(notas[notas > 75])
print((notas > 75).sum(), (notas > 75).mean().round(3))

Saída

[ True  True False  True  True False  True]
[78 92 88 91 85]
[78 92 88 91 85]
5 0.714

Como True vale 1 e False vale 0, o sum() de uma máscara conta quantos elementos passam, e o mean() dá a proporção. Aqui, 5 de 7 notas (71,4%) passaram de 75. É um dos truques mais usados em análise de dados.

Várias condições: `&`, `|` e `~`

Para combinar condições, use os operadores & (e), | (ou) e ~ (não), e sempre coloque cada condição entre parênteses, porque esses operadores têm precedência maior do que a comparação:

básico/cap09_indexacao_booleana.pylinhas 22 a 25
presenca = np.array([95, 60, 80, 92, 70, 88, 97])
print(notas[(notas > 80) & (presenca > 90)])
print(notas[(notas < 50) | (presenca < 65)])
print(notas[~(notas > 80)])

Saída

[88 85]
[92 45 30]
[78 45 30]

Usar and e or do Python é o erro clássico, e o NumPy responde com uma mensagem que parece enigmática:

básico/cap09_indexacao_booleana.pylinhas 27 a 30
try:
    notas[(notas > 80) and (presenca > 90)]
except ValueError as erro:
    print(erro)

Saída

The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()

O and do Python precisa decidir se um array inteiro é verdadeiro ou falso, e um array com vários elementos não tem resposta única. É por isso que existem o & e o |, que operam elemento a elemento.

Indexação fancy: uma lista de posições

Passando uma lista de índices, você escolhe as posições que quiser, na ordem que quiser, inclusive repetindo:

básico/cap09_indexacao_booleana.pylinhas 35 a 41
nums = np.array([10, 20, 30, 40, 50])
print(nums[[0, 2, 4]])
print(nums[[4, 4, 0]])

tabela = np.arange(12).reshape(3, 4)
print(tabela[[0, 2], :])
print(tabela[[0, 1], [1, 3]])

Saída

[10 30 50]
[50 50 10]
[[ 0  1  2  3]
 [ 8  9 10 11]]
[1 7]

Com duas listas, o NumPy as combina par a par: o último resultado é tabela[0, 1] e tabela[1, 3], e não um bloco 2×2.

Máscara e fancy devolvem cópias

Diferente da fatia, a indexação booleana e a fancy sempre devolvem uma cópia. Alterar o resultado não toca o original:

básico/cap09_indexacao_booleana.pylinhas 46 a 48
recorte = nums[[0, 1]]
recorte[0] = 999
print(nums[0], np.shares_memory(nums, recorte))

Saída

10 False
Técnica Devolve Compartilha memória com o original
Fatia (a[1:3]) Visão Sim
Máscara (a[a > 0]) Cópia Não
Fancy (a[[0, 2]]) Cópia Não

Exercício 1

Aprovados e acima da média

Escreva aprovados(notas, minimo=60) (as notas que atingem o mínimo) e acima_da_media(notas) (as posições das notas acima da média, com np.nonzero).

Ver solução
básico/cap09_indexacao_booleana.pylinhas 53 a 64
def aprovados(notas, minimo=60):
    return notas[notas >= minimo]


def acima_da_media(notas):
    return np.nonzero(notas > notas.mean())[0]


turma = np.array([50, 90, 60, 40, 80])
assert aprovados(turma).tolist() == [90, 60, 80]
assert acima_da_media(turma).tolist() == [1, 4]
print("ok")

Saída

ok

Capítulo 10, parte Básico

Cópias e views

Quase todo bug confuso com arrays é, no fundo, um caso de "alterei aqui e mudou ali". Entender quando o NumPy copia e quando compartilha elimina essa classe de erros.

Código deste capítulo: básico/cap10_copias_views.py

Uma view compartilha a memória

Quando você fatia um array, o NumPy não copia os números: cria um novo objeto que aponta para a mesma memória, com outro começo e outro tamanho. É por isso que o fatiamento custa quase nada, mesmo em arrays enormes. A consequência é que escrever na fatia escreve no original:

básico/cap10_copias_views.pylinhas 10 a 16
import numpy as np

original = np.arange(6)
fatia = original[1:4]
fatia[0] = 99
print(original)
print(np.shares_memory(original, fatia), fatia.base is original)

Saída

[ 0 99  2  3  4  5]
True True

O atributo base aponta para o array que é o dono da memória. Uma view tem base, e um array que possui os próprios dados tem base igual a None.

Quando você quer um array independente

Para uma cópia de verdade, peça explicitamente com .copy():

básico/cap10_copias_views.pylinhas 21 a 23
independente = original[1:4].copy()
independente[0] = -1
print(original[1], np.shares_memory(original, independente), independente.base is None)

Saída

99 False True
Operação Devolve Observação
Fatia a[1:4] View Sempre
a.reshape(...) View, se possível Cópia só se a memória não permitir
a.T, a.transpose() View
a.ravel() View, se possível flatten() sempre copia
a[mascara], a[[0, 2]] Cópia
a + 1, np.sqrt(a) Array novo Operações aritméticas criam resultado novo

O mesmo objeto, dois nomes

A atribuição b = a não copia nada: cria um segundo nome para o mesmo array (o mesmo modelo de etiquetas do livro de Python). E os operadores de atribuição composta (+=) alteram o array no lugar, enquanto b = b + 10 cria um novo:

básico/cap10_copias_views.pylinhas 28 a 33
a = np.array([1, 2, 3])
b = a
b += 10
print(a)
b = b + 10
print(a, b)

Saída

[11 12 13]
[11 12 13] [21 22 23]

O b += 10 mudou também o a, porque os dois são o mesmo objeto. Já o b = b + 10 criou um array novo e o ligou ao nome b, e o a ficou como estava.

Funções que alteram o que recebem

Um array passado para uma função é o mesmo objeto, e não uma cópia. Se a função altera o array, quem a chamou vê a mudança. Isso é útil para economizar memória e perigoso quando acontece sem querer:

básico/cap10_copias_views.pylinhas 38 a 45
def zerar_negativos(a):
    a[a < 0] = 0
    return a


dados = np.array([3, -1, 4, -5])
resultado = zerar_negativos(dados)
print(dados, resultado is dados)

Saída

[3 0 4 0] True

Os dados originais foram modificados, e o resultado é o mesmo objeto. Uma versão que não altera a entrada usa uma operação que cria resultado novo, como np.maximum(a, 0).

`asarray` e `array`

O np.asarray(x) não copia se x já for um array, e o np.array(x) copia por padrão. Funções que aceitam "qualquer coisa parecida com um array" costumam começar com np.asarray(entrada), o que converte listas sem copiar o que já é array:

básico/cap10_copias_views.pylinhas 50 a 51
x = np.arange(3)
print(np.asarray(x) is x, np.array(x) is x)

Saída

True False

A regra que eu sigo

Se vou modificar um array que veio de fora (de outra função, de um parâmetro, de uma fatia), faço .copy() antes. Se só vou ler, não copio. E quando uma função altera o argumento de propósito, o nome ou a documentação dela diz isso.

Exercício 1

Normalizar sem mutar a entrada

Escreva normalizar(a), que devolva (a - mínimo) / (máximo - mínimo) sem alterar o array recebido.

Ver solução
básico/cap10_copias_views.pylinhas 56 a 66
def normalizar(a):
    return (a - a.min()) / (a.max() - a.min())


entrada = np.array([10.0, 20.0, 30.0])
copia_da_entrada = entrada.copy()
saida = normalizar(entrada)
assert np.array_equal(entrada, copia_da_entrada)
assert saida.tolist() == [0.0, 0.5, 1.0]
assert not np.shares_memory(entrada, saida)
print("ok")

Saída

ok

Capítulo 11, parte Básico

Reshape e transposição

Os mesmos dados, em outra forma. Quase toda função de biblioteca espera uma forma específica, e remodelar é como você a entrega.

Código deste capítulo: básico/cap11_reshape.py

Remodelar sem mexer nos dados

O reshape reorganiza os mesmos elementos em outra forma. O total precisa continuar o mesmo: ele nunca cria nem remove elementos. E o -1 em uma das dimensões significa "calcule você":

básico/cap11_reshape.pylinhas 10 a 18
import numpy as np

arr = np.arange(12)
print(arr.reshape(3, 4))
print(arr.reshape(2, -1).shape, arr.reshape(-1, 3).shape)
try:
    arr.reshape(5, 3)
except ValueError as erro:
    print(erro)

Saída

[[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
(2, 6) (4, 3)
cannot reshape array of size 12 into shape (5,3)

Cinco vezes três dá 15, e o array tem 12 elementos, então o NumPy recusa. O reshape(2, -1) descobriu sozinho que a segunda dimensão é 6.

Transpor, achatar e inserir eixos

básico/cap11_reshape.pylinhas 23 a 27
grade = np.arange(6).reshape(2, 3)
print(grade.T.shape, grade.transpose().shape)
print(grade.flatten(), grade.ravel())
print(grade[:, np.newaxis, :].shape, grade[..., None].shape)
print(np.squeeze(np.zeros((1, 3, 1))).shape)

Saída

(3, 2) (3, 2)
[0 1 2 3 4 5] [0 1 2 3 4 5]
(2, 1, 3) (2, 3, 1)
(3,)
Função O que faz
a.T ou a.transpose() Troca linhas e colunas. Em mais dimensões, inverte a ordem dos eixos
a.swapaxes(i, j) Troca dois eixos específicos
a.flatten() Achata para 1D e sempre copia
a.ravel() Achata para 1D e devolve uma view quando possível
np.newaxis ou None Insere um eixo de tamanho 1
np.squeeze(a) Remove os eixos de tamanho 1

A diferença entre flatten e ravel é a mesma do capítulo anterior: um copia, o outro compartilha memória, e alterar o resultado do ravel muda o original:

básico/cap11_reshape.pylinhas 29 a 34
r = grade.ravel()
r[0] = 99
print(grade[0, 0])
f = grade.flatten()
f[0] = -1
print(grade[0, 0])

Saída

99
99

O truque do eixo extra

Inserir um eixo de tamanho 1 é a forma de preparar um vetor para o broadcasting do próximo capítulo. Um vetor de forma (3,) vira uma coluna (3, 1) com reshape(-1, 1) ou com [:, None]:

básico/cap11_reshape.pylinhas 39 a 40
vetor = np.array([1, 2, 3])
print(vetor.reshape(-1, 1))

Saída

[[1]
 [2]
 [3]]

Um caso real: lote de imagens

Um lote de 10 imagens de 28 por 28 pixels tem forma (10, 28, 28). Muitos modelos esperam cada imagem como um vetor de 784 números, ou seja, forma (10, 784). O -1 calcula o 784 sozinho:

básico/cap11_reshape.pylinhas 45 a 46
lote = np.zeros((10, 28, 28))
print(lote.reshape(10, -1).shape)

Saída

(10, 784)

reshape devolve view quando pode

O reshape devolve uma view se a memória permitir e só copia quando não há outro jeito. Por isso, depois de um reshape, tenha em mente que alterar o resultado pode alterar o original. Em dúvida, confira com np.shares_memory.

Exercício 1

Coluna e planilha

Escreva para_coluna(v) (um vetor 1D vira uma coluna) e lote_para_planilha(imagens) (de (n, h, w) para (n, h*w)).

Ver solução
básico/cap11_reshape.pylinhas 51 a 61
def para_coluna(v):
    return v.reshape(-1, 1)


def lote_para_planilha(imagens):
    return imagens.reshape(imagens.shape[0], -1)


assert para_coluna(np.array([1, 2, 3])).shape == (3, 1)
assert lote_para_planilha(np.zeros((10, 28, 28))).shape == (10, 784)
print("ok")

Saída

ok

Capítulo 12, parte Básico

Vetorização

Tudo o que você viu até aqui já dependia desta ideia: operar sobre o array inteiro de uma vez, sem escrever o laço. Este capítulo explica por que essa é a regra de ouro do NumPy.

Código deste capítulo: básico/cap12_vetorizacao.py

O laço e a operação sobre o array

Em Python puro, para dobrar todos os elementos você percorre um por um. No NumPy, você escreve a operação sobre o array, e ela vale para todos os elementos:

básico/cap12_vetorizacao.pylinhas 10 a 19
import time

import numpy as np

valores = np.array([1, 2, 3, 4, 5])
resultado = []
for x in valores:
    resultado.append(x * 2)
print(type(resultado).__name__, type(valores * 2).__name__)
print(valores * 2)

Saída

list ndarray
[ 2  4  6  8 10]

Repare que o laço devolveu uma lista (de escalares NumPy), e não um array. Quem escreve for x in array: lista.append(...) abandona as vantagens do NumPy: perde o tipo, perde a velocidade e ainda precisa converter a lista de volta com np.array(resultado).

Fórmulas inteiras, sem laço

As fórmulas se escrevem como na matemática, e valem para um elemento ou um milhão:

básico/cap12_vetorizacao.pylinhas 24 a 29
celsius = np.array([0.0, 25.0, 100.0])
print(celsius * 9 / 5 + 32)

precos = np.array([100.0, 250.0, 80.0])
quantidades = np.array([3, 1, 10])
print((precos * quantidades).sum())

Saída

[ 32.  77. 212.]
1350.0

A segunda conta é o total de um pedido: preço vezes quantidade, elemento a elemento, e depois a soma. Duas linhas, nenhum laço.

Por que é mais rápido

Um laço do Python paga um pequeno imposto a cada volta: verificar o tipo, gerenciar o laço, voltar ao interpretador. Para 5 elementos ele é invisível, e para milhões ele domina o tempo. Na versão vetorizada, o laço existe, mas roda em código compilado, sem esse imposto. A diferença é fácil de medir:

básico/cap12_vetorizacao.pylinhas 34 a 59
def medir(funcao, repeticoes=3):
    melhor = float("inf")
    for _ in range(repeticoes):
        inicio = time.perf_counter()
        funcao()
        melhor = min(melhor, time.perf_counter() - inicio)
    return melhor


dados = np.arange(200_000, dtype=np.float64)


def com_laco(a):
    saida = np.empty_like(a)
    for i in range(a.size):
        saida[i] = a[i] ** 2 + 1
    return saida


def vetorizado(a):
    return a ** 2 + 1


print(np.array_equal(com_laco(dados), vetorizado(dados)))
mais_rapido = medir(lambda: com_laco(dados)) > 50 * medir(lambda: vetorizado(dados))
print("vetorizado pelo menos 50 vezes mais rápido:", mais_rapido)

Saída

True
vetorizado pelo menos 50 vezes mais rápido: True

O resultado é idêntico, e a versão vetorizada é, nesse caso, muito mais rápida (com laço sobre um array, é pior do que sobre uma lista, porque cada a[i] cria um objeto escalar do NumPy).

O erro de usar funções que não são do NumPy

As funções do módulo math esperam um número, e não um array. O NumPy tem uma versão vetorizada de cada uma:

básico/cap12_vetorizacao.pylinhas 64 a 70
import math

try:
    math.sqrt(np.array([4.0, 9.0]))
except TypeError as erro:
    print("math.sqrt não aceita arrays:", type(erro).__name__)
print(np.sqrt(np.array([4.0, 9.0])))

Saída

math.sqrt não aceita arrays: TypeError
[2. 3.]

Quando o laço é a resposta certa

Nem tudo se vetoriza. Quando cada passo depende do resultado do anterior (um saldo que rende juros e recebe aportes, uma simulação, um algoritmo iterativo), o laço continua sendo a forma natural. O que vale é a pergunta: "existe uma versão vetorizada?":

básico/cap12_vetorizacao.pylinhas 75 a 81
juros = 0.01
saldo = 1000.0
saldos = []
for _ in range(3):
    saldo = saldo * (1 + juros) + 100
    saldos.append(round(saldo, 2))
print(saldos)

Saída

[1110.0, 1221.1, 1333.31]

A regra prática

Ao escrever um for sobre elementos de um array, eu paro e procuro a forma vetorizada primeiro: operadores aritméticos, comparações, np.where, agregações com axis, funções universais. Quando elas não resolvem, é que o problema tem dependência entre os passos, e aí o laço é legítimo (ou é hora de olhar cumsum, accumulate e o capítulo 34).

Exercício 1

Reescreva sem laço

Escreva imc(pesos, alturas) e soma_de_quadrados_pares(a) sem nenhum for. O IMC é peso / altura².

Ver solução
básico/cap12_vetorizacao.pylinhas 86 a 97
def imc(pesos, alturas):
    return pesos / alturas ** 2


def soma_de_quadrados_pares(a):
    return (a[a % 2 == 0] ** 2).sum()


resultado = imc(np.array([70.0, 90.0]), np.array([1.75, 1.80]))
assert np.allclose(resultado, [22.857, 27.778], atol=0.001)
assert soma_de_quadrados_pares(np.arange(10)) == 120
print("ok")

Saída

ok

Capítulo 13, parte Básico

Broadcasting

Somar dois arrays do mesmo tamanho é fácil. O broadcasting é o que acontece quando os tamanhos são diferentes, e o NumPy ainda assim encontra um jeito de fazer a conta.

Código deste capítulo: básico/cap13_broadcasting.py

A ideia: esticar, sem copiar

Pense no array menor sendo esticado para combinar com o maior. O NumPy não cria cópias dos dados: ele reaproveita a memória como se ela estivesse repetida. Uma linha somada a uma grade vale para todas as linhas, e uma coluna, para todas as colunas:

básico/cap13_broadcasting.pylinhas 10 a 18
import numpy as np

grade = np.ones((3, 4))
linha = np.array([1, 2, 3, 4])
print(grade + linha)

coluna = np.array([[10], [20], [30]])
print(grade + coluna)
print((np.arange(3).reshape(3, 1) + np.arange(4)).shape)

Saída

[[2. 3. 4. 5.]
 [2. 3. 4. 5.]
 [2. 3. 4. 5.]]
[[11. 11. 11. 11.]
 [21. 21. 21. 21.]
 [31. 31. 31. 31.]]
(3, 4)

O último exemplo é o mais poderoso: uma coluna (3, 1) somada a uma linha (4,) produz uma grade (3, 4), com todas as combinações.

A regra

Compare as formas da direita para a esquerda, uma dimensão de cada vez. Duas dimensões são compatíveis se forem iguais ou se uma delas for 1. Se todas as dimensões passam nesse teste, as formas combinam. Faltando dimensões à esquerda, o NumPy as trata como 1. Dá para consultar a regra por código:

básico/cap13_broadcasting.pylinhas 23 a 33
def compativel(a, b):
    try:
        return np.broadcast_shapes(a, b)
    except ValueError:
        return None


print(compativel((3, 4), (4,)))
print(compativel((3, 1), (1, 4)))
print(compativel((5, 1, 3), (3,)))
print(compativel((3, 4), (3,)))

Saída

(3, 4)
(3, 4)
(5, 1, 3)
None

O último caso falha: (3, 4) e (3,) alinham o 4 com o 3 (da direita para a esquerda), que não são iguais e nenhum é 1. O erro de verdade tem esta mensagem:

básico/cap13_broadcasting.pylinhas 35 a 38
try:
    np.ones((3, 4)) + np.ones(3)
except ValueError as erro:
    print(erro)

Saída

operands could not be broadcast together with shapes (3,4) (3,) 

Usos reais

Centralizar cada coluna (subtrair a média de cada uma) e normalizar cada linha (dividir pela soma da linha) são as duas operações mais comuns, e as duas dependem do broadcasting. O keepdims=True mantém o eixo reduzido com tamanho 1, que é exatamente o que o broadcasting precisa:

básico/cap13_broadcasting.pylinhas 43 a 45
notas = np.array([[7.0, 8.0, 9.0], [4.0, 6.0, 8.0]])
print(notas - notas.mean(axis=0))
print(notas / notas.sum(axis=1, keepdims=True))

Saída

[[ 1.5  1.   0.5]
 [-1.5 -1.  -0.5]]
[[0.29166667 0.33333333 0.375     ]
 [0.22222222 0.33333333 0.44444444]]

A subtração usa uma média por coluna (forma (3,)). A divisão precisa de uma soma por linha em forma de coluna, (2, 1), e é por isso que o keepdims=True é necessário.

Outro uso é calcular todas as diferenças entre pares de uma vez, sem laço duplo:

básico/cap13_broadcasting.pylinhas 47 a 48
indices = np.arange(4)
print(indices[:, None] - indices)

Saída

[[ 0 -1 -2 -3]
 [ 1  0 -1 -2]
 [ 2  1  0 -1]
 [ 3  2  1  0]]

A memória não é copiada

O broadcasting é um esticamento virtual. Dá para ver isso nos strides: um passo de 0 bytes significa "repita o mesmo dado". E o resultado é somente leitura, porque escrever em elementos que, na verdade, são o mesmo seria ambíguo:

básico/cap13_broadcasting.pylinhas 53 a 54
esticado = np.broadcast_to(np.array([1, 2, 3]), (1000, 3))
print(esticado.shape, esticado.strides, esticado.flags["WRITEABLE"])

Saída

(1000, 3) (0, 8) False

Mil linhas "existem", mas ocupam o espaço de uma. É por isso que o broadcasting é rápido e econômico. O cuidado vem do outro lado: o resultado da conta (a grade (3, 4) do exemplo) é um array real, e em escalas grandes ele pode estourar a memória (capítulo 31).

Não é "qualquer array menor serve"

O erro típico é achar que, se um array é menor, ele sempre combina. O que conta é cada dimensão. Teste a forma com np.broadcast_shapes antes de rodar uma conta grande, ou leia a mensagem de erro: ela mostra as duas formas lado a lado.

Exercício 1

Padronizar as colunas

Escreva padronizar_colunas(x) que subtraia a média de cada coluna e divida pelo desvio padrão dela (o z-score). Depois de padronizar, cada coluna tem média 0 e desvio 1.

Ver solução
básico/cap13_broadcasting.pylinhas 59 a 67
def padronizar_colunas(x):
    return (x - x.mean(axis=0)) / x.std(axis=0)


dados = np.array([[1.0, 100.0], [2.0, 200.0], [3.0, 300.0], [4.0, 400.0]])
z = padronizar_colunas(dados)
assert np.allclose(z.mean(axis=0), 0)
assert np.allclose(z.std(axis=0), 1)
print("ok")

Saída

ok

Capítulo 14, parte Básico

Agregações e o parâmetro axis

Um array cru raramente é a resposta. Você quer um número que o resuma: um total, uma média, uma dispersão. E, em arrays de duas dimensões, quase sempre quer esse número **por coluna** ou **por linha**.

Código deste capítulo: básico/cap14_agregacoes.py

As agregações mais usadas

básico/cap14_agregacoes.pylinhas 10 a 15
import numpy as np

dados = np.array([[4, 9, 2], [11, 6, 15]])
print(dados.sum(), dados.mean(), np.median(dados))
print(dados.sum(axis=0), dados.sum(axis=1))
print(dados.max(axis=1), dados.argmax(), dados.argmax(axis=1))

Saída

47 7.833333333333333 7.5
[15 15 17] [15 32]
[ 9 15] 5 [1 2]

Sem axis, a agregação achata o array e resume tudo em um número só. Com axis, o NumPy resume ao longo daquele eixo, e a dimensão some do resultado.

O que o axis faz

Parâmetro Resume Resultado (para uma grade de 2 linhas e 3 colunas)
axis=None (padrão) O array inteiro Um número
axis=0 Colapsa as linhas: um valor por coluna Forma (3,)
axis=1 Colapsa as colunas: um valor por linha Forma (2,)

Eu uso este modelo mental: axis=k é o eixo que desaparece do resultado. Em axis=0, o eixo das linhas desaparece e sobram as colunas.

O argmax e o argmin devolvem a posição do maior e do menor valor (sem axis, a posição no array achatado), em vez do valor.

Dispersão e posição

O desvio padrão e a variância têm uma sutileza: por padrão, o NumPy divide por N (a variância da população). Para tratar os dados como uma amostra, divide-se por N - 1, com ddof=1:

básico/cap14_agregacoes.pylinhas 20 a 21
print(dados.std().round(3), dados.var().round(3), dados.std(ddof=1).round(3))
print(np.percentile(dados, [25, 50, 75]))

Saída

4.375 19.139 4.792
[ 4.5  7.5 10.5]

O np.percentile devolve os valores abaixo dos quais fica a porcentagem pedida: o de 50% é a mediana.

Mediana resiste a valores extremos, a média não

Um único valor extremo arrasta a média para longe do que é "típico", e a mediana mal se mexe. Por isso, em dados de salário, preço de imóveis e tempo de resposta, a mediana costuma ser a medida honesta:

básico/cap14_agregacoes.pylinhas 26 a 27
salarios = np.array([3000, 3200, 3100, 2900, 50000])
print(salarios.mean(), np.median(salarios))

Saída

12440.0 3100.0

Contar valores distintos

O np.unique com return_counts=True devolve os valores diferentes e quantas vezes cada um aparece, sem laço:

básico/cap14_agregacoes.pylinhas 32 a 34
votos = np.array([2, 1, 2, 3, 2, 1])
valores, contagens = np.unique(votos, return_counts=True)
print(valores, contagens)

Saída

[1 2 3] [2 3 1]

Agregar com `keepdims`

Mantendo a dimensão reduzida, o resultado ainda "encaixa" no array original por broadcasting. É o que o capítulo anterior usou para normalizar linhas:

básico/cap14_agregacoes.pylinhas 39 a 40
print(dados.sum(axis=1, keepdims=True))
print(dados / dados.sum(axis=1, keepdims=True))

Saída

[[15]
 [32]]
[[0.26666667 0.6        0.13333333]
 [0.34375    0.1875     0.46875   ]]

Agregações em booleanos

Como True vale 1 e False vale 0, sum conta, mean dá a proporção, any pergunta se algum é verdadeiro e all pergunta se todos são. Elas combinadas com as máscaras do capítulo 9 resolvem a maior parte das perguntas sobre dados.

Exercício 1

Um resumo por coluna

Escreva resumo_por_coluna(dados) que devolva um dicionário com o mínimo, o máximo e a média de cada coluna (cada valor é um array com um elemento por coluna).

Ver solução
básico/cap14_agregacoes.pylinhas 45 a 57
def resumo_por_coluna(dados):
    return {
        "minimo": dados.min(axis=0),
        "maximo": dados.max(axis=0),
        "media": dados.mean(axis=0),
    }


r = resumo_por_coluna(np.array([[4, 9, 2], [11, 6, 15]]))
assert r["minimo"].tolist() == [4, 6, 2]
assert r["maximo"].tolist() == [11, 9, 15]
assert r["media"].tolist() == [7.5, 7.5, 8.5]
print("ok")

Saída

ok

Capítulo 15, parte Básico

Funções universais (ufuncs)

Raiz quadrada, logaritmo, exponencial, trigonometria: toda a matemática elementar aplicada ao array inteiro de uma vez, em código compilado, com broadcasting incluído.

Código deste capítulo: básico/cap15_ufuncs.py

Matemática sobre o array inteiro

Uma ufunc (função universal) aplica uma operação a cada elemento. Os operadores +, -, * e / são, por baixo, np.add, np.subtract, np.multiply e np.divide:

básico/cap15_ufuncs.pylinhas 10 a 16
import numpy as np

a = np.array([1.0, 4.0, 9.0])
print(np.sqrt(a), np.exp(np.array([0, 1])), np.log(np.array([1, np.e])))
print(np.add(a, 1), np.multiply(a, a))
print(np.abs(np.array([-3, 2])), np.round(np.array([1.234, 5.678]), 1))
print(np.clip(np.array([-5, 3, 12]), 0, 10))

Saída

[1. 2. 3.] [1.         2.71828183] [0. 1.]
[ 2.  5. 10.] [ 1. 16. 81.]
[3 2] [1.2 5.7]
[ 0  3 10]

O np.clip(x, mínimo, máximo) "aperta" os valores para dentro de um intervalo: tudo abaixo do mínimo vira o mínimo, e tudo acima do máximo vira o máximo. É muito usado para limpar dados com valores impossíveis.

Resultados impossíveis viram `nan` e `inf`

Uma conta impossível (raiz de número negativo, divisão por zero) não derruba o programa: o NumPy emite um aviso e devolve um valor especial, nan ("não é um número") ou inf (infinito). A execução continua:

básico/cap15_ufuncs.pylinhas 21 a 26
import warnings

with warnings.catch_warnings(record=True) as avisos:
    warnings.simplefilter("always")
    resultado = np.sqrt(np.array([4.0, -4.0]))
print(resultado, avisos[0].category.__name__)

Saída

[ 2. nan] RuntimeWarning

Esse comportamento é conveniente, e também perigoso: o nan se espalha. Qualquer conta com um nan produz nan, e o erro aparece longe de onde nasceu. O capítulo 25 trata dos dados ausentes. Se você sabe o que está fazendo, o np.errstate silencia os avisos em um trecho:

básico/cap15_ufuncs.pylinhas 28 a 29
with np.errstate(invalid="ignore", divide="ignore"):
    print(np.array([1.0, 0.0]) / np.array([0.0, 0.0]))

Saída

[inf nan]

Escolher onde calcular: `where` e `out`

As ufuncs aceitam dois parâmetros que economizam memória e evitam contas indesejadas. O where diz em quais posições calcular, e o out diz onde guardar o resultado, em vez de criar um array novo. Onde o where é falso, o valor de out permanece:

básico/cap15_ufuncs.pylinhas 34 a 37
x = np.array([1.0, 4.0, 9.0, 16.0])
saida = np.zeros_like(x)
np.sqrt(x, out=saida, where=x > 5)
print(saida)

Saída

[0. 0. 3. 4.]

Divisão inteira e resto

Os operadores // e % também são ufuncs, e a divisão inteira arredonda para baixo, mesmo com números negativos:

básico/cap15_ufuncs.pylinha 42
print(np.array([7, -7]) // 2, np.mod(-7, 3))

Saída

[ 3 -4] 2

Não misture math com NumPy

O módulo math trabalha com um número por vez e falha com arrays (capítulo 12). O NumPy não só aceita arrays como também aceita um número isolado, então, em código numérico, prefira np.sqrt, np.exp e companhia, mesmo para valores únicos, e o código serve para os dois casos.

Exercício 1

A função sigmoide

Escreva sigmoide(x), igual a 1 / (1 + e^(-x)). É a função que "comprime" qualquer número para o intervalo entre 0 e 1, e é a base de muita coisa em aprendizado de máquina.

Ver solução
básico/cap15_ufuncs.pylinhas 47 a 54
def sigmoide(x):
    return 1 / (1 + np.exp(-x))


assert sigmoide(0) == 0.5
assert np.allclose(sigmoide(np.array([-1.0, 1.0])), [0.26894142, 0.73105858])
assert (np.diff(sigmoide(np.linspace(-5, 5, 20))) > 0).all()
print("ok")

Saída

ok

Capítulo 16, parte Básico

Números aleatórios com Generator

Embaralhar dados, sorteá-los, gerar um conjunto sintético para treinar: tudo isso exige aleatoriedade **controlada**. E o NumPy tem hoje uma forma moderna de fazê-lo, diferente da que muitos tutoriais ainda ensinam.

Código deste capítulo: básico/cap16_aleatorios.py

A forma moderna: `default_rng`

Você cria um gerador com uma semente (seed) e pede números a ele. A mesma semente produz sempre a mesma sequência, e é isso que torna um resultado reproduzível:

básico/cap16_aleatorios.pylinhas 10 a 16
import numpy as np

rng = np.random.default_rng(42)
print(rng.random(3))
print(rng.integers(0, 10, size=5))
print(rng.normal(loc=0, scale=1, size=3))
print(rng.choice([10, 20, 30], size=2, replace=False))

Saída

[0.77395605 0.43887844 0.85859792]
[0 6 2 0 5]
[ 0.1278404  -0.31624259 -0.01680116]
[30 20]
básico/cap16_aleatorios.pylinhas 18 a 20
a = np.random.default_rng(7).random(3)
b = np.random.default_rng(7).random(3)
print(np.array_equal(a, b))

Saída

True
Método Gera
rng.random(n) Decimais uniformes em [0, 1)
rng.integers(a, b, size) Inteiros em [a, b)
rng.normal(media, desvio, size) Distribuição normal (a curva do sino)
rng.uniform(a, b, size) Decimais uniformes em [a, b)
rng.choice(opcoes, size, replace, p) Sorteio de valores existentes, com ou sem reposição
rng.shuffle(a), rng.permutation(a) Embaralhar
rng.binomial, rng.poisson, rng.exponential Outras distribuições

Por que não `np.random.seed`

A API antiga (np.random.seed(42) seguida de np.random.rand(3)) ainda funciona e aparece em muito material, inclusive em cursos, mas usa um estado global escondido no módulo:

básico/cap16_aleatorios.pylinhas 25 a 26
np.random.seed(42)
print(np.random.rand(3))

Saída

[0.37454012 0.95071431 0.73199394]

O problema do estado global é que qualquer código, em qualquer lugar, pode consumir números dele e mudar a sua sequência sem você perceber, o que quebra a reprodutibilidade e torna os testes frágeis. Com o Generator, o estado mora em um objeto que você controla. E, por isso, funções que usam aleatoriedade devem receber o gerador como parâmetro:

básico/cap16_aleatorios.pylinhas 28 a 32
def sorteio(n, rng):
    return rng.integers(1, 7, size=n)


print(sorteio(5, np.random.default_rng(1)).tolist())

Saída

[3, 4, 5, 6, 1]

Com essa assinatura, o teste da função passa um gerador com semente fixa e verifica um resultado exato, e a produção passa um gerador qualquer.

A garantia de reprodução

A mesma semente reproduz os mesmos números na mesma versão do NumPy. Entre versões, a garantia do Generator é mais fraca do que a da API antiga, que o projeto congelou. Para um experimento que precisa ser reproduzido anos depois, registre também a versão da biblioteca.

Embaralhar: no lugar ou em cópia

O rng.shuffle embaralha o próprio array e devolve None. Escrever x = rng.shuffle(x) joga fora o array. Para obter um embaralhado e manter o original, use permutation:

básico/cap16_aleatorios.pylinhas 37 a 43
vetor = np.arange(1, 6)
resultado = rng.shuffle(vetor)
print(resultado, sorted(vetor.tolist()))

original = np.arange(1, 6)
embaralhado = rng.permutation(original)
print(original, sorted(embaralhado.tolist()))

Saída

None [1, 2, 3, 4, 5]
[1 2 3 4 5] [1, 2, 3, 4, 5]

Gerar um conjunto sintético

Dados sintéticos com características conhecidas permitem testar uma análise sabendo a resposta. Aqui, mil alturas com média 170 e desvio 10: a média amostral fica perto de 170, mas não exatamente nela, porque é uma amostra:

básico/cap16_aleatorios.pylinhas 48 a 52
alturas = rng.normal(170, 10, size=1000)
print(abs(alturas.mean() - 170) < 1, abs(alturas.std() - 10) < 1)

sorteados = rng.choice(["cara", "coroa"], size=1000, p=[0.9, 0.1])
print((sorteados == "cara").mean() > 0.8)

Saída

True True
True

Exercício 1

Simular dados

Escreva simular_dados(n, rng) que jogue um dado de 6 faces n vezes e devolva quantas vezes saiu cada face (um array com 6 contagens), com np.bincount.

Ver solução
básico/cap16_aleatorios.pylinhas 57 a 66
def simular_dados(n, rng):
    lancamentos = rng.integers(1, 7, size=n)
    return np.bincount(lancamentos, minlength=7)[1:]


contagens = simular_dados(6000, np.random.default_rng(0))
assert contagens.shape == (6,)
assert contagens.sum() == 6000
assert (contagens > 800).all()
print("ok")

Saída

ok

Capítulo 17, parte Básico

Tensores: do escalar ao lote de imagens

A palavra "tensor" assusta, e não deveria. Um tensor é um array com três ou mais dimensões, e tudo o que você já aprendeu vale para ele.

Código deste capítulo: básico/cap17_tensores.py

As dimensões, de zero a cinco

Dimensões Nome Exemplo real Forma típica
0 Escalar O brilho de um pixel ()
1 Vetor O RGB de um pixel (3,)
2 Matriz Uma imagem em tons de cinza (altura, largura)
3 Tensor Uma imagem colorida (altura, largura, 3)
4 Tensor Um lote de imagens (lote, altura, largura, 3)
5 Tensor Um clipe de vídeo em lote (lote, quadros, altura, largura, 3)
básico/cap17_tensores.pylinhas 10 a 20
import numpy as np

escalar = np.array(7)
vetor = np.array([255, 0, 0])
matriz = np.zeros((28, 28))
imagem = np.zeros((224, 224, 3), dtype=np.uint8)
lote = np.zeros((32, 224, 224, 3), dtype=np.float32)

for nome, t in [("escalar", escalar), ("vetor", vetor), ("matriz", matriz), ("imagem", imagem), ("lote", lote)]:
    print(f"{nome:<8} ndim={t.ndim} shape={t.shape}")
print(round(lote.nbytes / 1e6, 1), "MB")

Saída

escalar  ndim=0 shape=()
vetor    ndim=1 shape=(3,)
matriz   ndim=2 shape=(28, 28)
imagem   ndim=3 shape=(224, 224, 3)
lote     ndim=4 shape=(32, 224, 224, 3)
19.3 MB

O último número é um aviso: um lote de 32 imagens de 224 por 224 em float32 ocupa cerca de 19 MB. Tensores crescem rápido, e o dtype e o tamanho do lote decidem se o seu computador aguenta.

Ler uma forma como um idioma

Quando um tutorial mostrar uma forma como (32, 224, 224, 3), leia como lote × altura × largura × canais. Esse hábito de leitura resolve a maior parte da confusão dos primeiros dias com aprendizado profundo.

Existem duas convenções para o lugar dos canais. O NumPy e o TensorFlow costumam usar canais no fim (altura, largura, canais), e o PyTorch usa canais antes (canais, altura, largura). O np.moveaxis troca de uma para a outra:

básico/cap17_tensores.pylinhas 25 a 26
chw = np.moveaxis(imagem, -1, 0)
print(chw.shape, np.transpose(chw, (1, 2, 0)).shape)

Saída

(3, 224, 224) (224, 224, 3)

A mesma regra de sempre, em mais dimensões

Indexação, fatiamento, broadcasting e agregação funcionam exatamente como antes. Com axis você escolhe sobre quais dimensões resumir. Para calcular a média de cada canal de cor em um lote inteiro, resuma lote, altura e largura de uma vez, e sobram os canais:

básico/cap17_tensores.pylinha 31
print(lote.mean(axis=(0, 1, 2)).shape)

Saída

(3,)

Uma imagem de verdade, em miniatura

Uma imagem colorida é um tensor. Em uma imagem de 2 por 2 pixels, cada pixel tem três canais (vermelho, verde, azul), de 0 a 255:

básico/cap17_tensores.pylinhas 36 a 44
pixels = np.array(
    [[[255, 0, 0], [0, 255, 0]], [[0, 0, 255], [255, 255, 255]]],
    dtype=np.uint8,
)
print(pixels.shape)
print(pixels[0, 1])
print(pixels[..., 0])
cinza = pixels.mean(axis=-1).astype(np.uint8)
print(cinza)

Saída

(2, 2, 3)
[  0 255   0]
[[255   0]
 [  0 255]]
[[ 85  85]
 [ 85 255]]

Os quatro pixels são vermelho, verde, azul e branco. pixels[..., 0] extrai o canal vermelho, e a média dos três canais produz uma versão em tons de cinza (um jeito simples, e não o mais fiel à percepção humana, de converter).

O que o PyTorch e o JAX acrescentam

Os tensores do PyTorch e do JAX têm a mesma forma, a mesma indexação e as mesmas regras de broadcasting. O que eles acrescentam é execução em GPU e derivação automática, que calcula gradientes por você. Quem domina os arrays do NumPy já domina o vocabulário deles.

Exercício 1

Empilhar imagens em um lote

Escreva para_lote(imagens) que receba uma lista de imagens de mesma forma (altura, largura, 3) e devolva um tensor de forma (n, altura, largura, 3), com np.stack.

Ver solução
básico/cap17_tensores.pylinhas 49 a 55
def para_lote(imagens):
    return np.stack(imagens)


imagens = [np.zeros((8, 8, 3)) for _ in range(4)]
assert para_lote(imagens).shape == (4, 8, 8, 3)
print("ok")

Saída

ok

Capítulo 18, parte Básico

Medir desempenho de verdade

Otimizar sem medir é adivinhar. Este capítulo mostra como medir o tempo e a memória de um trecho de código sem se enganar, e prova a afirmação do capítulo 4 com números.

Código deste capítulo: básico/cap18_medir_desempenho.py

O `timeit` e o melhor de vários

Um tempo medido uma vez é ruído: o processador pode estar ocupado, o cache pode estar frio. O módulo timeit repete a medida, e a regra é usar o menor dos tempos, que é o menos afetado pelo que o seu computador estava fazendo ao mesmo tempo:

básico/cap18_medir_desempenho.pylinhas 10 a 16
import timeit

import numpy as np

a = np.arange(1_000_000)
tempos = timeit.repeat(lambda: a.sum(), number=20, repeat=5)
print("melhor tempo por chamada abaixo de 10 ms:", min(tempos) / 20 < 0.01)

Saída

melhor tempo por chamada abaixo de 10 ms: True

Em um notebook, %timeit a.sum() faz tudo isso por você. Em um script, use timeit.repeat e olhe o min.

Quanto custa começar: tamanhos pequenos não contam a história

Chamar uma função do NumPy tem um custo fixo (verificar tipos, alocar o array de saída). Para poucos elementos, esse custo domina, e o array pode até perder para uma lista. A vantagem só aparece, e cresce, quando os dados crescem:

básico/cap18_medir_desempenho.pylinhas 21 a 30
def razao(n):
    lista = list(range(n))
    array = np.arange(n)
    t_lista = min(timeit.repeat(lambda: [x * 2 for x in lista], number=50, repeat=5))
    t_array = min(timeit.repeat(lambda: array * 2, number=50, repeat=5))
    return t_lista / t_array


print("a vantagem cresce com o tamanho:", razao(100_000) > razao(100))
print("em 100 mil elementos o array ganha:", razao(100_000) > 5)

Saída

a vantagem cresce com o tamanho: True
em 100 mil elementos o array ganha: True

Por isso, medir com 10 elementos e concluir que "o NumPy não é mais rápido" é um erro clássico. O teste tem de ser feito no tamanho real dos seus dados.

Medir memória

Tempo é só uma metade. Muitas análises falham por falta de memória, e não por lentidão. O tracemalloc mede a memória alocada pelo Python, e o NumPy reporta os seus arrays a ele:

básico/cap18_medir_desempenho.pylinhas 35 a 41
import tracemalloc

tracemalloc.start()
x = np.zeros(1_000_000)
atual, pico = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(round(atual / 1e6, 1), "MB")

Saída

8.0 MB

Um milhão de float64 ocupa 8 MB, exatamente size * itemsize. Para um array que já existe, o nbytes dá a resposta sem medir nada.

Armadilhas de medição

Armadilha O que acontece O que fazer
Medir uma só vez Ruído domina Repetir e usar o menor tempo
Medir dados minúsculos O custo fixo domina Usar o tamanho real
Incluir a criação dos dados Mede a coisa errada Criar fora da função medida
Comparar resultados diferentes A conta mais rápida pode estar errada Conferir com np.allclose antes
Confiar em número absoluto Muda de máquina para máquina Comparar razões
Otimizar o que não é gargalo Esforço sem efeito Perfilar antes (cProfile)

Meça antes de mexer

Eu só otimizo depois de saber onde o tempo vai. Para um programa inteiro, o cProfile (capítulo 48 do curso de Python) mostra qual função consome o tempo. Para um trecho, o timeit compara alternativas. E, antes de aceitar uma versão mais rápida, eu confiro que ela produz o mesmo resultado.

Exercício 1

Comparar duas implementações

Escreva comparar(f, g) que devolva quantas vezes f é mais lenta que g (a razão entre os melhores tempos, em 5 repetições). Use para comparar sum(range(10000)) com np.arange(10000).sum().

Ver solução
básico/cap18_medir_desempenho.pylinhas 46 a 54
def comparar(f, g, repeticoes=5):
    t_f = min(timeit.repeat(f, number=10, repeat=repeticoes))
    t_g = min(timeit.repeat(g, number=10, repeat=repeticoes))
    return t_f / t_g


razao_medida = comparar(lambda: sum(range(10000)), lambda: np.arange(10000).sum())
assert razao_medida > 1
print("ok")

Saída

ok

Capítulo 19, parte Intermediário

Juntar, empilhar e dividir

Dados raramente chegam em um bloco só. Eles vêm em pedaços que você precisa juntar, e depois você precisa separar em treino e teste, em lotes, em grupos.

Código deste capítulo: intermediario/cap19_juntar_dividir.py

Juntar ao longo de um eixo

O np.concatenate une arrays ao longo de um eixo que já existe. Todas as outras dimensões precisam ser iguais:

intermediario/cap19_juntar_dividir.pylinhas 10 a 15
import numpy as np

a = np.array([[1, 2], [3, 4]])
b = np.array([[5, 6]])
print(np.concatenate([a, b], axis=0))
print(np.concatenate([a, b.T], axis=1))

Saída

[[1 2]
 [3 4]
 [5 6]]
[[1 2 5]
 [3 4 6]]

Quando as dimensões não batem, o erro diz exatamente qual:

intermediario/cap19_juntar_dividir.pylinhas 17 a 20
try:
    np.concatenate([a, b], axis=1)
except ValueError as erro:
    print(erro)

Saída

all the input array dimensions except for the concatenation axis must match exactly, but along dimension 0, the array at index 0 has size 2 and the array at index 1 has size 1

Empilhar em um eixo novo

O np.stack cria um eixo novo e coloca cada array em uma posição dele. É o que você quer para juntar vários vetores de mesma forma em uma matriz, ou várias imagens em um lote. Os atalhos vstack e hstack empilham na vertical e na horizontal:

intermediario/cap19_juntar_dividir.pylinhas 25 a 28
x = np.array([1, 2, 3])
y = np.array([4, 5, 6])
print(np.stack([x, y]).shape, np.stack([x, y], axis=1).shape)
print(np.vstack([x, y]), np.hstack([x, y]))

Saída

(2, 3) (3, 2)
[[1 2 3]
 [4 5 6]] [1 2 3 4 5 6]
Função O que faz Forma de x e y com 3 elementos cada
np.concatenate([x, y]) Une em um eixo existente (6,)
np.stack([x, y]) Cria um eixo novo, na frente (2, 3)
np.stack([x, y], axis=1) Cria o eixo novo no meio (3, 2)
np.vstack([x, y]) Empilha como linhas (2, 3)
np.hstack([x, y]) Une lado a lado (6,)

Dividir

O np.split divide em partes iguais (e reclama se não der), o np.array_split aceita partes de tamanhos diferentes, e uma lista de posições diz onde cortar:

intermediario/cap19_juntar_dividir.pylinhas 33 a 36
dados = np.arange(10)
print(np.split(dados, 5))
print(np.array_split(dados, 3))
print(np.split(dados, [2, 7]))

Saída

[array([0, 1]), array([2, 3]), array([4, 5]), array([6, 7]), array([8, 9])]
[array([0, 1, 2, 3]), array([4, 5, 6]), array([7, 8, 9])]
[array([0, 1]), array([2, 3, 4, 5, 6]), array([7, 8, 9])]

O uso mais comum é separar treino e teste: embaralhe as posições (capítulo 16) e corte. Embaralhar antes é essencial, porque dados guardados em ordem (por data, por classe) dariam um teste enviesado:

intermediario/cap19_juntar_dividir.pylinhas 38 a 41
rng = np.random.default_rng(0)
ordem = rng.permutation(10)
treino, teste = np.split(ordem, [8])
print(len(treino), len(teste), sorted(np.concatenate([treino, teste]).tolist()) == list(range(10)))

Saída

8 2 True

Repetir

intermediario/cap19_juntar_dividir.pylinha 46
print(np.repeat([1, 2], 3), np.tile([1, 2], 3))

Saída

[1 1 1 2 2 2] [1 2 1 2 1 2]

O repeat repete cada elemento, e o tile repete o bloco inteiro.

O `np.append` em laço é uma armadilha

Cada np.append cria um array novo, copiando tudo o que já existia. Em um laço, isso custa tempo proporcional ao quadrado do tamanho. O padrão certo é acumular em uma lista (que cresce barato) e converter uma vez no fim:

intermediario/cap19_juntar_dividir.pylinhas 51 a 68
import timeit


def com_append(n):
    a = np.array([])
    for i in range(n):
        a = np.append(a, i)
    return a


def com_lista(n):
    return np.array([i for i in range(n)], dtype=float)


print(np.array_equal(com_append(500), com_lista(500)))
t_append = min(timeit.repeat(lambda: com_append(3000), number=1, repeat=3))
t_lista = min(timeit.repeat(lambda: com_lista(3000), number=1, repeat=3))
print("a lista foi pelo menos 5 vezes mais rápida:", t_append > 5 * t_lista)

Saída

True
a lista foi pelo menos 5 vezes mais rápida: True

Exercício 1

Adicionar a coluna de uns

Escreva adicionar_vies(X) que acrescente uma coluna de uns no início de uma matriz de características X. É o passo padrão antes de ajustar uma regressão linear com intercepto.

Ver solução
intermediario/cap19_juntar_dividir.pylinhas 73 a 82
def adicionar_vies(X):
    return np.hstack([np.ones((X.shape[0], 1)), X])


X = np.array([[2.0, 3.0], [4.0, 5.0], [6.0, 7.0]])
resultado = adicionar_vies(X)
assert resultado.shape == (3, 3)
assert (resultado[:, 0] == 1).all()
assert np.array_equal(resultado[:, 1:], X)
print("ok")

Saída

ok

Capítulo 20, parte Intermediário

Ordenar, buscar e contar

Ordenar um array é fácil. O que realmente se usa é a **posição** em que cada elemento ficaria, porque ela permite reordenar outros arrays na mesma ordem.

Código deste capítulo: intermediario/cap20_ordenar_buscar.py

`sort` e `argsort`

O np.sort devolve uma cópia ordenada. O np.argsort devolve, em vez dos valores, as posições que ordenariam o array, e essa é a ferramenta mais versátil:

intermediario/cap20_ordenar_buscar.pylinhas 10 a 15
import numpy as np

v = np.array([30, 10, 50, 20, 40])
print(np.sort(v), v)
print(np.argsort(v))
print(v[np.argsort(v)[::-1]])

Saída

[10 20 30 40 50] [30 10 50 20 40]
[1 3 0 4 2]
[50 40 30 20 10]

Com as posições você ordena um array pela chave de outro: classificar nomes pelas notas, por exemplo. Para a ordem decrescente, ordene o negativo:

intermediario/cap20_ordenar_buscar.pylinhas 17 a 19
nomes = np.array(["Ana", "Bia", "Caio"])
notas = np.array([7.5, 9.0, 6.0])
print(nomes[np.argsort(-notas)])

Saída

['Bia' 'Ana' 'Caio']

Em duas dimensões, o axis escolhe a direção da ordenação:

intermediario/cap20_ordenar_buscar.pylinhas 21 a 23
m = np.array([[3, 1, 2], [9, 8, 7]])
print(np.sort(m, axis=1))
print(np.sort(m, axis=0))

Saída

[[1 2 3]
 [7 8 9]]
[[3 1 2]
 [9 8 7]]

Ordenar por vários critérios

O np.lexsort ordena por várias chaves, e a última chave da tupla é a principal. Para ordenar por sobrenome e, em caso de empate, por idade, a idade vem primeiro:

intermediario/cap20_ordenar_buscar.pylinhas 28 a 31
sobrenome = np.array(["Silva", "Souza", "Silva", "Souza"])
idade = np.array([30, 25, 20, 40])
ordem = np.lexsort((idade, sobrenome))
print(ordem.tolist())

Saída

[2, 0, 1, 3]

Buscar em um array ordenado

O np.searchsorted encontra, por busca binária (rápida mesmo em milhões de elementos), a posição onde cada valor se encaixaria. Ele é a base para agrupar valores em faixas, e o np.digitize faz isso diretamente:

intermediario/cap20_ordenar_buscar.pylinhas 36 a 41
ordenado = np.array([10, 20, 30, 40])
print(np.searchsorted(ordenado, [5, 20, 25, 99]))

idades = np.array([5, 17, 18, 40, 65, 90])
faixas = np.digitize(idades, [18, 65])
print(faixas)

Saída

[0 1 2 4]
[0 0 1 1 2 2]

As faixas do digitize são: 0 abaixo de 18, 1 de 18 até 64 e 2 de 65 em diante.

Valores únicos e pertencimento

intermediario/cap20_ordenar_buscar.pylinhas 46 a 49
a = np.array([3, 1, 2, 3, 1])
print(np.unique(a), np.isin(a, [1, 2]))
valores, primeira, contagem = np.unique(a, return_index=True, return_counts=True)
print(valores, primeira, contagem)

Saída

[1 2 3] [False  True  True False  True]
[1 2 3] [1 2 0] [2 1 2]

O np.isin pergunta, para cada elemento, se ele está em um conjunto, e substitui o antigo np.in1d.

Os k maiores, sem ordenar tudo

Para achar os 3 maiores entre milhões de valores, ordenar tudo é desperdício. O np.argpartition coloca os k elementos desejados na frente, sem ordená-los, em tempo proporcional ao tamanho do array:

intermediario/cap20_ordenar_buscar.pylinhas 54 a 56
pontos = np.array([55, 91, 12, 78, 66, 99, 40])
topo3 = np.argpartition(-pontos, 3)[:3]
print(sorted(pontos[topo3].tolist(), reverse=True))

Saída

[99, 91, 78]

Ordenações estáveis

Se dois elementos são iguais, uma ordenação estável mantém a ordem original entre eles. Para garantir isso no argsort, passe kind="stable". Isso importa quando você ordena em etapas (primeiro por uma chave, depois por outra).

Exercício 1

Ranking dos melhores

Escreva ranking(nomes, notas, k) que devolva os nomes dos k melhores, do melhor para o pior.

Ver solução
intermediario/cap20_ordenar_buscar.pylinhas 61 a 68
def ranking(nomes, notas, k):
    return nomes[np.argsort(-notas)[:k]]


nomes = np.array(["Ana", "Bia", "Caio", "Davi"])
notas = np.array([7.5, 9.0, 6.0, 8.2])
assert ranking(nomes, notas, 2).tolist() == ["Bia", "Davi"]
print("ok")

Saída

ok

Capítulo 21, parte Intermediário

Condições vetorizadas: where, select e clip

O `if` não aceita um array. Para aplicar uma decisão a cada elemento, você usa funções que fazem a escolha para o array inteiro de uma vez.

Código deste capítulo: intermediario/cap21_condicoes.py

`np.where`: o `if` do array

Com três argumentos, o np.where(condição, se_verdadeiro, se_falso) escolhe, elemento a elemento, o valor de uma das duas saídas. Com um só argumento, devolve as posições onde a condição é verdadeira:

intermediario/cap21_condicoes.pylinhas 10 a 15
import numpy as np

notas = np.array([55, 72, 91, 40, 68])
print(np.where(notas >= 60, "aprovado", "reprovado"))
print(np.where(notas >= 60, notas, 0))
print(np.where(notas > 70))

Saída

['reprovado' 'aprovado' 'aprovado' 'reprovado' 'aprovado']
[ 0 72 91  0 68]
(array([1, 2]),)

O resultado de um argumento só é uma tupla com um array de posições por eixo, e por isso aparece o (array([1, 2]),).

`np.select`: várias faixas

Com mais de duas opções, encadear where vira bagunça. O np.select recebe uma lista de condições e uma lista de resultados, e usa o resultado da primeira condição verdadeira. Por isso a ordem importa: as condições mais restritivas vêm primeiro:

intermediario/cap21_condicoes.pylinhas 20 a 22
condicoes = [notas >= 90, notas >= 70, notas >= 60]
rotulos = ["A", "B", "C"]
print(np.select(condicoes, rotulos, default="D"))

Saída

['D' 'B' 'A' 'D' 'C']

Limitar e comparar elemento a elemento

O np.clip limita os valores a um intervalo. O np.maximum e o np.minimum comparam dois arrays (ou um array e um número) elemento a elemento, e o np.maximum(x, 0) é a função ReLU, uma das mais usadas em redes neurais:

intermediario/cap21_condicoes.pylinhas 27 a 30
print(np.maximum(notas, 50))

x = np.array([-2.0, -0.5, 0.5, 2.0])
print(np.piecewise(x, [x < 0, x >= 0], [lambda t: t ** 2, lambda t: t]))

Saída

[55 72 91 50 68]
[4.   0.25 0.5  2.  ]

O np.piecewise aplica uma função diferente em cada faixa, útil para funções definidas por partes.

A armadilha: `where` calcula os dois lados

O np.where avalia as duas expressões inteiras antes de escolher. Se uma delas gera um erro ou um aviso em algum elemento (uma divisão por zero), o aviso aparece mesmo que aquele elemento nunca fosse escolhido:

intermediario/cap21_condicoes.pylinhas 35 a 38
den = np.array([2.0, 0.0, 4.0])
with np.errstate(divide="ignore"):
    ingenuo = np.where(den != 0, 1 / den, 0.0)
print(ingenuo)

Saída

[0.5  0.   0.25]

Para não calcular onde não deve, use o where e o out da própria ufunc (capítulo 15):

intermediario/cap21_condicoes.pylinhas 40 a 41
seguro = np.divide(1.0, den, out=np.zeros_like(den), where=den != 0)
print(seguro)

Saída

[0.5  0.   0.25]

O resultado é o mesmo, mas a segunda forma nem tenta dividir por zero.

Exercício 1

Classificar o IMC

Escreva classificar_imc(imc) que devolva "abaixo" (menor que 18,5), "normal" (menor que 25), "sobrepeso" (menor que 30) ou "obesidade", para um array de valores, com np.select.

Ver solução
intermediario/cap21_condicoes.pylinhas 46 a 53
def classificar_imc(imc):
    condicoes = [imc < 18.5, imc < 25, imc < 30]
    return np.select(condicoes, ["abaixo", "normal", "sobrepeso"], default="obesidade")


resultado = classificar_imc(np.array([17.0, 22.0, 27.5, 33.0]))
assert resultado.tolist() == ["abaixo", "normal", "sobrepeso", "obesidade"]
print("ok")

Saída

ok

Capítulo 22, parte Intermediário

Estatística: percentis, histogramas e correlação

Resumir um conjunto de dados em poucos números, e descobrir se duas coisas variam juntas. O NumPy traz o essencial; o resto você encontra no pandas e no SciPy.

Código deste capítulo: intermediario/cap22_estatistica.py

Correlação entre duas variáveis

A correlação mede o quanto duas variáveis andam juntas, de -1 (andam em sentidos opostos) a +1 (andam juntas), com 0 significando que não há relação linear. O np.corrcoef devolve a matriz de correlações, e a diagonal é sempre 1 (cada variável com ela mesma):

intermediario/cap22_estatistica.pylinhas 10 a 18
import numpy as np

rng = np.random.default_rng(42)
altura = rng.normal(170, 10, 500)
peso = 0.9 * altura - 85 + rng.normal(0, 5, 500)

matriz = np.corrcoef(altura, peso)
print(matriz.round(2))
print(0.8 < matriz[0, 1] < 0.95)

Saída

[[1.   0.86]
 [0.86 1.  ]]
True

Eu construí o peso a partir da altura mais um ruído, então a correlação alta é esperada (perto de 0,87, em teoria). Correlação não é causa, e dois números podem andar juntos por coincidência ou porque um terceiro fator move os dois.

Histogramas

O np.histogram conta quantos valores caem em cada faixa. Você escolhe as faixas, com um número de faixas iguais ou com os limites exatos. Cada faixa inclui o limite esquerdo e exclui o direito (menos a última, que inclui os dois):

intermediario/cap22_estatistica.pylinhas 23 a 28
notas = np.array([1, 2, 2, 3, 3, 3, 4, 4, 5])
contagens, limites = np.histogram(notas, bins=[0, 2, 4, 6])
print(contagens, limites)

contagens, limites = np.histogram(altura, bins=5)
print(contagens.sum(), len(limites))

Saída

[1 5 3] [0 2 4 6]
500 6

O número de limites é sempre um a mais que o de faixas, e a soma das contagens é o total de valores.

Variações ao longo do tempo

O np.diff calcula a diferença entre elementos consecutivos, e dividi-la pelo valor anterior dá a variação percentual. O np.cumsum acumula uma soma ao longo do array:

intermediario/cap22_estatistica.pylinhas 33 a 35
precos = np.array([100.0, 102.0, 99.0, 105.0])
retornos = np.diff(precos) / precos[:-1]
print(retornos.round(4), np.cumsum([1, 2, 3, 4]))

Saída

[ 0.02   -0.0294  0.0606] [ 1  3  6 10]

O resultado tem um elemento a menos que a entrada, porque o primeiro preço não tem anterior.

Agrupar com `bincount`

O np.bincount conta ocorrências de cada inteiro, e com o parâmetro weights soma valores por grupo. É um GROUP BY do SQL em uma linha, para categorias numeradas de 0 em diante:

intermediario/cap22_estatistica.pylinhas 40 a 42
categorias = np.array([0, 1, 1, 2, 2, 2])
valores = np.array([10, 20, 30, 1, 2, 3])
print(np.bincount(categorias), np.bincount(categorias, weights=valores))

Saída

[1 2 3] [10. 50.  6.]

Detectar valores atípicos (outliers)

Uma regra clássica usa o intervalo interquartil (IQR, a distância entre o quartil de 25% e o de 75%): é atípico o que passa de 1,5 IQR além dos quartis. Ela resiste a extremos, ao contrário de uma regra baseada na média:

intermediario/cap22_estatistica.pylinhas 47 a 51
dados = np.array([10, 12, 11, 13, 12, 95])
q1, q3 = np.percentile(dados, [25, 75])
iqr = q3 - q1
limite = q3 + 1.5 * iqr
print(dados[dados > limite])

Saída

[95]

Média e desvio dependem dos extremos

O valor 95 desloca muito a média e o desvio padrão deste conjunto. Por isso, para achar outliers e para descrever dados assimétricos, eu prefiro mediana e quartis. O capítulo 14 mostrou o mesmo com salários.

Exercício 1

Detectar outliers dos dois lados

Escreva outliers(x, k=1.5) que devolva os valores abaixo de Q1 - k*IQR ou acima de Q3 + k*IQR.

Ver solução
intermediario/cap22_estatistica.pylinhas 56 a 65
def outliers(x, k=1.5):
    q1, q3 = np.percentile(x, [25, 75])
    iqr = q3 - q1
    return x[(x < q1 - k * iqr) | (x > q3 + k * iqr)]


assert outliers(np.array([10, 12, 11, 13, 12, 95])).tolist() == [95]
assert outliers(np.array([-80, 10, 12, 11, 13, 12])).tolist() == [-80]
assert outliers(np.array([1, 2, 3, 4, 5])).tolist() == []
print("ok")

Saída

ok

Capítulo 23, parte Intermediário

Álgebra linear

Resolver sistemas de equações, medir distâncias, ajustar uma reta aos dados: a álgebra linear está por baixo de quase tudo em ciência de dados, e o NumPy a entrega pronta.

Código deste capítulo: intermediario/cap23_algebra_linear.py

Multiplicação de matrizes

Há duas multiplicações, e confundi-las é um erro clássico. O * multiplica elemento a elemento. O @ (ou np.matmul) é a multiplicação de matrizes, linhas por colunas:

intermediario/cap23_algebra_linear.pylinhas 10 a 17
import numpy as np

A = np.array([[2.0, 1.0], [1.0, 3.0]])
b = np.array([3.0, 5.0])
print(A @ A)
print(A.T @ b)
print(A * A)
print(np.array_equal(A * A, A @ A))

Saída

[[ 5.  5.]
 [ 5. 10.]]
[11. 18.]
[[4. 1.]
 [1. 9.]]
False

Para vetores, o @ é o produto escalar. Com ele e com a norma (o comprimento do vetor) se calcula o cosseno do ângulo entre dois vetores, uma medida de similaridade usada em busca e recomendação:

intermediario/cap23_algebra_linear.pylinhas 19 a 21
u = np.array([3.0, 4.0])
v = np.array([1.0, 0.0])
print(u @ v, np.linalg.norm(u), (u @ v) / (np.linalg.norm(u) * np.linalg.norm(v)))

Saída

3.0 5.0 0.6

Resolver um sistema de equações

O sistema 2x + y = 3 e x + 3y = 5 se escreve como A @ x = b. O np.linalg.solve o resolve, e conferir é simples: multiplicar de volta e comparar:

intermediario/cap23_algebra_linear.pylinhas 26 a 27
x = np.linalg.solve(A, b)
print(x, np.allclose(A @ x, b))

Saída

[0.8 1.4] True

Não calcule a inversa para resolver sistemas

Muita gente escreve inv(A) @ b. Evite: calcular a inversa é mais lento e menos preciso do que o solve. Use o solve sempre que o objetivo for resolver um sistema, e deixe a inversa para quando você precisa dela de verdade.

Determinante, inversa e autovalores

intermediario/cap23_algebra_linear.pylinhas 32 a 35
print(round(np.linalg.det(A), 6))
print(np.round(np.linalg.inv(A), 3))
valores, vetores = np.linalg.eigh(A)
print(np.round(valores, 3))

Saída

5.0
[[ 0.6 -0.2]
 [-0.2  0.4]]
[1.382 3.618]

O eigh é a versão para matrizes simétricas, mais rápida e mais estável do que a geral (eig). Os autovalores descrevem, de forma resumida, "quanto a matriz estica" cada direção, e estão por trás de técnicas como a análise de componentes principais.

Ajustar uma reta: mínimos quadrados

Dado um conjunto de pontos com ruído, o np.linalg.lstsq encontra a reta que minimiza o erro quadrático. Basta montar a matriz com a coluna de x e uma coluna de uns (para o intercepto):

intermediario/cap23_algebra_linear.pylinhas 40 a 45
rng = np.random.default_rng(1)
xs = np.linspace(0, 10, 50)
ys = 3 * xs + 2 + rng.normal(0, 0.5, 50)
X = np.column_stack([xs, np.ones_like(xs)])
(coef, intercepto), *_ = np.linalg.lstsq(X, ys, rcond=None)
print(abs(coef - 3) < 0.1, abs(intercepto - 2) < 0.5)

Saída

True True

Eu gerei os dados com inclinação 3 e intercepto 2, mais ruído, e o ajuste os recupera com boa precisão. É o mesmo cálculo que o capítulo 40 compara com o scikit-learn.

Lotes de matrizes

O @ funciona em lotes: ele multiplica as duas últimas dimensões e trata as anteriores como lote (com broadcasting). É assim que uma rede neural processa vários exemplos de uma vez:

intermediario/cap23_algebra_linear.pylinhas 50 a 52
lote_a = np.ones((10, 3, 4))
lote_b = np.ones((10, 4, 2))
print((lote_a @ lote_b).shape)

Saída

(10, 3, 2)

Exercício 1

Projetar um vetor sobre outro

Escreva projetar(u, v), a projeção de u na direção de v: (u · v) / (v · v) * v.

Ver solução
intermediario/cap23_algebra_linear.pylinhas 57 a 65
def projetar(u, v):
    return (u @ v) / (v @ v) * v


p = projetar(np.array([3.0, 4.0]), np.array([1.0, 0.0]))
assert np.allclose(p, [3.0, 0.0])
resto = np.array([3.0, 4.0]) - p
assert np.isclose(resto @ np.array([1.0, 0.0]), 0.0)
print("ok")

Saída

ok

Capítulo 24, parte Intermediário

Ler e gravar arrays

Os dados vêm de arquivos e voltam para arquivos. O NumPy lê e grava texto e tem um formato binário próprio, mais rápido e mais fiel. A escolha entre os dois depende de quem vai ler o resultado.

Código deste capítulo: intermediario/cap24_ler_gravar.py

Texto: legível para pessoas e planilhas

O np.savetxt grava um array como texto, e o np.loadtxt lê de volta. O parâmetro fmt controla a formatação (sem ele, o padrão é uma notação científica enorme), e o header escreve uma linha de títulos:

intermediario/cap24_ler_gravar.pylinhas 10 a 19
from pathlib import Path

import numpy as np

dados = np.array([[1.5, 2.0], [3.25, 4.0]])
np.savetxt("tabela.csv", dados, delimiter=",", header="a,b", comments="", fmt="%.2f")
print(Path("tabela.csv").read_text(encoding="utf-8"))

lido = np.loadtxt("tabela.csv", delimiter=",", skiprows=1)
print(np.array_equal(lido, dados))

Saída

a,b
1.50,2.00
3.25,4.00

True

O texto é universal (qualquer planilha abre), mas tem dois custos: perde precisão (o fmt="%.2f" descartou casas) e é lento para arquivos grandes, porque converter texto em número custa.

Binário: o formato `.npy`

O np.save grava o array exatamente como está na memória, com o tipo e a forma, em um arquivo .npy. O np.load o devolve idêntico. É rápido, preciso e compacto:

intermediario/cap24_ler_gravar.pylinhas 24 a 26
np.save("matriz.npy", dados)
print(np.array_equal(np.load("matriz.npy"), dados))
print(Path("matriz.npy").stat().st_size > dados.nbytes)

Saída

True
True

O arquivo é só um pouco maior que os dados (nbytes), por causa de um cabeçalho pequeno que descreve o tipo e a forma. Para guardar vários arrays em um arquivo, use o np.savez (ou np.savez_compressed, que comprime), que cria um .npz, uma espécie de pasta de arrays com nome:

intermediario/cap24_ler_gravar.pylinhas 28 a 30
np.savez("pacote.npz", x=dados, y=np.arange(3))
with np.load("pacote.npz") as pacote:
    print(sorted(pacote.files), pacote["y"])

Saída

['x', 'y'] [0 1 2]
Formato Quando usar Limitações
Texto (.csv, .txt) Trocar dados com planilhas e com outras pessoas Lento, perde precisão, só 1D e 2D
.npy Guardar um array entre execuções Só o NumPy lê
.npz Guardar vários arrays nomeados Só o NumPy lê
Parquet, HDF5 Conjuntos grandes e tabelas (pandas, capítulo 38) Exigem bibliotecas

Dados faltando em texto

O np.genfromtxt lê arquivos de texto com lacunas, e as converte em nan. O loadtxt falharia nesse caso:

intermediario/cap24_ler_gravar.pylinhas 35 a 37
Path("faltando.csv").write_text("a,b\n1,2\n3,\n5,6\n", encoding="utf-8")
cru = np.genfromtxt("faltando.csv", delimiter=",", skip_header=1)
print(cru)

Saída

[[ 1.  2.]
 [ 3. nan]
 [ 5.  6.]]

Não carregue pickle de quem você não conhece

O np.load recusa, por padrão, arrays de objetos Python (allow_pickle=False), e eu mantenho assim. O formato pickle pode executar código arbitrário ao ser lido, e carregar um arquivo de origem desconhecida com ele ligado é um risco de segurança real.

Quando a tabela tem texto e números misturados

O NumPy sozinho é desajeitado para tabelas com colunas de tipos diferentes (nomes, datas, números). Para isso existe o pandas (capítulo 38), que lê CSV com pd.read_csv e devolve um DataFrame com rótulos.

Exercício 1

Gravar e conferir

Escreva ida_e_volta(a, pasta) que grave a em pasta/teste.npy, leia de volta e devolva True se o resultado for idêntico (mesmo tipo, mesma forma, mesmos valores).

Ver solução
intermediario/cap24_ler_gravar.pylinhas 42 a 55
import tempfile


def ida_e_volta(a, pasta):
    caminho = Path(pasta) / "teste.npy"
    np.save(caminho, a)
    lido = np.load(caminho)
    return lido.dtype == a.dtype and lido.shape == a.shape and np.array_equal(lido, a)


with tempfile.TemporaryDirectory() as pasta:
    assert ida_e_volta(np.arange(6, dtype=np.int16).reshape(2, 3), pasta)
    assert ida_e_volta(np.linspace(0, 1, 5), pasta)
print("ok")

Saída

ok

Capítulo 25, parte Intermediário

Dados ausentes: NaN e arrays mascarados

Dados reais têm buracos: um sensor que falhou, uma pergunta não respondida. O NumPy os representa com `nan`, e você precisa saber como ele se comporta, porque ele contamina quase tudo.

Código deste capítulo: intermediario/cap25_dados_ausentes.py

O `nan` contamina

O nan ("não é um número") é um valor decimal especial. Qualquer conta que o envolva dá nan, e essa propagação é o que o torna perigoso: um único buraco estraga uma média inteira. As versões "nan-aware" (nansum, nanmean, nanmax...) ignoram os buracos:

intermediario/cap25_dados_ausentes.pylinhas 10 a 13
import numpy as np

x = np.array([1.0, np.nan, 3.0, 4.0])
print(x.sum(), np.nansum(x), np.nanmean(x), np.nanmax(x))

Saída

nan 8.0 2.6666666666666665 4.0

Como detectar (e a pegadinha da igualdade)

O nan é diferente de si mesmo: nan == nan é falso. Por isso x == np.nan nunca encontra nada, e a detecção correta é com np.isnan:

intermediario/cap25_dados_ausentes.pylinhas 18 a 19
print(np.isnan(x), np.isnan(x).sum())
print(np.nan == np.nan, np.isnan(np.nan))

Saída

[False  True False False] 1
False True

Remover ou preencher

Duas estratégias comuns: descartar as posições com nan, ou preencher com um valor razoável (a média, a mediana, o valor anterior). Qual escolher é uma decisão sobre o seu problema, e não uma regra do NumPy. Descartar perde informação, e preencher a inventa:

intermediario/cap25_dados_ausentes.pylinhas 24 a 27
limpo = x[~np.isnan(x)]
preenchido = np.where(np.isnan(x), np.nanmean(x), x)
print(limpo, preenchido.round(2))
print(x > 2)

Saída

[1. 3. 4.] [1.   2.67 3.   4.  ]
[False False  True  True]

Repare no último resultado: uma comparação com nan dá sempre False, e por isso uma máscara como x > 2 descarta os buracos em silêncio, sem avisar.

Só decimais têm `nan`

Os inteiros não têm representação para "ausente". Um array de inteiros não pode guardar nan, e colocar um nan em uma lista de inteiros a converte para decimal:

intermediario/cap25_dados_ausentes.pylinhas 32 a 36
print(np.array([1, 2, np.nan]).dtype)
try:
    np.array([1, 2, 3])[0] = np.nan
except ValueError as erro:
    print(erro)

Saída

float64
cannot convert float NaN to integer

Arrays mascarados

O np.ma oferece uma alternativa: um array com uma máscara que marca quais posições devem ser ignoradas, funcionando também para inteiros. Ele é útil quando os dados ausentes vêm marcados por um valor especial, como -999:

intermediario/cap25_dados_ausentes.pylinhas 41 a 45
m = np.ma.masked_invalid(x)
print(m, m.mean(), m.mask)

sentinela = np.ma.masked_equal([1, -999, 3], -999)
print(sentinela, sentinela.mean())

Saída

[1.0 -- 3.0 4.0] 2.6666666666666665 [False  True False False]
[1 -- 3] 2.0

O -- marca a posição ignorada, e a média considera só os valores válidos.

Valores sentinela são uma armadilha

Valores como -999 ou 0 para "sem dado" são um perigo: se alguém esquecer de tratá-los, entram nas contas como números de verdade. Converta-os para nan (ou mascare-os) na entrada dos dados, uma única vez, e não confie na memória de quem usar depois.

Exercício 1

Média por coluna ignorando buracos

Escreva media_por_coluna(m) que calcule a média de cada coluna ignorando os nan.

Ver solução
intermediario/cap25_dados_ausentes.pylinhas 50 a 56
def media_por_coluna(m):
    return np.nanmean(m, axis=0)


dados = np.array([[1.0, np.nan], [3.0, 4.0], [5.0, 8.0]])
assert media_por_coluna(dados).tolist() == [3.0, 6.0]
print("ok")

Saída

ok

Capítulo 26, parte Intermediário

Tipos estruturados e datas

Um array tem um tipo só, mas esse tipo pode ser composto (um "registro" com campos) ou ser uma data. Os dois resolvem problemas reais, e mostram até onde o NumPy vai antes de o pandas fazer sentido.

Código deste capítulo: intermediario/cap26_estruturados_datas.py

Registros com campos

Um dtype estruturado define campos com nome e tipo, e cada elemento do array é um registro. Dá para ler um campo inteiro como um array comum, filtrar e ordenar por campo:

intermediario/cap26_estruturados_datas.pylinhas 10 a 19
import numpy as np

pessoas = np.array(
    [("Ana", 30, 1.65), ("Bia", 25, 1.70)],
    dtype=[("nome", "U10"), ("idade", "i4"), ("altura", "f8")],
)
print(pessoas["nome"], pessoas["idade"].mean())
print(pessoas[pessoas["idade"] > 26])
print(pessoas.dtype.names, pessoas.itemsize)
print(np.sort(pessoas, order="idade")["nome"])

Saída

['Ana' 'Bia'] 27.5
[('Ana', 30, 1.65)]
('nome', 'idade', 'altura') 52
['Bia' 'Ana']

O itemsize é a soma dos campos: 40 bytes do nome (U10 são 10 caracteres de 4 bytes), 4 da idade e 8 da altura. Os registros ficam contíguos na memória, o que é bom para ler e gravar, e o acesso por campo é rápido.

Quando o conjunto de dados tem muitas colunas, tipos variados ou precisa de rótulos, o DataFrame do pandas faz tudo isso com mais conforto, e o NumPy estruturado é mais usado em interface com formatos binários e com código em C.

Datas e durações

O datetime64 guarda datas como números inteiros, com uma unidade que você escolhe (D para dias, M para meses, s para segundos). O timedelta64 guarda durações. A subtração de duas datas dá uma duração, e somar uma duração a uma data dá outra data:

intermediario/cap26_estruturados_datas.pylinhas 24 a 27
d = np.array(["2026-01-15", "2026-02-20"], dtype="datetime64[D]")
print(d, d.dtype)
print(d[1] - d[0])
print(np.datetime64("2026-03-01") + np.timedelta64(30, "D"))

Saída

['2026-01-15' '2026-02-20'] datetime64[D]
36 days
2026-03-31

Sequências de datas e conversão de unidade

O arange funciona com datas. E o astype troca a unidade, o que é útil para agrupar por mês:

intermediario/cap26_estruturados_datas.pylinhas 32 a 33
print(np.arange("2026-01-01", "2026-01-06", dtype="datetime64[D]"))
print(d.astype("datetime64[M]"))

Saída

['2026-01-01' '2026-01-02' '2026-01-03' '2026-01-04' '2026-01-05']
['2026-01' '2026-02']

Dias úteis

O NumPy conhece o calendário de dias úteis (segunda a sexta, por padrão), e conta e soma dias úteis diretamente. Feriados entram como uma lista:

intermediario/cap26_estruturados_datas.pylinha 38
print(np.busday_count("2026-10-01", "2026-10-31"))

Saída

22

De 1º a 30 de outubro de 2026 (o fim é excluído) existem 22 dias úteis.

Fusos horários

O datetime64 não tem fuso horário: ele guarda um instante sem dizer em que fuso ele está. Para trabalhar com fusos, use o módulo datetime com timezone/zoneinfo (capítulo 39 do curso de Python) ou o pandas, e guarde tudo em UTC.

Exercício 1

Dias entre duas datas

Escreva dias_entre(inicio, fim) que receba duas datas no formato "2026-01-01" e devolva a quantidade de dias como um inteiro Python.

Ver solução
intermediario/cap26_estruturados_datas.pylinhas 43 a 49
def dias_entre(inicio, fim):
    return int((np.datetime64(fim) - np.datetime64(inicio)).astype(int))


assert dias_entre("2026-01-01", "2026-10-06") == 278
assert dias_entre("2026-10-06", "2026-10-06") == 0
print("ok")

Saída

ok

Capítulo 27, parte Intermediário

Indexação avançada e malhas

Quando a pergunta é "o maior de cada linha, e onde ele está", a indexação simples não basta. Estas ferramentas resolvem esses casos sem laço.

Código deste capítulo: intermediario/cap27_indexacao_avancada.py

Pegar o melhor de cada linha

O argmax(axis=1) dá a posição do maior valor em cada linha. Para pegar os valores nessas posições, o take_along_axis usa as posições ao longo de um eixo, e o [:, None] as prepara para combinar por broadcasting:

intermediario/cap27_indexacao_avancada.pylinhas 10 a 15
import numpy as np

notas = np.array([[7, 9, 5], [4, 8, 6]])
melhor = notas.argmax(axis=1)
print(melhor, np.take_along_axis(notas, melhor[:, None], axis=1).ravel())
print(np.take_along_axis(notas, np.argsort(notas, axis=1), axis=1))

Saída

[1 1] [9 8]
[[5 7 9]
 [4 6 8]]

A segunda linha ordena cada linha pela sua própria ordem, algo que o np.sort(axis=1) também faria. A vantagem do take_along_axis aparece quando você quer reordenar outro array com as posições de um primeiro.

Selecionar linhas e colunas ao mesmo tempo

Com duas listas, a indexação fancy combina par a par (capítulo 9). Para o produto cartesiano (todas as combinações de linhas e colunas escolhidas), o np.ix_ monta os índices certos:

intermediario/cap27_indexacao_avancada.pylinhas 20 a 21
m = np.arange(16).reshape(4, 4)
print(m[np.ix_([0, 2], [1, 3])])

Saída

[[ 1  3]
 [ 9 11]]

O resultado é o bloco formado pelas linhas 0 e 2 e pelas colunas 1 e 3.

Malhas de coordenadas

O np.meshgrid gera, a partir de dois vetores, duas grades com todas as coordenadas de um plano. É a base para calcular uma função em uma região inteira e depois desenhá-la:

intermediario/cap27_indexacao_avancada.pylinhas 26 a 30
x = np.linspace(-1, 1, 3)
y = np.linspace(-1, 1, 3)
X, Y = np.meshgrid(x, y)
print(X)
print(np.hypot(X, Y).round(2))

Saída

[[-1.  0.  1.]
 [-1.  0.  1.]
 [-1.  0.  1.]]
[[1.41 1.   1.41]
 [1.   0.   1.  ]
 [1.41 1.   1.41]]

O np.hypot(X, Y) calcula a distância de cada ponto da malha à origem. O resultado é a distância sobre uma grade, sem nenhum laço.

Onde está o máximo?

O argmax de um array 2D devolve a posição no array achatado. O np.unravel_index a converte em coordenadas (linha, coluna). E o np.argwhere lista todas as posições que satisfazem uma condição:

intermediario/cap27_indexacao_avancada.pylinhas 35 a 38
plano = np.array([[3, 8, 1], [9, 2, 7]])
pos = np.unravel_index(plano.argmax(), plano.shape)
print(pos, plano[pos])
print(np.argwhere(plano > 6))

Saída

(np.int64(1), np.int64(0)) 9
[[0 1]
 [1 0]
 [1 2]]

Repare na forma como o NumPy 2 imprime a tupla: cada coordenada aparece como np.int64(...), porque elas são escalares NumPy, e não inteiros do Python. É só a representação, e as contas funcionam igual. Em NumPy 1.x, você veria apenas os números.

One-hot por indexação

Transformar rótulos numéricos em vetores "one-hot" (um 1 na posição da classe, zeros no resto) é uma operação comum em aprendizado de máquina, e a indexação fancy de uma matriz identidade a faz em uma linha:

intermediario/cap27_indexacao_avancada.pylinhas 43 a 44
rotulos = np.array([0, 2, 1])
print(np.eye(4)[rotulos])

Saída

[[1. 0. 0. 0.]
 [0. 0. 1. 0.]
 [0. 1. 0. 0.]]

Exercício 1

Ordenar as linhas pela soma

Escreva ordenar_linhas_pela_soma(m) que devolva a matriz com as linhas reordenadas da menor para a maior soma.

Ver solução
intermediario/cap27_indexacao_avancada.pylinhas 49 a 55
def ordenar_linhas_pela_soma(m):
    return m[np.argsort(m.sum(axis=1))]


m = np.array([[5, 5], [1, 1], [3, 2]])
assert ordenar_linhas_pela_soma(m).tolist() == [[1, 1], [3, 2], [5, 5]]
print("ok")

Saída

ok

Capítulo 28, parte Intermediário

Janelas deslizantes e séries

Médias móveis, máximos de uma janela, filtros de imagem: tudo isso é "olhar um pedaço, andar um passo, repetir". O NumPy faz isso sem laço, e sem copiar os dados.

Código deste capítulo: intermediario/cap28_janelas_series.py

`sliding_window_view`

A função cria uma visão com todas as janelas de um tamanho dado, uma por linha. Agregando cada janela, você obtém a média móvel:

intermediario/cap28_janelas_series.pylinhas 10 a 17
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view

serie = np.array([1, 2, 3, 4, 5, 6], dtype=float)
janelas = sliding_window_view(serie, 3)
print(janelas)
print(janelas.mean(axis=1))
print(np.shares_memory(serie, janelas), janelas.flags["WRITEABLE"])

Saída

[[1. 2. 3.]
 [2. 3. 4.]
 [3. 4. 5.]
 [4. 5. 6.]]
[2. 3. 4. 5.]
True False

As janelas não foram copiadas: o último resultado mostra que compartilham memória com a série. E ela é somente leitura, porque várias janelas apontam para os mesmos números, e escrever em uma bagunçaria as outras. Essa visão é possível por causa dos strides (capítulo 29).

Duas outras formas de média móvel

A soma acumulada permite calcular qualquer média móvel em uma passada, qualquer que seja o tamanho da janela. A convolução é a forma "clássica" de processamento de sinais:

intermediario/cap28_janelas_series.pylinhas 22 a 28
def media_movel(x, k):
    acumulada = np.cumsum(np.insert(x, 0, 0.0))
    return (acumulada[k:] - acumulada[:-k]) / k


print(np.allclose(media_movel(serie, 3), janelas.mean(axis=1)))
print(np.convolve(serie, np.ones(3) / 3, mode="valid"))

Saída

True
[2. 3. 4. 5.]
Técnica Memória Quando usar
sliding_window_view + agregação Visão, sem cópia, mas a agregação percorre todas as janelas Qualquer função (média, máximo, mediana)
cumsum Uma passada Somas e médias, com janela de qualquer tamanho
np.convolve Cria o resultado Filtros lineares

Acumuladores: máximo corrido e queda máxima

Os acumuladores guardam o resultado até aqui. O np.maximum.accumulate dá o maior valor visto até cada ponto. Com ele se calcula a queda máxima (drawdown) de uma série de preços: a maior perda desde um pico anterior, uma medida de risco comum em finanças:

intermediario/cap28_janelas_series.pylinhas 33 a 36
precos = np.array([100, 110, 105, 120, 90, 95], dtype=float)
pico = np.maximum.accumulate(precos)
queda = (precos - pico) / pico
print(pico, queda.min().round(3))

Saída

[100. 110. 110. 120. 120. 120.] -0.25

A queda máxima foi de 25%: do pico de 120 para 90.

Janelas em duas dimensões

A mesma função aceita uma janela com uma dimensão por eixo, e é a base de filtros de imagem. Aqui, cada bloco de 3 por 3 de uma imagem de 5 por 5 vira uma janela:

intermediario/cap28_janelas_series.pylinhas 41 a 43
img = np.arange(25, dtype=float).reshape(5, 5)
blocos = sliding_window_view(img, (3, 3))
print(blocos.shape, blocos.mean(axis=(-1, -2))[0, 0])

Saída

(3, 3, 3, 3) 6.0

São 3 × 3 = 9 janelas possíveis, cada uma com 3 × 3 valores. O projeto do capítulo 42 usa isso para desfocar e detectar bordas em uma imagem.

Exercício 1

Máximo móvel

Escreva maximo_movel(x, k) que devolva o maior valor de cada janela de tamanho k.

Ver solução
intermediario/cap28_janelas_series.pylinhas 48 a 54
def maximo_movel(x, k):
    return sliding_window_view(x, k).max(axis=1)


assert maximo_movel(np.array([1, 3, 2, 5, 4]), 2).tolist() == [3, 3, 5, 5]
assert maximo_movel(np.array([1, 3, 2, 5, 4]), 3).tolist() == [3, 5, 5]
print("ok")

Saída

ok

Capítulo 29, parte Avançado

Memória por dentro: strides, contiguidade e ordem

Um array é um bloco de bytes mais uma descrição de como lê-lo. Quando você entende essa descrição, entende por que fatiar é de graça, por que transpor é de graça, e por que algumas operações são mais lentas do que deveriam.

Código deste capítulo: avancado/cap29_memoria_strides.py

O array é um bloco mais uma lista de `strides`

Os dados ficam em um bloco contínuo de memória. O que dá forma a ele são os strides: quantos bytes pular para avançar uma posição em cada eixo. Um array de 3 por 4 de int64 (8 bytes por elemento) em ordem de linhas (ordem C, o padrão) avança 8 bytes para a próxima coluna e 32 (4 colunas × 8) para a próxima linha. A transposta não move um byte: só troca os strides:

avancado/cap29_memoria_strides.pylinhas 10 a 16
import numpy as np

a = np.arange(12, dtype=np.int64).reshape(3, 4)
print(a.strides, a.flags["C_CONTIGUOUS"], a.flags["F_CONTIGUOUS"])

t = a.T
print(t.strides, t.flags["C_CONTIGUOUS"], t.flags["F_CONTIGUOUS"], np.shares_memory(a, t))

Saída

(32, 8) True False
(8, 32) False True True

A transposta é contígua em ordem Fortran (F, a ordem de colunas usada em Fortran e em MATLAB): os elementos da mesma coluna estão lado a lado. Os dois arrays compartilham a memória, e é por isso que a.T custa quase nada, mesmo em uma matriz de gigabytes.

Fatiar só muda os strides

Uma fatia com passo é uma nova leitura do mesmo bloco, com outros strides. Nenhum dado se move:

avancado/cap29_memoria_strides.pylinha 21
print(a[:, ::2].strides, a[::2].strides, a[:, 0].strides)

Saída

(32, 16) (64, 8) (32,)

Pegar uma linha em cada duas dobra o stride do eixo das linhas (de 32 para 64), e pegar uma coluna em cada duas dobra o das colunas (de 8 para 16). E a coluna 0 isolada tem um stride de 32 bytes: para percorrê-la, o NumPy salta pela memória, e esse salto tem um custo.

Por que a contiguidade importa

O processador lê a memória em linhas de cache (blocos de 64 bytes). Percorrer elementos vizinhos aproveita cada linha de cache inteira. Percorrer com saltos grandes desperdiça: a cada elemento, uma linha de cache nova. Compare somar a mesma quantidade de elementos, contíguos e espaçados:

avancado/cap29_memoria_strides.pylinhas 26 a 34
import timeit

x = np.ones(16_000_000, dtype=np.float32)
contiguo = x[:1_000_000]
espacado = x[::16]
print(contiguo.size == espacado.size)
t_contiguo = min(timeit.repeat(lambda: contiguo.sum(), number=5, repeat=5))
t_espacado = min(timeit.repeat(lambda: espacado.sum(), number=5, repeat=5))
print("o contíguo foi mais rápido:", t_contiguo < t_espacado)

Saída

True
o contíguo foi mais rápido: True

São os mesmos 1 milhão de elementos, mas o espaçado precisa percorrer 16 vezes mais memória. Quando uma operação é inesperadamente lenta, os strides são o primeiro suspeito, e np.ascontiguousarray(x) faz uma cópia contígua quando vale a pena.

Flag Significa
C_CONTIGUOUS Linhas contínuas na memória (padrão)
F_CONTIGUOUS Colunas contínuas na memória
OWNDATA Este array é dono da sua memória (não é uma visão)
WRITEABLE Pode ser alterado (broadcast_to e janelas são somente leitura)

Reinterpretar bytes com `view`

O view com outro dtype não converte os valores: lê os mesmos bytes de outro jeito. É útil para inspecionar a representação interna de números e para processar dados binários:

avancado/cap29_memoria_strides.pylinhas 39 a 43
inteiros = np.array([1, 256, 65536], dtype=np.int32)
print(inteiros.view(np.uint8))

bits = np.array([1.0], dtype=np.float32).view(np.uint32)[0]
print(hex(int(bits)))

Saída

[1 0 0 0 0 1 0 0 0 0 1 0]
0x3f800000

O resultado do primeiro view mostra que o NumPy guarda os inteiros em ordem little-endian nesta máquina (o byte menos significativo primeiro). O 0x3f800000 é a representação binária do número 1,0 em float32, segundo o padrão IEEE 754.

`as_strided`: poder com perigo

A função as_strided constrói qualquer visão que você descrever com shape e strides. Com ela você monta janelas deslizantes à mão. É a base do que o sliding_window_view do capítulo 28 faz com segurança:

avancado/cap29_memoria_strides.pylinhas 48 a 52
from numpy.lib.stride_tricks import as_strided

y = np.arange(6)
janelas = as_strided(y, shape=(4, 3), strides=(y.strides[0], y.strides[0]))
print(janelas)

Saída

[[0 1 2]
 [1 2 3]
 [2 3 4]
 [3 4 5]]

Não use as_strided sem necessidade

O as_strided não verifica nada. Uma forma ou um stride que ultrapasse o bloco de memória lê (ou escreve) bytes que não pertencem ao array, sem erro e sem aviso. Isso corrompe dados ou derruba o programa de forma imprevisível. Prefira sempre o sliding_window_view, que calcula tudo para você, e reserve o as_strided para quem tem uma razão forte e testa com muito cuidado.

Exercício 1

Descrever a memória

Escreva passos_em_bytes(a) que devolva os strides de um array e e_visao(a) que diga se ele não é dono dos seus dados. Teste com np.zeros((2, 3), dtype=np.int16) e uma fatia dele.

Ver solução
avancado/cap29_memoria_strides.pylinhas 57 a 70
def passos_em_bytes(a):
    return a.strides


def e_visao(a):
    return not a.flags["OWNDATA"]


z = np.zeros((2, 3), dtype=np.int16)
assert passos_em_bytes(z) == (6, 2)
assert not e_visao(z)
assert e_visao(z[:, 1:])
assert passos_em_bytes(z[:, ::2]) == (6, 4)
print("ok")

Saída

ok

Capítulo 30, parte Avançado

einsum e tensordot

O `einsum` descreve uma operação entre arrays por uma pequena fórmula com letras, e substitui dezenas de combinações de `transpose`, `reshape` e `sum` por uma linha legível.

Código deste capítulo: avancado/cap30_einsum.py

A fórmula

Você nomeia cada eixo com uma letra e diz o que quer no resultado. A regra é curta: letras repetidas entre as entradas são multiplicadas, e letras que não aparecem na saída são somadas. A multiplicação de matrizes é o exemplo clássico: "ij,jk->ik" multiplica pelo j (repetido) e o soma (porque o j não está na saída):

avancado/cap30_einsum.pylinhas 10 a 15
import numpy as np

A = np.arange(6).reshape(2, 3)
B = np.arange(12).reshape(3, 4)
print(np.einsum("ij,jk->ik", A, B))
print(np.array_equal(np.einsum("ij,jk->ik", A, B), A @ B))

Saída

[[20 23 26 29]
 [56 68 80 92]]
True

Um dicionário de padrões

Com a mesma regra, a fórmula expressa dezenas de operações:

avancado/cap30_einsum.pylinhas 20 a 25
M = np.arange(9).reshape(3, 3)
print(np.einsum("ii->", M), np.einsum("ii->i", M), np.einsum("ij->i", M), np.einsum("ij->j", M))

v = np.array([1, 2, 3])
w = np.array([4, 5, 6])
print(np.einsum("i,i->", v, w), np.einsum("i,j->ij", v, w))

Saída

12 [0 4 8] [ 3 12 21] [ 9 12 15]
32 [[ 4  5  6]
 [ 8 10 12]
 [12 15 18]]
Fórmula Operação
"ij->ji" Transposta
"ii->" Traço (soma da diagonal)
"ii->i" Diagonal
"ij->i" Soma de cada linha
"ij->j" Soma de cada coluna
"i,i->" Produto escalar
"i,j->ij" Produto externo (todas as combinações)
"bij,bjk->bik" Multiplicação de matrizes em lote

O caso de uso real: atenção em redes neurais

O cálculo central dos modelos de linguagem compara cada "consulta" com cada "chave", em um lote. Em fórmula, "bqd,bkd->bqk": para cada exemplo b, multiplica o vetor de dimensão d de cada consulta q por cada chave k. Sem o einsum, você precisaria transpor a última dimensão à mão:

avancado/cap30_einsum.pylinhas 30 a 34
rng = np.random.default_rng(0)
Q = rng.normal(size=(2, 5, 8))
K = rng.normal(size=(2, 7, 8))
scores = np.einsum("bqd,bkd->bqk", Q, K)
print(scores.shape, np.allclose(scores, Q @ K.transpose(0, 2, 1)))

Saída

(2, 5, 7) True

A fórmula documenta os eixos: cada letra tem um significado, e quem lê entende a operação sem decifrar uma sequência de transpose.

`tensordot` e a ordem das multiplicações

O np.tensordot soma sobre os eixos que você indica (o parâmetro axes), e para duas matrizes com axes=1 equivale ao @. A mesma operação pode ser muito mais barata dependendo da ordem em que se multiplica uma cadeia de matrizes, porque a multiplicação é associativa, mas o custo não é:

avancado/cap30_einsum.pylinhas 39 a 51
import timeit

print(np.allclose(np.tensordot(A, B, axes=1), A @ B))

C1 = rng.normal(size=(2000, 3))
C2 = rng.normal(size=(3, 2000))
C3 = rng.normal(size=(2000, 3))
esquerda = lambda: (C1 @ C2) @ C3
direita = lambda: C1 @ (C2 @ C3)
print(np.allclose(esquerda(), direita()))
t_esq = min(timeit.repeat(esquerda, number=1, repeat=3))
t_dir = min(timeit.repeat(direita, number=1, repeat=3))
print("a ordem importa:", t_esq > 3 * t_dir)

Saída

True
True
a ordem importa: True

O primeiro jeito cria uma matriz de 2000 por 2000 no meio do caminho, e o segundo reduz tudo a uma matriz de 3 por 3 antes. O resultado é o mesmo, e o custo, muito diferente. O np.linalg.multi_dot escolhe a melhor ordem para você, e o einsum com optimize=True faz o mesmo.

Quando eu não uso einsum

Para uma multiplicação de matrizes simples, o @ é mais claro e usa as bibliotecas otimizadas diretamente. O einsum compensa quando há mais de duas entradas, eixos de lote ou somas incomuns, e quando a fórmula documenta melhor do que o código equivalente.

Exercício 1

Similaridade do cosseno entre todos os pares

Escreva similaridade_cosseno(X) que receba uma matriz com um vetor por linha e devolva a matriz n × n de cossenos entre todos os pares. Use np.einsum para os produtos escalares.

Ver solução
avancado/cap30_einsum.pylinhas 56 a 68
def similaridade_cosseno(X):
    produtos = np.einsum("ik,jk->ij", X, X)
    normas = np.linalg.norm(X, axis=1)
    return produtos / np.outer(normas, normas)


X = np.array([[1.0, 0.0], [0.0, 2.0], [3.0, 3.0]])
S = similaridade_cosseno(X)
assert np.allclose(np.diag(S), 1.0)
assert np.allclose(S, S.T)
assert np.isclose(S[0, 1], 0.0)
assert np.isclose(S[0, 2], np.sqrt(2) / 2)
print("ok")

Saída

ok

Capítulo 31, parte Avançado

Vetorizar problemas difíceis

Vetorizar uma soma é fácil. Vetorizar "a distância entre todos os pares de pontos" ou "a soma por grupo" exige repensar o problema, e quase sempre cobra o preço em memória.

Código deste capítulo: avancado/cap31_vetorizar_dificil.py

Todas as distâncias entre pontos

A versão ingênua tem dois laços (cada ponto contra cada ponto). A versão vetorizada usa broadcasting: pontos[:, None, :] (forma (n, 1, d)) menos pontos[None, :, :] (forma (1, n, d)) dá todas as diferenças em um array (n, n, d):

avancado/cap31_vetorizar_dificil.pylinhas 10 a 20
import numpy as np

rng = np.random.default_rng(0)
pontos = rng.random((4, 2))

dif = pontos[:, None, :] - pontos[None, :, :]
dist = np.sqrt((dif ** 2).sum(axis=-1))
print(dist.shape, np.allclose(np.diag(dist), 0), np.allclose(dist, dist.T))

lento = np.array([[np.linalg.norm(p - q) for q in pontos] for p in pontos])
print(np.allclose(dist, lento))

Saída

(4, 4) True True
True

A distância de cada ponto a si mesmo é zero (diagonal), e a matriz é simétrica. A versão vetorizada confere com a de laços.

O preço: o array intermediário

O dif tem n × n × d números. Para 4 pontos, nada. Para 10 mil pontos em 2 dimensões:

avancado/cap31_vetorizar_dificil.pylinhas 25 a 26
n = 10_000
print(n * n * 2 * 8 / 1e9, "GB")

Saída

1.6 GB

São 1,6 GB só para um intermediário, antes de qualquer conta, e o programa estoura a memória. A vetorização trocou tempo por memória, e essa é a regra mais importante deste capítulo: para cada vetorização, pergunte qual é o maior array intermediário.

Uma identidade que evita o intermediário

Como |a − b|² = |a|² + |b|² − 2·a·b, dá para calcular todas as distâncias com uma multiplicação de matrizes, sem o array (n, n, d). O resultado intermediário é n × n, e a multiplicação usa as bibliotecas de álgebra linear mais rápidas:

avancado/cap31_vetorizar_dificil.pylinhas 31 a 37
def distancias(X):
    q = (X ** 2).sum(axis=1)
    d2 = q[:, None] + q[None, :] - 2 * X @ X.T
    return np.sqrt(np.maximum(d2, 0))


print(np.allclose(distancias(pontos), dist))

Saída

True

O np.maximum(d2, 0) existe porque erros de arredondamento podem produzir números levemente negativos onde o valor verdadeiro é zero, e a raiz de um negativo daria nan.

Quando ainda não cabe: processar em blocos

Se o n × n também não cabe, processe o problema em blocos: um laço curto sobre fatias de linhas, com a operação vetorizada dentro. Para achar o vizinho mais próximo de cada ponto de X em Y, o bloco limita o intermediário a bloco × len(Y) × d:

avancado/cap31_vetorizar_dificil.pylinhas 42 a 54
def mais_proximo(X, Y, bloco=500):
    resultado = np.empty(len(X), dtype=np.int64)
    for inicio in range(0, len(X), bloco):
        parte = X[inicio : inicio + bloco]
        d2 = ((parte[:, None, :] - Y[None, :, :]) ** 2).sum(axis=-1)
        resultado[inicio : inicio + bloco] = d2.argmin(axis=1)
    return resultado


X = rng.random((50, 3))
Y = rng.random((30, 3))
por_forca_bruta = np.array([np.argmin(((Y - x) ** 2).sum(axis=1)) for x in X])
print(np.array_equal(mais_proximo(X, Y, bloco=7), por_forca_bruta))

Saída

True

Aqui o bloco é 7 de propósito, para o laço girar várias vezes e o teste exercitar a divisão. Em um problema real, o tamanho do bloco é ajustado ao orçamento de memória.

Agrupar sem laço: `reduceat`

Para somar valores por grupo quando os grupos não são inteiros pequenos (o caso do bincount), ordene por grupo e use o add.reduceat, que soma trechos consecutivos:

avancado/cap31_vetorizar_dificil.pylinhas 59 a 65
grupos = np.array([2, 0, 1, 0, 2, 1])
valores = np.array([10.0, 20.0, 30.0, 40.0, 50.0, 60.0])
ordem = np.argsort(grupos, kind="stable")
g = grupos[ordem]
v = valores[ordem]
inicios = np.r_[0, np.flatnonzero(np.diff(g)) + 1]
print(g[inicios], np.add.reduceat(v, inicios))

Saída

[0 1 2] [60. 90. 60.]

Depois de ordenar, cada grupo ocupa um trecho contínuo. O np.diff(g) é diferente de zero onde o grupo muda, e esses são os inícios dos trechos.

Problema Ferramenta Custo de memória
Todos os pares Broadcasting [:, None] n² (cuidado)
Distâncias Identidade com @ n², mas sem o fator d
Cabe em blocos Laço sobre fatias + vetorização Limitado pelo bloco
Soma por grupo (inteiros pequenos) bincount(weights=...) Pequeno
Soma por grupo (qualquer rótulo) argsort + reduceat Cópia ordenada

Exercício 1

Os k maiores de cada linha

Escreva top_k_por_linha(M, k) que devolva, para cada linha, as posições dos k maiores valores, do maior para o menor. Use np.argpartition e depois ordene só os k.

Ver solução
avancado/cap31_vetorizar_dificil.pylinhas 70 a 79
def top_k_por_linha(M, k):
    candidatos = np.argpartition(-M, k - 1, axis=1)[:, :k]
    valores = np.take_along_axis(M, candidatos, axis=1)
    ordem = np.argsort(-valores, axis=1)
    return np.take_along_axis(candidatos, ordem, axis=1)


M = np.array([[5, 1, 9, 3], [2, 8, 4, 7]])
assert top_k_por_linha(M, 2).tolist() == [[2, 0], [1, 3]]
print("ok")

Saída

ok

Capítulo 32, parte Avançado

Desempenho: temporários, in-place e dtypes

O NumPy já é rápido. O que o torna lento, quando acontece, é quase sempre memória: arrays temporários demais, tipos grandes demais, percursos fora de ordem.

Código deste capítulo: avancado/cap32_desempenho_numpy.py

O custo dos temporários

Cada operação aritmética cria um array novo para o resultado. Em uma expressão com várias operações, o NumPy cria vários temporários, cada um do tamanho do array original. Para arrays grandes, isso significa memória gasta e páginas novas sendo alocadas. A alternativa é escrever no mesmo buffer, com out= e operadores in-place (+=, -=):

avancado/cap32_desempenho_numpy.pylinhas 10 a 35
import time
import tracemalloc

import numpy as np

rng = np.random.default_rng(0)
a = rng.random(2_000_000)
b = rng.random(2_000_000)


def com_temporarios(a, b):
    return (a * 2 + b * 3 - 1) ** 2


def sem_temporarios(a, b, t1, t2):
    np.multiply(a, 2, out=t1)
    np.multiply(b, 3, out=t2)
    t1 += t2
    t1 -= 1
    np.square(t1, out=t1)
    return t1


t1 = np.empty_like(a)
t2 = np.empty_like(a)
print(np.allclose(com_temporarios(a, b), sem_temporarios(a, b, t1, t2)))

Saída

True

O resultado é o mesmo. A diferença está na memória: a versão com buffers reutilizados não aloca arrays novos a cada passo. Dá para medir o pico de memória de cada uma:

avancado/cap32_desempenho_numpy.pylinhas 37 a 48
def pico_de_memoria(funcao):
    tracemalloc.start()
    funcao()
    _, pico = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return pico


pico_com = pico_de_memoria(lambda: com_temporarios(a, b))
pico_sem = pico_de_memoria(lambda: sem_temporarios(a, b, t1, t2))
print("o pico é menor sem temporários:", pico_sem < pico_com)
print("e muito menor, menos de 10% do outro:", pico_sem < 0.1 * pico_com)

Saída

o pico é menor sem temporários: True
e muito menor, menos de 10% do outro: True

Esse padrão (alocar os buffers uma vez, fora de um laço, e reutilizá-los) é o que bibliotecas de alto desempenho fazem. Para código que roda uma vez, a expressão simples é mais legível e basta. Eu só uso out= quando a medição mostra que a memória ou as alocações importam.

Escolher o dtype certo

O tamanho do tipo define quanta memória o array ocupa, e quanto precisa ser lido da memória a cada operação. Para a maioria dos problemas de aprendizado de máquina, float32 basta, e usa metade:

avancado/cap32_desempenho_numpy.pylinhas 53 a 55
a32 = a.astype(np.float32)
print(a32.nbytes == a.nbytes // 2)
print(np.allclose(a32, a, atol=1e-6))

Saída

True
True

O custo é a precisão (capítulo 7). Não troque o tipo sem saber quanta precisão o seu problema exige.

Iterar um array em Python é lento

Percorrer um array com for cria um objeto escalar do NumPy a cada passo, e isso é caro. Quando um laço Python é inevitável, converter para lista antes com .tolist() o deixa mais rápido. E funções Python como o sum sobre um array são muito mais lentas do que o método do próprio array:

avancado/cap32_desempenho_numpy.pylinhas 60 a 71
def medir(funcao, repeticoes=3):
    melhor = float("inf")
    for _ in range(repeticoes):
        inicio = time.perf_counter()
        funcao()
        melhor = min(melhor, time.perf_counter() - inicio)
    return melhor


x = np.arange(100_000)
print("iterar a lista é mais rápido:", medir(lambda: [v for v in x.tolist()]) < medir(lambda: [v for v in x]))
print("sum do Python é pelo menos 10 vezes mais lento:", medir(lambda: sum(x)) > 10 * medir(lambda: x.sum()))

Saída

iterar a lista é mais rápido: True
sum do Python é pelo menos 10 vezes mais lento: True

O que mais existe

Técnica Ganho Observação
out= e in-place Menos alocação e menos memória Menos legível, use onde a medição pedir
float32 em vez de float64 Metade da memória e da leitura Perde precisão
Percorrer na ordem da memória Aproveita o cache Capítulo 29
Operações em blocos Cabe na memória e no cache Capítulo 31
Bibliotecas de álgebra linear @ usa BLAS multi-thread Já vem com o NumPy
numexpr, numba, Cython Fundem operações ou compilam laços Bibliotecas separadas, para quando o gargalo é comprovado

A ordem que eu sigo

Primeiro, o algoritmo (a vetorização certa). Depois, a memória (tipos e temporários). Só então ferramentas externas de compilação. E, entre uma etapa e outra, medir (capítulo 18): o gargalo quase nunca está onde a intuição aponta.

Exercício 1

Softmax estável e vetorizado

Escreva softmax(x) para uma matriz, aplicando a cada linha. Subtraia o máximo da linha antes do exp para evitar overflow, e confira que funciona com valores enormes.

Ver solução
avancado/cap32_desempenho_numpy.pylinhas 76 a 87
def softmax(x):
    deslocado = x - x.max(axis=-1, keepdims=True)
    e = np.exp(deslocado)
    return e / e.sum(axis=-1, keepdims=True)


x = np.array([[1.0, 2.0, 3.0], [1000.0, 1001.0, 1002.0]])
s = softmax(x)
assert np.allclose(s.sum(axis=1), 1.0)
assert np.isfinite(s).all()
assert np.allclose(s[0], s[1])
print("ok")

Saída

ok

Capítulo 33, parte Avançado

Arquivos maiores que a memória: memmap

Quando o conjunto de dados não cabe na memória, você não precisa carregá-lo. O `memmap` trata um arquivo em disco como um array, e o sistema operacional traz para a memória só o pedaço que você toca.

Código deste capítulo: avancado/cap33_memmap.py

Um arquivo que se comporta como array

O np.memmap mapeia um arquivo binário na memória. Você escreve e lê como em um array comum, e o sistema operacional cuida de transferir as páginas do disco sob demanda. Aqui, um arquivo com um milhão de float32:

avancado/cap33_memmap.pylinhas 10 a 19
from pathlib import Path

import numpy as np

caminho = Path("grande.bin")
mm = np.memmap(caminho, dtype=np.float32, mode="w+", shape=(1_000_000,))
mm[:] = np.arange(1_000_000, dtype=np.float32)
mm.flush()
print(caminho.stat().st_size, mm.shape)
del mm

Saída

4000000 (1000000,)

O arquivo tem exatamente 1.000.000 × 4 bytes: o memmap é só os dados, sem cabeçalho, e por isso você precisa informar o tipo e a forma ao abri-lo de novo. O flush garante que as alterações cheguem ao disco, e o del solta o arquivo.

Abrir só para ler

Os modos mais usados são "r" (somente leitura), "r+" (leitura e escrita em um arquivo existente) e "w+" (cria ou apaga o arquivo e abre para escrita). No modo de leitura, o array é protegido contra alterações acidentais:

avancado/cap33_memmap.pylinhas 24 a 29
lido = np.memmap(caminho, dtype=np.float32, mode="r", shape=(1_000_000,))
print(lido[999_999], lido[:3], isinstance(lido, np.ndarray))
try:
    lido[0] = 1.0
except ValueError as erro:
    print(erro)

Saída

999999.0 [0. 1. 2.] True
assignment destination is read-only

Um memmap é um ndarray, e por isso aceita tudo o que você já aprendeu: fatias, máscaras, agregações.

Processar em blocos

Mesmo mapeado, um cálculo sobre o array inteiro pode precisar de memória para os intermediários. A solução é percorrer o arquivo em blocos, acumulando o resultado. Aqui, a média de um milhão de valores com no máximo 250 mil em memória por vez:

avancado/cap33_memmap.pylinhas 34 a 41
def media_em_blocos(mm, bloco=250_000):
    total = 0.0
    for inicio in range(0, len(mm), bloco):
        total += float(mm[inicio : inicio + bloco].sum(dtype=np.float64))
    return total / len(mm)


print(media_em_blocos(lido), np.arange(1_000_000, dtype=np.float64).mean())

Saída

499999.5 499999.5

O sum(dtype=np.float64) acumula em precisão dupla, mesmo que os dados sejam float32, para o erro de arredondamento não crescer com o tamanho.

`np.load` com `mmap_mode`

Um arquivo .npy (capítulo 24) já carrega o tipo e a forma no cabeçalho. Com mmap_mode="r", o np.load o abre como memmap sem você repetir esses dados:

avancado/cap33_memmap.pylinhas 46 a 48
np.save("grande.npy", np.arange(1_000_000, dtype=np.float32))
vista = np.load("grande.npy", mmap_mode="r")
print(type(vista).__name__, vista[10])

Saída

memmap 10.0

Esse costuma ser o jeito mais prático: guarde em .npy e abra com mmap_mode.

Limpeza

Um memmap mantém o arquivo aberto. No Windows, não dá para apagar um arquivo enquanto algum array ainda o mapeia, então solte as referências antes:

avancado/cap33_memmap.pylinhas 53 a 56
del lido, vista
for nome in ("grande.bin", "grande.npy"):
    Path(nome).unlink()
print(Path("grande.bin").exists())

Saída

False
Quando usar memmap Quando não
Arquivo maior que a memória O dado cabe com folga: carregue de uma vez, é mais rápido
Acesso a pedaços (uma janela, algumas linhas) Você vai ler tudo muitas vezes: o disco é bem mais lento que a memória
Vários processos lendo o mesmo dado Dados em formato de tabela: use Parquet com pandas

Exercício 1

Máximo de um arquivo, em blocos

Escreva maximo_em_blocos(caminho, dtype, tamanho, bloco) que abra o arquivo como memmap somente leitura e devolva o maior valor, lendo bloco elementos por vez. Teste gravando um arquivo temporário.

Ver solução
avancado/cap33_memmap.pylinhas 61 a 77
import tempfile


def maximo_em_blocos(caminho, dtype, tamanho, bloco=1000):
    mm = np.memmap(caminho, dtype=dtype, mode="r", shape=(tamanho,))
    melhor = -np.inf
    for inicio in range(0, tamanho, bloco):
        melhor = max(melhor, float(mm[inicio : inicio + bloco].max()))
    del mm
    return melhor


with tempfile.TemporaryDirectory() as pasta:
    arquivo = Path(pasta) / "teste.bin"
    np.arange(5000, dtype=np.int32).tofile(arquivo)
    assert maximo_em_blocos(arquivo, np.int32, 5000, bloco=700) == 4999.0
print("ok")

Saída

ok

Capítulo 34, parte Avançado

Ufuncs por dentro

Uma ufunc é mais do que uma função elementar. Ela carrega métodos que acumulam, reduzem e combinam, e um protocolo que permite a outras classes se comportarem como arrays.

Código deste capítulo: avancado/cap34_ufuncs_por_dentro.py

Os métodos de uma ufunc

Toda ufunc binária tem métodos que generalizam a operação. O reduce aplica a operação ao longo de um eixo até sobrar um valor. O accumulate guarda os resultados parciais. O outer aplica a operação a todos os pares:

avancado/cap34_ufuncs_por_dentro.pylinhas 10 a 14
import numpy as np

v = np.array([1, 2, 3, 4])
print(np.add.reduce(v), np.multiply.reduce(v), np.add.accumulate(v), np.multiply.accumulate(v))
print(np.multiply.outer([1, 2, 3], [1, 2, 3]))

Saída

10 24 [ 1  3  6 10] [ 1  2  6 24]
[[1 2 3]
 [2 4 6]
 [3 6 9]]

O np.add.reduce é o sum, o np.multiply.reduce é o produto, e o np.add.accumulate é o cumsum. Essas funções "de atalho" são só nomes convenientes para os métodos. A vantagem de conhecer os métodos é que eles funcionam com qualquer ufunc: np.maximum.accumulate (o máximo corrido do capítulo 28), np.logical_and.reduce (todos verdadeiros?), np.multiply.outer (uma tabuada).

A armadilha dos índices repetidos

Esta é uma das surpresas mais famosas do NumPy. Quando um índice aparece mais de uma vez, a atribuição a[indices] += 1 não acumula: cada elemento recebe o resultado de uma só leitura. A razão é que a expressão lê a[indices], soma 1 e escreve de volta, tudo em três passos separados:

avancado/cap34_ufuncs_por_dentro.pylinhas 19 a 22
a = np.zeros(3, dtype=int)
indices = np.array([0, 0, 1, 2, 2, 2])
a[indices] += 1
print(a)

Saída

[1 1 1]

O esperado seria [2 1 3] (o índice 0 apareceu duas vezes, o 2, três). O np.add.at faz a operação sem buffer, e acumula corretamente. E, para contar, o bincount é a ferramenta feita para isso:

avancado/cap34_ufuncs_por_dentro.pylinhas 24 a 27
b = np.zeros(3, dtype=int)
np.add.at(b, indices, 1)
print(b)
print(np.bincount(indices, minlength=3))

Saída

[2 1 3]
[2 1 3]

O np.add.at serve para casos gerais (somar valores em posições repetidas), e o bincount é quase sempre a opção mais rápida para contagens e somas por grupo de inteiros pequenos.

`np.vectorize` não torna nada mais rápido

O np.vectorize transforma uma função que trabalha com um valor em uma que aceita arrays. É uma conveniência de interface: por baixo, ele continua chamando a função Python uma vez por elemento, e por isso não tem a velocidade de uma vetorização de verdade:

avancado/cap34_ufuncs_por_dentro.pylinhas 32 a 49
import timeit


def passos_collatz(n):
    passos = 0
    while n != 1:
        n = n // 2 if n % 2 == 0 else 3 * n + 1
        passos += 1
    return passos


vetorizada = np.vectorize(passos_collatz)
print(vetorizada(np.array([6, 7, 27])))

entradas = np.arange(1, 2000)
t_vec = min(timeit.repeat(lambda: vetorizada(entradas), number=1, repeat=3))
t_laco = min(timeit.repeat(lambda: [passos_collatz(int(n)) for n in entradas], number=1, repeat=3))
print("vectorize não é muito mais rápido que o laço:", t_vec > 0.5 * t_laco)

Saída

[  8  16 111]
vectorize não é muito mais rápido que o laço: True

Uma função com while e desvios (como a de Collatz) não tem forma vetorizada simples, e o np.vectorize só a deixa mais cômoda de chamar. Para velocidade de verdade, as opções são reescrever o algoritmo de forma vetorizada, ou compilar o laço com uma ferramenta externa (como o Numba).

`__array_ufunc__`: como outras classes entram no jogo

Quando você chama np.sqrt(objeto) com um objeto que não é um array, o NumPy pergunta ao objeto o que fazer, chamando o método __array_ufunc__ dele. É assim que bibliotecas como pandas, Dask e CuPy deixam as funções do NumPy funcionarem em estruturas próprias. Um exemplo mínimo, um invólucro que "embrulha" o resultado de volta:

avancado/cap34_ufuncs_por_dentro.pylinhas 54 a 64
class Embrulhado:
    def __init__(self, dados):
        self.dados = np.asarray(dados)

    def __array_ufunc__(self, ufunc, metodo, *entradas, **kwargs):
        abertas = [x.dados if isinstance(x, Embrulhado) else x for x in entradas]
        return Embrulhado(getattr(ufunc, metodo)(*abertas, **kwargs))


r = np.sqrt(Embrulhado([4, 9]))
print(type(r).__name__, r.dados)

Saída

Embrulhado [2. 3.]

O np.sqrt entregou ao objeto a ufunc, o método ("__call__" aqui) e as entradas, e o objeto decidiu: abrir, calcular e embrulhar de novo. Esse é o contrato que torna o ecossistema interoperável.

Exercício 1

Contar por grupo

Escreva contar_por_grupo(indices, n) com np.add.at e confira que o resultado é igual ao do np.bincount.

Ver solução
avancado/cap34_ufuncs_por_dentro.pylinhas 69 a 78
def contar_por_grupo(indices, n):
    contagem = np.zeros(n, dtype=int)
    np.add.at(contagem, indices, 1)
    return contagem


indices = np.array([3, 1, 3, 3, 0, 1])
assert contar_por_grupo(indices, 5).tolist() == [1, 2, 0, 3, 0]
assert np.array_equal(contar_por_grupo(indices, 5), np.bincount(indices, minlength=5))
print("ok")

Saída

ok

Capítulo 35, parte Avançado

Aleatoriedade avançada

Reproduzir um experimento, rodá-lo em paralelo sem repetir números e estimar uma incerteza: tudo isso exige entender como os geradores de números aleatórios se organizam.

Código deste capítulo: avancado/cap35_aleatoriedade_avancada.py

Fluxos independentes com `SeedSequence`

Quando várias partes do programa (ou vários processos) precisam de números aleatórios, não use sementes "consecutivas" (1, 2, 3...). O jeito documentado é partir de uma semente raiz e derivar fluxos independentes com spawn. A mesma raiz reproduz os mesmos fluxos, e eles não se sobrepõem:

avancado/cap35_aleatoriedade_avancada.pylinhas 10 a 19
import numpy as np

raiz = np.random.SeedSequence(2026)
geradores = [np.random.default_rng(filho) for filho in raiz.spawn(3)]
amostras = [g.random(2000) for g in geradores]
print(len(amostras), abs(np.corrcoef(amostras)[0, 1]) < 0.1)

a = np.random.default_rng(np.random.SeedSequence(2026).spawn(3)[1]).random(2)
b = np.random.default_rng(np.random.SeedSequence(2026).spawn(3)[1]).random(2)
print(np.array_equal(a, b))

Saída

3 True
True

O próprio gerador também sabe se dividir, com o método spawn, que devolve novos geradores independentes:

avancado/cap35_aleatoriedade_avancada.pylinhas 21 a 22
filhos = np.random.default_rng(1).spawn(2)
print(type(filhos[0]).__name__)

Saída

Generator

Em um programa paralelo, você cria os filhos no processo principal e entrega um a cada trabalhador. Assim cada um tem o seu fluxo, e o resultado total é reproduzível, não importa a ordem em que os trabalhadores terminem.

Guardar e restaurar o estado

O estado do gerador pode ser salvo e restaurado, o que permite retomar uma simulação do ponto em que parou:

avancado/cap35_aleatoriedade_avancada.pylinhas 27 a 31
rng = np.random.default_rng(1)
estado = rng.bit_generator.state
primeiro = rng.random(2)
rng.bit_generator.state = estado
print(np.array_equal(primeiro, rng.random(2)))

Saída

True

Simulação de Monte Carlo

Muitos problemas sem fórmula fechada se resolvem sorteando muitas vezes e contando. O clássico é estimar π: sorteie pontos em um quadrado de lado 1, e a fração que cai dentro do quarto de círculo vale π/4. A conta inteira é vetorizada:

avancado/cap35_aleatoriedade_avancada.pylinhas 36 a 46
import math


def estimar_pi(n, rng):
    xy = rng.random((n, 2))
    dentro = (xy ** 2).sum(axis=1) <= 1
    return 4 * dentro.mean()


estimativa = estimar_pi(1_000_000, np.random.default_rng(0))
print(abs(estimativa - math.pi) < 0.01)

Saída

True

O erro cai com a raiz quadrada do número de sorteios: para ganhar uma casa decimal de precisão, você precisa de cem vezes mais amostras. Dá para ver isso repetindo o experimento e medindo o quanto as estimativas variam:

avancado/cap35_aleatoriedade_avancada.pylinhas 48 a 53
def dispersao(n, repeticoes=30):
    rng = np.random.default_rng(3)
    return np.std([estimar_pi(n, rng) for _ in range(repeticoes)])


print("mais amostras, menos variação:", dispersao(100_000) < dispersao(1_000))

Saída

mais amostras, menos variação: True

Bootstrap: incerteza sem fórmula

Como saber o quanto confiar em uma média calculada de uma amostra? O bootstrap reamostra a própria amostra, com reposição, muitas vezes, calcula a média de cada reamostra e usa a dispersão delas como medida da incerteza. A matriz de reamostras é gerada de uma vez, e a média é tirada ao longo de um eixo:

avancado/cap35_aleatoriedade_avancada.pylinhas 58 a 62
rng = np.random.default_rng(5)
amostra = rng.normal(100, 15, 200)
medias = rng.choice(amostra, size=(2000, amostra.size), replace=True).mean(axis=1)
baixo, alto = np.percentile(medias, [2.5, 97.5])
print(baixo < amostra.mean() < alto, alto - baixo < 10)

Saída

True True

O intervalo entre os percentis 2,5% e 97,5% é um intervalo de confiança de 95% para a média. Aqui a largura ficou abaixo de 10, perto do esperado pela teoria (cerca de 4, pois o erro padrão é 15 / √200).

Amostrar de uma distribuição qualquer

Se você sabe a função de distribuição acumulada, a transformada inversa transforma sorteios uniformes em sorteios da distribuição que quiser. Para a exponencial, -ln(1 − u) / λ:

avancado/cap35_aleatoriedade_avancada.pylinhas 67 a 69
lam = 2.0
x = -np.log(1 - rng.random(100_000)) / lam
print(abs(x.mean() - 1 / lam) < 0.01)

Saída

True

Registre o gerador e a versão

Para um experimento que precisa ser repetido anos depois, guarde a semente raiz, o tipo de gerador (o padrão é o PCG64) e a versão do NumPy. A mesma semente reproduz os mesmos números na mesma versão, mas o projeto reserva o direito de mudar os algoritmos entre versões do Generator.

Exercício 1

Intervalo de confiança por bootstrap

Escreva intervalo_bootstrap(x, rng, n=1000, nivel=0.95) que devolva o par (baixo, alto) de um intervalo de confiança para a média.

Ver solução
avancado/cap35_aleatoriedade_avancada.pylinhas 74 a 84
def intervalo_bootstrap(x, rng, n=1000, nivel=0.95):
    medias = rng.choice(x, size=(n, x.size), replace=True).mean(axis=1)
    cauda = (1 - nivel) / 2 * 100
    return tuple(np.percentile(medias, [cauda, 100 - cauda]))


dados = np.random.default_rng(9).normal(50, 10, 300)
baixo, alto = intervalo_bootstrap(dados, np.random.default_rng(10))
assert baixo < dados.mean() < alto
assert alto - baixo < 5
print("ok")

Saída

ok

Capítulo 36, parte Avançado

Testes numéricos e estabilidade

Em aritmética de ponto flutuante, `a == b` quase nunca é a pergunta certa, e a ordem em que você soma muda o resultado. Código numérico exige uma forma própria de testar.

Código deste capítulo: avancado/cap36_testes_numericos.py

Igualdade com tolerância

Dois números calculados por caminhos diferentes raramente são idênticos até o último bit. Em vez de ==, compare com tolerância. A regra do isclose é |a − b| <= atol + rtol × |b|, com rtol=1e-5 e atol=1e-8 por padrão. A parte relativa serve para números grandes, e a absoluta, para números perto de zero:

avancado/cap36_testes_numericos.pylinhas 10 a 15
import math

import numpy as np

print(0.1 + 0.2 == 0.3, np.isclose(0.1 + 0.2, 0.3), np.allclose([1.0, 2.0], [1.0 + 1e-9, 2.0]))
print(np.isclose(1e-10, 0.0), np.isclose(1e-10, 0.0, atol=0))

Saída

False True True
True False

Comparando com zero, a tolerância relativa não ajuda (qualquer coisa é infinitamente maior que zero em termos relativos), e é a parte absoluta que decide. Por isso um teste que compara com zero precisa de um atol escolhido conscientemente.

`numpy.testing`

Para testes automatizados, o módulo numpy.testing dá mensagens úteis: em vez de um simples False, ele mostra quais elementos diferem e por quanto:

avancado/cap36_testes_numericos.pylinhas 20 a 26
from numpy.testing import assert_allclose

assert_allclose(np.array([1.0, 2.0]), np.array([1.0 + 1e-9, 2.0]), rtol=1e-6)
try:
    assert_allclose(np.array([1.0, 2.0]), np.array([1.0, 2.1]))
except AssertionError as erro:
    print("Mismatched elements: 1 / 2 (50%)" in str(erro))

Saída

True

Dentro do pytest, prefira assert_allclose a assert np.allclose(...): quando o teste falha, você vê o motivo, e não apenas "assertion failed".

A ordem da soma muda o resultado

A adição de decimais não é associativa: (a + b) + c pode diferir de a + (b + c). Um número pequeno somado a um enorme "some" (perde-se na precisão), e se o enorme for cancelado depois, o pequeno já foi embora:

avancado/cap36_testes_numericos.pylinhas 31 a 32
x = np.array([1e16, 1.0, -1e16])
print(x.sum(), x[0] + x[2] + x[1], math.fsum(x))

Saída

0.0 1.0 1.0

A soma de esquerda para direita deu 0, e somando primeiro os dois grandes deu 1, o resultado correto, que o math.fsum (uma soma de alta precisão) também acha. O NumPy usa uma soma "em pares" (pairwise) para arrays grandes, que reduz esse erro em comparação com uma soma sequencial:

avancado/cap36_testes_numericos.pylinhas 34 a 35
grande = np.ones(20_000_000, dtype=np.float32)
print(np.cumsum(grande)[-1], grande.sum())

Saída

1.6777216e+07 2e+07

O cumsum soma sequencialmente em float32 e trava em 16.777.216 (a partir daí, somar 1 não muda nada, como vimos no capítulo 7). O sum, com a soma em pares, chega ao valor certo de 20 milhões (o Python imprime 2e+07). Conhecer isso evita acusar o NumPy de "errar" uma conta que só é imprecisa no tipo que você escolheu.

Cancelamento catastrófico

Subtrair dois números quase iguais destrói dígitos significativos. Quando existe uma função feita para o caso, use-a. O np.log1p(x) calcula log(1 + x) com precisão mesmo para x minúsculo, e o expm1 faz o mesmo para exp(x) − 1:

avancado/cap36_testes_numericos.pylinhas 40 a 41
y = 1e-10
print(np.log(1 + y), np.log1p(y))

Saída

1.000000082690371e-10 9.999999999500001e-11

O primeiro resultado perdeu quase metade dos dígitos: o 1 + y foi arredondado antes do logaritmo.

Estabilidade numérica: o softmax

Calcular o softmax ingenuamente estoura com valores grandes, porque o exp(1000) é infinito, e inf / inf é nan. Subtrair o máximo antes (que não muda o resultado matemático) resolve:

avancado/cap36_testes_numericos.pylinhas 46 a 50
z = np.array([1000.0, 1001.0, 1002.0])
with np.errstate(over="ignore", invalid="ignore"):
    ingenuo = np.exp(z) / np.exp(z).sum()
estavel = np.exp(z - z.max()) / np.exp(z - z.max()).sum()
print(ingenuo, estavel.round(4))

Saída

[nan nan nan] [0.09   0.2447 0.6652]

Verificar um gradiente por diferenças finitas

Quando você escreve o gradiente de uma função à mão (o caso das redes neurais do capítulo 43), é fácil errar um sinal. Uma checagem confiável compara o gradiente analítico com a diferença finita: (f(w + h) − f(w − h)) / 2h, uma estimativa numérica da derivada:

avancado/cap36_testes_numericos.pylinhas 55 a 78
def f(w):
    return (w ** 3).sum() + 2 * w[0] * w[1]


def gradiente_analitico(w):
    g = 3 * w ** 2
    g[0] += 2 * w[1]
    g[1] += 2 * w[0]
    return g


def gradiente_numerico(funcao, w, h=1e-6):
    g = np.zeros_like(w)
    for i in range(w.size):
        mais = w.copy()
        mais[i] += h
        menos = w.copy()
        menos[i] -= h
        g[i] = (funcao(mais) - funcao(menos)) / (2 * h)
    return g


w = np.array([1.0, -2.0, 0.5])
print(np.allclose(gradiente_analitico(w), gradiente_numerico(f, w), atol=1e-5))

Saída

True
Prática Por que
assert_allclose em vez de == Tolerância, e mensagem útil
Escolher atol ao comparar com zero A parte relativa não ajuda
Testar propriedades (a soma do softmax é 1; ordenar duas vezes é igual a ordenar uma) Funciona sem saber a resposta exata
Sementes fixas nos testes Falhas reproduzíveis
Comparar com uma versão lenta e obviamente correta A de laços valida a vetorizada

Exercício 1

Testar o softmax por propriedades

Escreva softmax estável e verifique, com assert_allclose, que cada linha soma 1 e que somar uma constante às entradas não muda o resultado (invariância ao deslocamento).

Ver solução
avancado/cap36_testes_numericos.pylinhas 83 a 92
def softmax(x):
    e = np.exp(x - x.max(axis=-1, keepdims=True))
    return e / e.sum(axis=-1, keepdims=True)


x = np.random.default_rng(0).normal(size=(4, 6))
assert_allclose(softmax(x).sum(axis=1), 1.0)
assert_allclose(softmax(x), softmax(x + 100.0))
assert_allclose(softmax(np.array([[1000.0, 1000.0]])), [[0.5, 0.5]])
print("ok")

Saída

ok

Capítulo 37, parte Avançado

Tipagem, API e o NumPy 2

Como declarar o que uma função aceita e devolve, como validar formas em tempo de execução, e o que mudou na versão 2 do NumPy, porque grande parte do material que você encontra ainda usa a versão 1.

Código deste capítulo: avancado/cap37_tipagem_numpy2.py

Anotar funções que usam arrays

O módulo numpy.typing oferece dois nomes que resolvem quase tudo. O ArrayLike é "qualquer coisa que o NumPy converta em array" (listas, tuplas, números, arrays), e é o tipo certo para entradas. O NDArray[...] é um array de um tipo específico, e é o tipo certo para saídas. O padrão é aceitar generosamente e devolver com precisão, convertendo na entrada com np.asarray:

avancado/cap37_tipagem_numpy2.pylinhas 10 a 20
import numpy as np
import numpy.typing as npt


def normalizar(x: npt.ArrayLike) -> npt.NDArray[np.float64]:
    a = np.asarray(x, dtype=np.float64)
    return (a - a.mean()) / a.std()


print(normalizar([1, 2, 3]).round(3))
print(normalizar(np.array([10, 20, 30])).round(3))

Saída

[-1.225  0.     1.225]
[-1.225  0.     1.225]

O np.asarray não copia o que já é um array do tipo certo (capítulo 10), e converte listas, de modo que a função aceita as duas formas sem custo extra. Um verificador de tipos como o mypy confere as anotações, mas não consegue verificar a forma (quantas dimensões, quantas linhas): o sistema de tipos do NumPy conhece o tipo dos elementos, e não as dimensões.

Validar a forma em tempo de execução

Como a forma não é checada estaticamente, uma função que depende dela deve validar na entrada, com uma mensagem clara. É melhor falhar no começo, com a explicação, do que mais adiante, com um erro de broadcasting incompreensível. Um None na forma esperada significa "qualquer tamanho nesse eixo":

avancado/cap37_tipagem_numpy2.pylinhas 25 a 34
def exigir_forma(a, esperada):
    if a.ndim != len(esperada) or any(e is not None and e != s for e, s in zip(esperada, a.shape)):
        raise ValueError(f"forma {a.shape} incompatível com {esperada}")
    return a


try:
    exigir_forma(np.zeros((3, 4)), (None, 5))
except ValueError as erro:
    print(erro)

Saída

forma (3, 4) incompatível com (None, 5)

Eu também documento a forma esperada na docstring, no estilo X : (n_amostras, n_características), que é a convenção do scikit-learn.

O que mudou no NumPy 2

A versão 2.0 foi a primeira mudança incompatível em muitos anos. Muitos nomes antigos foram removidos para deixar a API mais enxuta, e outros comportamentos mudaram. Eu verifiquei, na versão 2.4 usada neste livro, quais nomes comuns não existem mais:

avancado/cap37_tipagem_numpy2.pylinhas 39 a 41
removidos = ["float_", "NaN", "Inf", "in1d", "trapz", "round_", "msort"]
print({nome: hasattr(np, nome) for nome in removidos})
print(hasattr(np, "trapezoid"), hasattr(np, "isin"))

Saída

{'float_': False, 'NaN': False, 'Inf': False, 'in1d': False, 'trapz': False, 'round_': False, 'msort': False}
True True
Nome antigo Use agora
np.float_, np.complex_ np.float64, np.complex128
np.NaN, np.Inf, np.PINF np.nan, np.inf
np.in1d np.isin
np.trapz np.trapezoid
np.round_ np.round
np.msort(a) np.sort(a, axis=0)

Outras mudanças que aparecem na prática:

  • A representação dos escalares. O repr de um escalar agora mostra o tipo, e o print continua igual. Em um resultado como (np.int64(1), np.int64(0)) os números são os mesmos de sempre.
  • Promoção de tipos (NEP 50). Um número Python puro não promove o tipo do array (capítulo 7).
  • O inteiro padrão é int64 também no Windows. Na série 1.x, o Windows usava int32, e o mesmo código dava resultados diferentes entre sistemas.
  • copy=False mudou de significado. Antes: "evite copiar, se puder". Agora: "nunca copie, e falhe se for preciso". Para o comportamento antigo, use copy=None (o padrão) ou np.asarray.
avancado/cap37_tipagem_numpy2.pylinhas 43 a 52
print(repr(np.float64(3.0)), str(np.float64(3.0)))

lista = [1, 2, 3]
try:
    np.array(lista, copy=False)
except ValueError as erro:
    print("copy=False falha quando é preciso copiar:", type(erro).__name__)

x = np.array([1.0, 2.0])
print(np.asarray(x, copy=False) is x, np.array(x, copy=None) is x)

Saída

np.float64(3.0) 3.0
copy=False falha quando é preciso copiar: ValueError
True True

A versão 2 também trouxe funções novas alinhadas ao padrão de arrays da comunidade, como o np.unstack (o inverso do stack), o np.vecdot e o np.linalg.matrix_transpose:

avancado/cap37_tipagem_numpy2.pylinha 54
print(np.unstack(np.arange(6).reshape(2, 3)))

Saída

(array([0, 1, 2]), array([3, 4, 5]))

Achar o código antigo automaticamente

Você não precisa caçar os nomes antigos à mão. O ruff tem uma regra de migração, a NPY201, que marca cada uso de um nome removido e, em vários casos, sugere (ou aplica com --fix) a troca. Este arquivo mistura nomes da série 1:

exemplos/velho_numpy1.py
import numpy as np

x = np.float_(3)
y = np.NaN
z = np.in1d([1], [1])
w = np.trapz([1, 2])
Terminal
cd exemplos
ruff check --select NPY201 velho_numpy1.py --output-format concise

Saída

velho_numpy1.py:3:5: NPY201 [*] `np.float_` will be removed in NumPy 2.0. Use `numpy.float64` instead.
velho_numpy1.py:4:5: NPY201 [*] `np.NaN` will be removed in NumPy 2.0. Use `numpy.nan` instead.
velho_numpy1.py:5:5: NPY201 `np.in1d` will be removed in NumPy 2.0. Use `np.isin` instead. Unlike `np.in1d`, `np.isin` preserves the shape of its input, so `np.in1d(ar1, ar2)` is equivalent to `np.isin(ar1, ar2).ravel()`.
velho_numpy1.py:6:5: NPY201 `np.trapz` will be removed in NumPy 2.0. Use `numpy.trapezoid` on NumPy 2.0, or ignore this warning on earlier versions.
Found 4 errors.
[*] 2 fixable with the `--fix` option (1 hidden fix can be enabled with the `--unsafe-fixes` option).

Dependências que ainda não migraram

Se uma biblioteca que você usa foi compilada contra a série 1.x, ela pode quebrar com o NumPy 2. Ao atualizar um projeto, veja se as suas dependências declaram suporte ao NumPy 2, e fixe numpy>=2 no pyproject.toml só depois de conferir. O uv e o pip avisam de conflitos de versão na hora de resolver.

Exercício 1

Validar uma matriz de entrada

Escreva validar_matriz(a) que converta a entrada para float64, recuse (com ValueError) o que não for 2D ou tiver nan ou infinitos, e devolva o array validado.

Ver solução
avancado/cap37_tipagem_numpy2.pylinhas 59 a 76
def validar_matriz(a):
    m = np.asarray(a, dtype=np.float64)
    if m.ndim != 2:
        raise ValueError(f"esperava 2 dimensões, recebi {m.ndim}")
    if not np.isfinite(m).all():
        raise ValueError("a matriz contém nan ou infinitos")
    return m


assert validar_matriz([[1, 2], [3, 4]]).dtype == np.float64
for ruim in ([1, 2, 3], [[1.0, np.nan]], [[np.inf, 1.0]]):
    try:
        validar_matriz(ruim)
    except ValueError:
        pass
    else:
        raise AssertionError(f"{ruim!r} deveria falhar")
print("ok")

Saída

ok

Capítulo 38, parte Ecossistema

NumPy e pandas

O pandas é o NumPy com rótulos e com tipos por coluna. Saber onde os dois se tocam, e onde se comportam de forma diferente, evita os erros mais sutis de quem passa de um para o outro.

Código deste capítulo: ecossistema/cap38_numpy_pandas.py

Uma tabela é um conjunto de arrays com nomes

Um DataFrame é uma tabela em que cada coluna é, por baixo, um array do NumPy (cada coluna com o seu tipo), e as linhas e colunas têm rótulos. É exatamente o que o projeto de estudantes do capítulo 41 ensina a sentir: várias colunas do mesmo tamanho que descrevem as mesmas pessoas.

Terminal
uv sync --group ecossistema
ecossistema/cap38_numpy_pandas.pylinhas 10 a 17
import numpy as np
import pandas as pd

df = pd.DataFrame(
    {"nome": ["Ana", "Bia", "Caio", "Davi"], "nota": [7.5, 9.0, 6.0, 8.2], "faltas": [1, 0, 4, 2]}
)
print(df)
print(df.dtypes.astype(str).tolist())

Saída

   nome  nota  faltas
0   Ana   7.5       1
1   Bia   9.0       0
2  Caio   6.0       4
3  Davi   8.2       2
['str', 'float64', 'int64']

Cada coluna tem o seu tipo. No pandas 3, as colunas de texto têm um tipo próprio, str, e as numéricas mantêm o dtype do NumPy.

Do pandas para o NumPy, e de volta

O to_numpy() extrai os valores como um array. Em uma coluna só, o resultado é um array 1D com o tipo da coluna. Em um conjunto de colunas numéricas, é uma matriz 2D (e os inteiros viram decimais, pelas regras de promoção do capítulo 7). Em um DataFrame com texto misturado a números, o NumPy só consegue representar tudo como object, e você perde a velocidade:

ecossistema/cap38_numpy_pandas.pylinhas 22 a 24
notas = df["nota"].to_numpy()
print(type(notas).__name__, notas.dtype, notas.mean().round(1))
print(df.to_numpy().dtype, df[["nota", "faltas"]].to_numpy().dtype, df[["nota", "faltas"]].to_numpy().shape)

Saída

ndarray float64 7.7
object float64 (4, 2)

A regra que eu sigo: selecione só as colunas numéricas antes de converter. Uma matriz object não tem nenhuma das vantagens do NumPy.

Para voltar, o DataFrame aceita um array e os nomes das colunas:

ecossistema/cap38_numpy_pandas.pylinhas 26 a 27
matriz = np.array([[1.0, 2.0], [3.0, 4.0]])
print(pd.DataFrame(matriz, columns=["a", "b"]))

Saída

     a    b
0  1.0  2.0
1  3.0  4.0

O array que volta é uma visão protegida

No pandas 3, o to_numpy() devolve, sempre que possível, uma visão dos dados da coluna, sem copiar (e somente leitura), para que escrever nele não corrompa o DataFrame sem você perceber:

ecossistema/cap38_numpy_pandas.pylinhas 32 a 35
print(notas.flags["WRITEABLE"], notas.flags["OWNDATA"])
copia_editavel = df["nota"].to_numpy().copy()
copia_editavel[0] = 0.0
print(df["nota"].iloc[0])

Saída

False False
7.5

O array não é editável (WRITEABLE falso) e não é dono dos dados (OWNDATA falso). Para modificar, faça uma cópia, como no capítulo 10, e o DataFrame fica intacto.

A diferença mais perigosa: alinhamento por rótulo

O NumPy soma por posição. O pandas soma por rótulo: ele alinha os índices antes de operar. Com os mesmos valores em outra ordem, o resultado muda:

ecossistema/cap38_numpy_pandas.pylinhas 40 a 42
s1 = pd.Series([1, 2, 3], index=[0, 1, 2])
s2 = pd.Series([10, 20, 30], index=[2, 1, 0])
print((s1 + s2).tolist(), (s1.to_numpy() + s2.to_numpy()).tolist())

Saída

[31, 22, 13] [11, 22, 33]

O pandas somou 1 + 30, 2 + 20 e 3 + 10, porque os rótulos 0, 1 e 2 se correspondem. O NumPy somou 1 + 10, 2 + 20 e 3 + 30, posição por posição. Nenhum dos dois está "errado", mas misturar os dois sem perceber é uma fonte clássica de resultados trocados.

As ideias do NumPy, com outro nome

Quase tudo o que você aprendeu tem uma versão no pandas, com a vantagem dos rótulos:

No NumPy No pandas
a[a > 7] (máscara) df[df["nota"] > 7]
np.where(cond, x, y) np.where funciona direto sobre as colunas
a.mean(axis=0) df.mean()
np.bincount(grupos, weights=v) df.groupby("grupo")["v"].sum()
np.unique(a, return_counts=True) df["col"].value_counts()
np.isnan df.isna()
ecossistema/cap38_numpy_pandas.pylinhas 47 a 53
df["aprovado"] = np.where(df["nota"] >= 7, "sim", "não")
print(df["aprovado"].tolist(), df[df["nota"] > 7]["nome"].tolist())

vendas = pd.DataFrame(
    {"regiao": ["sul", "norte", "sul", "norte", "sul"], "valor": [10, 5, 20, 15, 30]}
)
print(vendas.groupby("regiao")["valor"].sum().to_dict())

Saída

['sim', 'sim', 'não', 'sim'] ['Ana', 'Bia', 'Davi']
{'norte': 20, 'sul': 60}

O groupby é a versão com rótulos do que o capítulo 31 fez com argsort e reduceat, só que aceita qualquer tipo de chave (aqui, texto).

Quando ficar no NumPy, quando ir para o pandas

Eu fico no NumPy quando os dados são números do mesmo tipo e da mesma forma (imagens, sinais, matrizes de características) e a velocidade importa. Vou para o pandas quando há colunas de tipos diferentes, rótulos, datas, dados ausentes e junções entre tabelas. E converto entre os dois sem cerimônia, no ponto em que a natureza dos dados muda.

Exercício 1

Normalizar uma coluna e devolver à tabela

Escreva adicionar_zscore(df, coluna) que acrescente ao DataFrame uma coluna coluna + "_z" com o z-score da coluna, calculado em NumPy, sem alterar o DataFrame original.

Ver solução
ecossistema/cap38_numpy_pandas.pylinhas 58 a 71
def adicionar_zscore(df, coluna):
    resultado = df.copy()
    valores = df[coluna].to_numpy(dtype=float)
    resultado[coluna + "_z"] = (valores - valores.mean()) / valores.std()
    return resultado


original = pd.DataFrame({"x": [1.0, 2.0, 3.0, 4.0]})
novo = adicionar_zscore(original, "x")
assert list(original.columns) == ["x"]
assert list(novo.columns) == ["x", "x_z"]
assert np.isclose(novo["x_z"].mean(), 0)
assert np.isclose(novo["x_z"].std(ddof=0), 1)
print("ok")

Saída

ok

Capítulo 39, parte Ecossistema

NumPy e Matplotlib

Um gráfico é a forma mais rápida de descobrir que os seus dados não são o que você pensava. O Matplotlib recebe arrays e desenha, e devolve a imagem, que por sua vez também é um array.

Código deste capítulo: ecossistema/cap39_numpy_matplotlib.py

Desenhar sem abrir janela

Em um script, em um servidor ou em um teste, não existe tela. O Matplotlib tem um backend sem janela, o Agg, que desenha em memória e salva em arquivo. Escolha-o antes de importar o pyplot. Em um notebook, os gráficos aparecem sozinhos abaixo da célula, e você não precisa dessa linha:

Terminal
uv sync --group ecossistema
ecossistema/cap39_numpy_matplotlib.pylinhas 10 a 22
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 2 * np.pi, 200)
fig, ax = plt.subplots(figsize=(4, 3), dpi=100)
ax.plot(x, np.sin(x), label="seno")
ax.plot(x, np.cos(x), label="cosseno")
ax.legend()
fig.savefig("ondas.png")
plt.close(fig)

Eu uso sempre a interface orientada a objetos (fig, ax = plt.subplots() e depois ax.plot(...)), e não a que depende de um "gráfico atual" global (plt.plot(...)): fica claro em qual gráfico cada comando age, e o código funciona em funções e em vários gráficos ao mesmo tempo. E o plt.close(fig) libera a memória, o que importa quando você gera centenas de figuras em um laço.

O gráfico salvo é um tensor

Ler a imagem de volta devolve um array de forma (altura, largura, 4), com os canais vermelho, verde, azul e transparência. O tamanho é o da figura multiplicado pelos pontos por polegada (4 × 100 por 3 × 100):

ecossistema/cap39_numpy_matplotlib.pylinhas 27 a 28
imagem = plt.imread("ondas.png")
print(imagem.shape, imagem.dtype)

Saída

(300, 400, 4) float32

É o tensor do capítulo 17 em ação: uma imagem é um array, e tudo o que você aprendeu (fatiar, tirar a média de um canal, comparar duas imagens) funciona sobre ela.

O `hist` usa o `np.histogram`

O Matplotlib não reinventa as contas. O ax.hist chama o np.histogram do capítulo 22 e desenha o resultado, e ele devolve também as contagens, para você usar:

ecossistema/cap39_numpy_matplotlib.pylinhas 33 a 38
dados = np.random.default_rng(0).normal(size=1000)
fig, ax = plt.subplots()
contagens, limites, _ = ax.hist(dados, bins=10)
esperado, _ = np.histogram(dados, bins=10)
print(np.array_equal(contagens, esperado))
plt.close(fig)

Saída

True

Um array 2D como imagem

O imshow desenha uma matriz como uma imagem, com uma escala de cores. Combinado com o meshgrid do capítulo 27, ele mostra uma função de duas variáveis. Aqui, uma "montanha" gaussiana, com o pico no centro:

ecossistema/cap39_numpy_matplotlib.pylinhas 43 a 51
X, Y = np.meshgrid(np.linspace(-2, 2, 100), np.linspace(-2, 2, 100))
Z = np.exp(-(X ** 2 + Y ** 2))

fig, ax = plt.subplots()
imagem_z = ax.imshow(Z, extent=(-2, 2, -2, 2), origin="lower")
fig.colorbar(imagem_z)
fig.savefig("montanha.png")
plt.close(fig)
print(Z.shape, round(float(Z.max()), 2))

Saída

(100, 100) 1.0

O parâmetro origin="lower" põe a linha 0 embaixo, como em um gráfico, e não no topo, como em uma imagem. Esse é um detalhe que confunde muito: imagens indexam de cima para baixo, e gráficos de baixo para cima.

Gravar uma matriz direto como imagem

O plt.imsave converte um array em arquivo de imagem sem criar uma figura, com a escala de cores que você escolher:

ecossistema/cap39_numpy_matplotlib.pylinhas 56 a 57
plt.imsave("cinza.png", Z, cmap="gray")
print(plt.imread("cinza.png").shape)

Saída

(100, 100, 4)

Limpeza

ecossistema/cap39_numpy_matplotlib.pylinhas 62 a 66
from pathlib import Path

for nome in ("ondas.png", "montanha.png", "cinza.png"):
    Path(nome).unlink()
print(Path("ondas.png").exists())

Saída

False
Quero Uso
Uma série ao longo do tempo ax.plot(x, y)
A distribuição de valores ax.hist(dados, bins=...)
Duas variáveis juntas ax.scatter(x, y)
Uma matriz ou imagem ax.imshow(matriz)
Uma função de duas variáveis meshgrid + imshow ou contourf
Salvar sem janela matplotlib.use("Agg") + fig.savefig(...)

Exercício 1

Salvar um gráfico e conferir

Escreva salvar_grafico(x, y, caminho) que desenhe a linha, salve em caminho, feche a figura e devolva a forma da imagem salva. Com a figura padrão (6,4 por 4,8 polegadas e 100 pontos por polegada), a forma deve ser (480, 640, 4).

Ver solução
ecossistema/cap39_numpy_matplotlib.pylinhas 71 a 85
import tempfile


def salvar_grafico(x, y, caminho):
    fig, ax = plt.subplots(dpi=100)
    ax.plot(x, y)
    fig.savefig(caminho)
    plt.close(fig)
    return plt.imread(caminho).shape


with tempfile.TemporaryDirectory() as pasta:
    forma = salvar_grafico(np.arange(5), np.arange(5) ** 2, Path(pasta) / "g.png")
assert forma == (480, 640, 4)
print("ok")

Saída

ok

Capítulo 40, parte Ecossistema

Do NumPy ao aprendizado de máquina

O aprendizado de máquina clássico é álgebra linear sobre matrizes de números. Aqui você vê a mesma regressão feita à mão com o NumPy e pelo scikit-learn, e confere que dão a mesma resposta.

Código deste capítulo: ecossistema/cap40_numpy_sklearn.py

A convenção que une tudo

O scikit-learn espera os dados como uma matriz X de forma (n_amostras, n_características): uma linha por exemplo, uma coluna por atributo. E os alvos em y, um vetor com um valor por exemplo. É a convenção do capítulo 41 (linhas são estudantes, colunas são atributos), e vale para praticamente todo o ecossistema.

Terminal
uv sync --group ecossistema

A mesma regressão, de dois jeitos

Primeiro, dados com resposta conhecida: três características, coeficientes verdadeiros [2, -1, 0,5], um intercepto de 3 e um pouco de ruído. A solução com NumPy usa mínimos quadrados (capítulo 23), com uma coluna de uns para o intercepto. A do scikit-learn usa o LinearRegression:

ecossistema/cap40_numpy_sklearn.pylinhas 10 a 23
import numpy as np
from sklearn.linear_model import LinearRegression

rng = np.random.default_rng(0)
X = rng.normal(size=(200, 3))
verdadeiro = np.array([2.0, -1.0, 0.5])
y = X @ verdadeiro + 3.0 + rng.normal(0, 0.1, 200)

Xb = np.hstack([X, np.ones((200, 1))])
theta, *_ = np.linalg.lstsq(Xb, y, rcond=None)

modelo = LinearRegression().fit(X, y)
print(np.allclose(theta[:3], modelo.coef_), np.isclose(theta[3], modelo.intercept_))
print(np.allclose(theta[:3], verdadeiro, atol=0.05))

Saída

True True
True

Os dois métodos dão a mesma resposta, e ambos recuperam os coeficientes verdadeiros. Não há mágica no LinearRegression: é essa conta, empacotada com uma interface padronizada (fit, predict, score).

Medir o erro à mão

O coeficiente de determinação (R²) compara o erro do modelo com o erro de simplesmente prever a média. Escrevê-lo em NumPy leva uma linha, e o resultado confere com o score do scikit-learn:

ecossistema/cap40_numpy_sklearn.pylinhas 28 a 32
def r2(real, previsto):
    return 1 - ((real - previsto) ** 2).sum() / ((real - real.mean()) ** 2).sum()


print(round(r2(y, modelo.predict(X)), 3) == round(modelo.score(X, y), 3))

Saída

True

Avaliar em dados que o modelo não viu

Medir o erro nos mesmos dados do ajuste é otimista demais. A prática correta é separar uma parte para teste (com a ideia do capítulo 19: embaralhar e cortar):

ecossistema/cap40_numpy_sklearn.pylinhas 37 a 42
ordem = rng.permutation(len(y))
treino, teste = np.split(ordem, [150])
Xb_treino = np.hstack([X[treino], np.ones((150, 1))])
theta_t, *_ = np.linalg.lstsq(Xb_treino, y[treino], rcond=None)
previsto = np.hstack([X[teste], np.ones((50, 1))]) @ theta_t
print(r2(y[teste], previsto) > 0.99)

Saída

True

Descida do gradiente: aprender sem a fórmula fechada

Para modelos sem solução fechada (como as redes neurais), o caminho é iterar: calcular o gradiente do erro e dar um passo pequeno na direção contrária. Para a regressão, o gradiente do erro quadrático médio é 2/n · Xᵀ(Xw − y), e a iteração converge para a mesma solução dos mínimos quadrados:

ecossistema/cap40_numpy_sklearn.pylinhas 47 a 50
w = np.zeros(4)
for _ in range(500):
    w -= 0.1 * (2 / len(y)) * Xb.T @ (Xb @ w - y)
print(np.allclose(w, theta, atol=1e-3))

Saída

True

O 0.1 é a taxa de aprendizado: grande demais e a iteração diverge, pequena demais e ela demora. O capítulo 43 constrói uma rede neural inteira com essa mesma ideia.

Padronização: o que o `StandardScaler` faz

Muitos modelos funcionam melhor com características na mesma escala. O StandardScaler subtrai a média e divide pelo desvio de cada coluna, exatamente o z-score do capítulo 13:

ecossistema/cap40_numpy_sklearn.pylinhas 55 a 57
from sklearn.preprocessing import StandardScaler

print(np.allclose(StandardScaler().fit_transform(X), (X - X.mean(axis=0)) / X.std(axis=0)))

Saída

True

Vazamento de dados

Calcule a média e o desvio só nos dados de treino e aplique os mesmos números ao teste. Padronizar tudo junto antes de separar deixa informação do teste vazar para o treino, e a avaliação fica otimista. O fit aprende com o treino, e o transform só aplica.

O caminho até o aprendizado profundo

O PyTorch e o JAX têm tensores com a mesma forma, a mesma indexação e as mesmas regras de broadcasting do NumPy. A conversão entre eles é barata, porque, na CPU, podem compartilhar a memória. O que eles acrescentam é GPU e derivação automática. Quem domina o capítulo 17 lê um tutorial deles sem estranhar o vocabulário.

Exercício 1

Um classificador do centroide mais próximo

Escreva classificador_centroide(X_treino, y_treino, X_teste): calcule o centro (a média) de cada classe no treino e classifique cada ponto de teste pela classe do centro mais próximo. Confira que concorda com o NearestCentroid do scikit-learn.

Ver solução
ecossistema/cap40_numpy_sklearn.pylinhas 62 a 82
from sklearn.neighbors import NearestCentroid


def classificador_centroide(X_treino, y_treino, X_teste):
    classes = np.unique(y_treino)
    centros = np.array([X_treino[y_treino == c].mean(axis=0) for c in classes])
    distancias = np.linalg.norm(X_teste[:, None, :] - centros[None, :, :], axis=-1)
    return classes[distancias.argmin(axis=1)]


gerador = np.random.default_rng(1)
X0 = gerador.normal([0, 0], 1.0, size=(60, 2))
X1 = gerador.normal([4, 4], 1.0, size=(60, 2))
Xc = np.vstack([X0, X1])
yc = np.array([0] * 60 + [1] * 60)
Xt = gerador.normal([2, 2], 2.5, size=(40, 2))

minha = classificador_centroide(Xc, yc, Xt)
deles = NearestCentroid().fit(Xc, yc).predict(Xt)
assert np.array_equal(minha, deles)
print("ok")

Saída

ok

Capítulo 41, parte Projetos

Projeto Básico: desempenho de estudantes

O projeto final do curso original, agora completo e com as soluções. Cinco arrays, duzentos estudantes e três níveis de pergunta. Faça depois do capítulo 18.

Os arquivos deste capítulo estão em projetos/estudantes/.

O problema

Todos os capítulos anteriores usaram um array de cada vez. Dados reais são vários arrays, cada um descrevendo um atributo das mesmas pessoas: o índice i de cada array é sempre o mesmo estudante. Aqui são cinco, todos de tamanho 200: o identificador, a nota, a presença, as horas de estudo por semana e as tarefas entregues.

Nível O que se pede Técnica
1 Maior, menor, média, mediana, desvio padrão e variância das notas Agregações (capítulo 14)
2 Quem tirou mais de 90, menos de 40, tem presença acima de 90%, estuda mais de 5 horas, entregou mais de 8 tarefas Máscaras (capítulo 9)
3 Combinações: nota alta e presença alta, nota baixa e presença baixa, presença alta mas nota baixa, pouco estudo mas nota alta & e \| (capítulo 9)

Ao projeto original eu acrescentei o que faz a análise virar uma conclusão: a correlação de cada variável com a nota (capítulo 22) e o perfil de um grupo, para responder à pergunta que o nível 3 deixa em aberto: "o que esses estudantes têm em comum?".

Como eu desenharia

Os cinco arrays vão dentro de uma dataclass imutável, a Turma, que garante no construtor o que o enunciado só supõe: que todos têm o mesmo tamanho. Se alguém criar uma Turma com notas de 200 estudantes e presenças de 199, o erro aparece na hora, com uma mensagem clara, e não três funções adiante, como um resultado errado.

Os dados são sintéticos e têm estrutura: a nota é construída a partir da presença, das horas de estudo e das tarefas, mais um ruído. Por isso os padrões que a análise encontra são reais. E uma decisão que vale registrar: a fórmula original do curso deixava a nota média perto de 87, com quase todos os grupos do nível 3 vazios (ninguém abaixo de 40, ninguém com presença alta e nota baixa). Uma análise cuja resposta é sempre zero não ensina nada, então eu ajustei os coeficientes para a nota média ficar perto de 63, com dispersão suficiente para os grupos existirem. Um teste guarda isso.

O código

Os dados e o gerador. Note que cada array tem o seu tipo (float64 ou int64), declarado nas anotações:

projetos/estudantes/src/estudantes/dados.py
from dataclasses import dataclass

import numpy as np
import numpy.typing as npt

Reais = npt.NDArray[np.float64]
Inteiros = npt.NDArray[np.int64]


@dataclass(frozen=True)
class Turma:
    """Cinco arrays do mesmo tamanho: a posição i descreve sempre o mesmo estudante."""

    ids: Inteiros
    notas: Reais
    presenca: Reais
    horas_estudo: Reais
    tarefas: Inteiros

    def __post_init__(self) -> None:
        tamanhos = {len(self.ids), len(self.notas), len(self.presenca)}
        tamanhos |= {len(self.horas_estudo), len(self.tarefas)}
        if len(tamanhos) != 1:
            raise ValueError("todos os arrays da turma precisam ter o mesmo tamanho")

    def __len__(self) -> int:
        return len(self.ids)


def gerar_turma(n: int = 200, semente: int = 42) -> Turma:
    """Gera uma turma sintética em que a nota **depende** das outras variáveis.

    A nota é construída a partir da presença, das horas de estudo e das tarefas, mais um ruído,
    então os padrões que a análise encontra são reais, e não coincidência. Os coeficientes
    foram escolhidos para a nota média ficar perto de 63, com bastante dispersão: assim todos
    os grupos que o projeto pede (notas acima de 90, abaixo de 40, presença alta com nota
    baixa...) têm estudantes, e nenhuma pergunta termina com resposta zero.
    """
    rng = np.random.default_rng(semente)
    ids = np.arange(1, n + 1)
    presenca = np.clip(rng.normal(85, 10, n), 50, 100).round(1)
    horas_estudo = np.clip(rng.normal(4, 2, n), 0, 10).round(1)
    tarefas = rng.integers(0, 11, n)
    ruido = rng.normal(0, 10, n)
    notas = np.clip(18 + presenca * 0.25 + horas_estudo * 4.5 + tarefas * 1.2 + ruido, 0, 100)
    notas = notas.round(1)
    return Turma(ids, notas, presenca, horas_estudo, tarefas)

As três análises são uma função por nível. Cada uma devolve dados (números e ids), e não texto: formatar é outro trabalho, feito no relatório. É a separação de sempre entre calcular e mostrar:

projetos/estudantes/src/estudantes/analise.py
import numpy as np

from estudantes.dados import Inteiros, Turma


def nivel_1(t: Turma) -> dict[str, float]:
    """Estatísticas descritivas das notas."""
    return {
        "maior": float(t.notas.max()),
        "menor": float(t.notas.min()),
        "media": float(t.notas.mean()),
        "mediana": float(np.median(t.notas)),
        "desvio_padrao": float(t.notas.std()),
        "variancia": float(t.notas.var()),
    }


def nivel_2(t: Turma) -> dict[str, Inteiros]:
    """Filtros por uma condição. Devolve os ids dos estudantes de cada grupo."""
    return {
        "nota_acima_de_90": t.ids[t.notas > 90],
        "nota_abaixo_de_40": t.ids[t.notas < 40],
        "presenca_acima_de_90": t.ids[t.presenca > 90],
        "estuda_mais_de_5h": t.ids[t.horas_estudo > 5],
        "mais_de_8_tarefas": t.ids[t.tarefas > 8],
    }


def nivel_3(t: Turma) -> dict[str, Inteiros]:
    """Filtros por várias condições. Cada condição vai entre parênteses, e o operador é & ou |."""
    return {
        "nota_alta_e_presenca_alta": t.ids[(t.notas > 80) & (t.presenca > 90)],
        "nota_baixa_e_presenca_baixa": t.ids[(t.notas < 40) & (t.presenca < 75)],
        "presenca_alta_nota_baixa": t.ids[(t.presenca > 90) & (t.notas < 60)],
        "pouco_estudo_nota_alta": t.ids[(t.horas_estudo < 2) & (t.notas > 80)],
    }


def correlacoes(t: Turma) -> dict[str, float]:
    """Correlação de cada variável com a nota (a primeira linha da matriz de correlação)."""
    variaveis = {
        "presenca": t.presenca,
        "horas_estudo": t.horas_estudo,
        "tarefas": t.tarefas.astype(np.float64),
    }
    matriz = np.corrcoef(np.vstack([t.notas, *variaveis.values()]))
    return {nome: float(matriz[0, i + 1]) for i, nome in enumerate(variaveis)}


def perfil_do_grupo(t: Turma, ids: Inteiros) -> dict[str, float]:
    """Média de cada atributo entre os estudantes do grupo."""
    mascara = np.isin(t.ids, ids)
    if not mascara.any():
        return {}
    return {
        "tamanho": float(mascara.sum()),
        "nota_media": float(t.notas[mascara].mean()),
        "presenca_media": float(t.presenca[mascara].mean()),
        "horas_estudo_medias": float(t.horas_estudo[mascara].mean()),
        "tarefas_medias": float(t.tarefas[mascara].mean()),
    }


def melhores(t: Turma, k: int = 5) -> Inteiros:
    """Ids dos k estudantes com as maiores notas, da maior para a menor."""
    return t.ids[np.argsort(-t.notas, kind="stable")[:k]]


def estudante(t: Turma, estudante_id: int) -> dict[str, float]:
    """Os dados de um estudante pelo id (os ids vão de 1 a n)."""
    posicoes = np.flatnonzero(t.ids == estudante_id)
    if posicoes.size == 0:
        raise KeyError(f"não existe o estudante {estudante_id}")
    i = int(posicoes[0])
    return {
        "id": float(t.ids[i]),
        "nota": float(t.notas[i]),
        "presenca": float(t.presenca[i]),
        "horas_estudo": float(t.horas_estudo[i]),
        "tarefas": float(t.tarefas[i]),
    }

Repare em dois detalhes. Nas condições combinadas, cada comparação vai entre parênteses e o operador é &, não and (capítulo 9). E o np.isin do perfil_do_grupo transforma uma lista de ids em uma máscara, que seleciona as linhas do grupo em todos os arrays de uma vez.

O relatório monta o texto e a CLI expõe três comandos (relatorio, aluno e exportar):

projetos/estudantes/src/estudantes/relatorio.py
from estudantes.analise import (
    correlacoes,
    melhores,
    nivel_1,
    nivel_2,
    nivel_3,
    perfil_do_grupo,
)
from estudantes.dados import Turma


def montar_relatorio(t: Turma) -> str:
    linhas = [f"Turma com {len(t)} estudantes", "", "Nível 1: estatísticas das notas"]
    for nome, valor in nivel_1(t).items():
        linhas.append(f"  {nome:<14}{valor:>8.2f}")

    linhas += ["", "Nível 2: grupos por uma condição"]
    for nome, ids in nivel_2(t).items():
        linhas.append(f"  {nome:<28}{len(ids):>4} estudantes")

    linhas += ["", "Nível 3: grupos por várias condições"]
    grupos = nivel_3(t)
    for nome, ids in grupos.items():
        linhas.append(f"  {nome:<28}{len(ids):>4} estudantes")

    linhas += ["", "Correlação de cada variável com a nota"]
    for nome, valor in correlacoes(t).items():
        linhas.append(f"  {nome:<14}{valor:>7.2f}")

    perfil = perfil_do_grupo(t, grupos["presenca_alta_nota_baixa"])
    if perfil:
        linhas += ["", "Perfil de quem tem presença alta e nota baixa"]
        linhas.append(f"  horas de estudo médias {perfil['horas_estudo_medias']:.1f}")
        linhas.append(f"  tarefas médias         {perfil['tarefas_medias']:.1f}")

    linhas += ["", "Os 5 melhores (ids): " + ", ".join(str(int(i)) for i in melhores(t, 5))]
    return "\n".join(linhas)
projetos/estudantes/src/estudantes/cli.py
import argparse
from pathlib import Path

import numpy as np

from estudantes.analise import estudante
from estudantes.dados import gerar_turma
from estudantes.relatorio import montar_relatorio


def criar_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="estudantes", description="Análise de uma turma")
    parser.add_argument("--n", type=int, default=200, help="número de estudantes")
    parser.add_argument("--semente", type=int, default=42, help="semente do gerador")
    sub = parser.add_subparsers(dest="comando", required=True)
    sub.add_parser("relatorio", help="mostra o relatório completo")
    aluno = sub.add_parser("aluno", help="mostra os dados de um estudante")
    aluno.add_argument("id", type=int)
    exportar = sub.add_parser("exportar", help="grava a turma em um arquivo CSV")
    exportar.add_argument("arquivo", type=Path)
    return parser


def main(argv: list[str] | None = None) -> int:
    args = criar_parser().parse_args(argv)
    turma = gerar_turma(args.n, args.semente)
    if args.comando == "relatorio":
        print(montar_relatorio(turma))
    elif args.comando == "aluno":
        try:
            dados = estudante(turma, args.id)
        except KeyError as erro:
            print(f"Erro: {erro.args[0]}")
            return 1
        for chave, valor in dados.items():
            print(f"{chave:<14}{valor:g}")
    else:
        tabela = np.column_stack(
            [turma.ids, turma.notas, turma.presenca, turma.horas_estudo, turma.tarefas]
        )
        np.savetxt(
            args.arquivo,
            tabela,
            delimiter=",",
            fmt="%g",
            header="id,nota,presenca,horas_estudo,tarefas",
            comments="",
        )
        print(f"{len(turma)} estudantes gravados em {args.arquivo}")
    return 0

Os testes

O teste que eu mais gosto compara a versão vetorizada com um laço obviamente correto. Se as duas contagens batem, a máscara faz o que eu acho que faz. Os demais verificam relações que precisam valer (o nível 3 é subconjunto do nível 2, a mediana está entre o mínimo e o máximo) e as exceções, como a turma com arrays de tamanhos diferentes:

projetos/estudantes/tests/test_dados.py
import numpy as np
import pytest

from estudantes.dados import Turma, gerar_turma


def test_tamanho_e_intervalos() -> None:
    t = gerar_turma(200)
    assert len(t) == 200
    assert t.ids[0] == 1 and t.ids[-1] == 200
    assert t.presenca.min() >= 50 and t.presenca.max() <= 100
    assert t.horas_estudo.min() >= 0 and t.horas_estudo.max() <= 10
    assert t.tarefas.min() >= 0 and t.tarefas.max() <= 10
    assert t.notas.min() >= 0 and t.notas.max() <= 100


def test_mesma_semente_gera_a_mesma_turma() -> None:
    a, b = gerar_turma(50, semente=1), gerar_turma(50, semente=1)
    assert np.array_equal(a.notas, b.notas)
    assert not np.array_equal(a.notas, gerar_turma(50, semente=2).notas)


def test_arrays_de_tamanhos_diferentes_sao_recusados() -> None:
    t = gerar_turma(10)
    with pytest.raises(ValueError, match="mesmo tamanho"):
        Turma(t.ids, t.notas[:5], t.presenca, t.horas_estudo, t.tarefas)
projetos/estudantes/tests/test_analise.py
import numpy as np

from estudantes.analise import (
    correlacoes,
    estudante,
    melhores,
    nivel_1,
    nivel_2,
    nivel_3,
    perfil_do_grupo,
)
from estudantes.dados import gerar_turma

T = gerar_turma(200)


def test_nivel_1_e_coerente() -> None:
    r = nivel_1(T)
    assert r["menor"] <= r["mediana"] <= r["maior"]
    assert r["menor"] <= r["media"] <= r["maior"]
    assert np.isclose(r["desvio_padrao"] ** 2, r["variancia"])


def test_nivel_2_confere_com_contagem_por_laco() -> None:
    """A versão vetorizada precisa dar o mesmo resultado de um laço obviamente correto."""
    grupos = nivel_2(T)
    por_laco = [int(T.ids[i]) for i in range(len(T)) if T.notas[i] > 90]
    assert grupos["nota_acima_de_90"].tolist() == por_laco
    por_laco = [int(T.ids[i]) for i in range(len(T)) if T.tarefas[i] > 8]
    assert grupos["mais_de_8_tarefas"].tolist() == por_laco


def test_nivel_3_e_subconjunto_do_nivel_2() -> None:
    n2, n3 = nivel_2(T), nivel_3(T)
    assert set(n3["nota_alta_e_presenca_alta"]) <= set(n2["presenca_acima_de_90"])
    assert set(n3["presenca_alta_nota_baixa"]) <= set(n2["presenca_acima_de_90"])
    assert set(n3["nota_baixa_e_presenca_baixa"]) <= set(n2["nota_abaixo_de_40"])


def test_a_nota_correlaciona_mais_com_as_horas_de_estudo() -> None:
    c = correlacoes(T)
    assert c["horas_estudo"] > c["tarefas"] > c["presenca"] > 0


def test_os_grupos_pedidos_tem_estudantes() -> None:
    """Um projeto cuja resposta é sempre zero não ensina nada: a turma precisa ser variada."""
    excecao = "pouco_estudo_nota_alta"
    for nome, grupo in {**nivel_2(T), **nivel_3(T)}.items():
        if nome != excecao:
            assert len(grupo) > 0, nome


def test_estudar_pouco_e_tirar_nota_alta_e_raro() -> None:
    """Esse grupo pode ser vazio: as horas de estudo são o que mais pesa na nota."""
    n3 = nivel_3(T)
    assert len(n3["pouco_estudo_nota_alta"]) < len(n3["presenca_alta_nota_baixa"])


def test_perfil_do_grupo() -> None:
    ids = nivel_2(T)["nota_acima_de_90"]
    perfil = perfil_do_grupo(T, ids)
    assert perfil["tamanho"] == len(ids)
    assert perfil["nota_media"] > 90
    assert perfil_do_grupo(T, np.array([], dtype=np.int64)) == {}


def test_melhores_e_busca_por_id() -> None:
    topo = melhores(T, 3)
    notas_do_topo = [estudante(T, int(i))["nota"] for i in topo]
    assert notas_do_topo == sorted(notas_do_topo, reverse=True)
    assert notas_do_topo[0] == T.notas.max()
    assert estudante(T, 1)["id"] == 1
projetos/estudantes/tests/test_cli.py
from pathlib import Path

import numpy as np
import pytest

from estudantes.cli import main


def test_relatorio(capsys: pytest.CaptureFixture[str]) -> None:
    assert main(["relatorio"]) == 0
    saida = capsys.readouterr().out
    assert "Turma com 200 estudantes" in saida
    assert "Nível 3" in saida


def test_aluno_inexistente_devolve_1(capsys: pytest.CaptureFixture[str]) -> None:
    assert main(["aluno", "9999"]) == 1
    assert "não existe o estudante 9999" in capsys.readouterr().out


def test_exportar_csv(tmp_path: Path) -> None:
    destino = tmp_path / "turma.csv"
    assert main(["--n", "30", "exportar", str(destino)]) == 0
    lido = np.loadtxt(destino, delimiter=",", skiprows=1)
    assert lido.shape == (30, 5)

Rodar

Terminal
cd projetos/estudantes
uv sync
uv run pytest
uv run estudantes relatorio

Saída

..............                                                           [100%]
14 passed
O relatório (execução real, semente 42)
Turma com 200 estudantes

Nível 1: estatísticas das notas
  maior           98.20
  menor           26.80
  media           62.21
  mediana         63.15
  desvio_padrao   14.22
  variancia      202.17

Nível 2: grupos por uma condição
  nota_acima_de_90               5 estudantes
  nota_abaixo_de_40             13 estudantes
  presenca_acima_de_90          51 estudantes
  estuda_mais_de_5h             57 estudantes
  mais_de_8_tarefas             34 estudantes

Nível 3: grupos por várias condições
  nota_alta_e_presenca_alta      6 estudantes
  nota_baixa_e_presenca_baixa    3 estudantes
  presenca_alta_nota_baixa      19 estudantes
  pouco_estudo_nota_alta         0 estudantes

Correlação de cada variável com a nota
  presenca         0.18
  horas_estudo     0.58
  tarefas          0.23

Perfil de quem tem presença alta e nota baixa
  horas de estudo médias 2.8
  tarefas médias         3.7

Os 5 melhores (ids): 28, 182, 125, 176, 74

Consultando um estudante, como a "ferramenta de busca" do curso original:

Terminal
uv run estudantes aluno 17

Saída

id            17
nota          71
presenca      88.7
horas_estudo  5.1
tarefas       9

Lendo o resultado

Um relatório só vale se alguém o interpreta. Três conclusões, e o que as sustenta:

  • As horas de estudo pesam mais do que a presença. A correlação com a nota é de 0,58 para as horas e de 0,18 para a presença. Isso é coerente com a forma como os dados foram gerados (as horas têm o maior coeficiente e a maior variação), e é o tipo de coisa que a análise recupera a partir dos dados.
  • Presença alta não garante nota alta. Dos 51 estudantes com presença acima de 90%, 19 têm nota abaixo de 60. O perfil deles mostra o motivo: estudam, em média, 2,8 horas por semana, contra cerca de 4 na turma toda.
  • O zero também é uma resposta. Ninguém estuda menos de 2 horas e tira mais de 80. Em uma turma em que o estudo é o fator dominante, é o esperado, e o teste test_estudar_pouco_e_tirar_nota_alta_e_raro registra isso como uma propriedade, e não como um erro.

Desafios

  1. Quartis. Divida a turma em quatro faixas de nota com np.percentile e np.digitize e mostre a média de horas de estudo em cada faixa.
  2. Visualizar. Com o capítulo 39, desenhe a nota contra as horas de estudo (scatter) e o histograma das notas.
  3. Refazer no pandas. Reescreva a análise com um DataFrame (capítulo 38) e compare: o que ficou mais curto, e o que ficou mais lento?
  4. Prever a nota. Ajuste uma regressão linear (capítulo 40) das notas contra as três variáveis e veja se os coeficientes recuperam os que foram usados para gerar os dados.

Capítulo 42, parte Projetos

Projeto Intermediário: imagens só com NumPy

Ler e gravar um PNG sem biblioteca de imagem, converter para cinza, melhorar o contraste, desfocar, achar bordas e separar objetos. Uma imagem é um tensor, e aqui você prova isso. Faça depois do capítulo 28.

Os arquivos deste capítulo estão em projetos/imagens/.

O problema

Uma imagem é um array de uint8: tons de cinza em duas dimensões, ou cor em três (altura, largura, 3 canais). Tudo o que se faz com uma imagem é uma operação de array que você já conhece. O projeto monta uma cadeia completa, do arquivo ao resultado:

Etapa O que faz Técnica Capítulo
Ler e gravar PNG Converte arrays em arquivo e de volta struct, zlib, reshape 11, 24
Cinza Média ponderada dos canais Produto matricial @ 23
Esticar contraste Leva o menor valor a 0 e o maior a 255 Aritmética com broadcasting 13
Equalizar Redistribui os níveis de brilho bincount, cumsum, indexação fancy 9, 22
Desfoque e bordas Cada pixel vira uma soma ponderada dos vizinhos sliding_window_view e einsum 28, 30
Limiar de Otsu Acha o brilho que separa fundo e objeto Variância entre grupos, tudo vetorizado 22

A parte que parece mágica: gravar um PNG

O formato PNG é uma assinatura, uma sequência de blocos (cada um com tipo, tamanho, dados e um CRC) e, no meio, os pixels comprimidos com zlib. A única sutileza é que cada linha de pixels começa com um byte de filtro. O projeto usa o filtro 0 (sem filtro), e por isso lê apenas os arquivos que ele mesmo grava. O ponto é ver que o arquivo é, no fim, um array com um byte extra por linha:

projetos/imagens/src/imagens/pngio.py
"""Leitura e gravação de PNG com a biblioteca padrão e NumPy.

Grava imagens de 8 bits em tons de cinza ou RGB, sem filtro por linha (filtro 0). Lê apenas
esse mesmo formato, ou seja, os arquivos gravados por este módulo.
"""

import struct
import zlib
from pathlib import Path

import numpy as np

from imagens.tipos import Bytes

ASSINATURA = b"\x89PNG\r\n\x1a\n"


def _bloco(tipo: bytes, dados: bytes) -> bytes:
    return (
        struct.pack(">I", len(dados)) + tipo + dados + struct.pack(">I", zlib.crc32(tipo + dados))
    )


def salvar_png(caminho: Path, img: Bytes) -> None:
    if img.dtype != np.uint8:
        raise ValueError("a imagem precisa ser uint8")
    if img.ndim == 2:
        tipo_cor = 0
    elif img.ndim == 3 and img.shape[2] == 3:
        tipo_cor = 2
    else:
        raise ValueError("use uma imagem 2D (cinza) ou (altura, largura, 3) (RGB)")
    altura, largura = img.shape[:2]
    # Cada linha começa com um byte de filtro (0 = sem filtro).
    linhas = np.concatenate(
        [np.zeros((altura, 1), dtype=np.uint8), img.reshape(altura, -1)], axis=1
    )
    cabecalho = struct.pack(">IIBBBBB", largura, altura, 8, tipo_cor, 0, 0, 0)
    conteudo = (
        ASSINATURA
        + _bloco(b"IHDR", cabecalho)
        + _bloco(b"IDAT", zlib.compress(linhas.tobytes(), 9))
        + _bloco(b"IEND", b"")
    )
    caminho.write_bytes(conteudo)


def ler_png(caminho: Path) -> Bytes:
    dados = caminho.read_bytes()
    if not dados.startswith(ASSINATURA):
        raise ValueError("não é um arquivo PNG")
    posicao = len(ASSINATURA)
    largura = altura = tipo_cor = 0
    comprimidos = b""
    while posicao < len(dados):
        (tamanho,) = struct.unpack(">I", dados[posicao : posicao + 4])
        tipo = dados[posicao + 4 : posicao + 8]
        corpo = dados[posicao + 8 : posicao + 8 + tamanho]
        if tipo == b"IHDR":
            largura, altura, profundidade, tipo_cor, *_ = struct.unpack(">IIBBBBB", corpo)
            if profundidade != 8 or tipo_cor not in (0, 2):
                raise ValueError("só leio PNG de 8 bits em cinza ou RGB")
        elif tipo == b"IDAT":
            comprimidos += corpo
        posicao += 12 + tamanho
    canais = 1 if tipo_cor == 0 else 3
    bruto = np.frombuffer(zlib.decompress(comprimidos), dtype=np.uint8)
    linhas = bruto.reshape(altura, 1 + largura * canais)
    if linhas[:, 0].any():
        raise ValueError("só leio PNG gravado sem filtro por linha (como o deste módulo)")
    pixels = linhas[:, 1:]
    return pixels.reshape(altura, largura) if canais == 1 else pixels.reshape(altura, largura, 3)

A imagem de teste

Para ter o que processar sem baixar nada, uma cena sintética: um fundo em gradiente, um círculo claro e um retângulo escuro, com ruído. O np.mgrid gera as coordenadas de cada pixel, e as formas saem de comparações sobre elas (uma máscara de círculo é só (x − cx)² + (y − cy)² ≤ r²):

projetos/imagens/src/imagens/sintetica.py
import numpy as np

from imagens.tipos import Bytes


def criar_cena(tamanho: int = 128, semente: int = 7) -> Bytes:
    """Imagem RGB de teste: fundo em gradiente, um círculo claro, um retângulo escuro e ruído."""
    rng = np.random.default_rng(semente)
    y, x = np.mgrid[0:tamanho, 0:tamanho]
    cena = np.zeros((tamanho, tamanho, 3), dtype=np.float64)
    cena[..., 0] = 60 + 80 * x / (tamanho - 1)
    cena[..., 1] = 60 + 80 * y / (tamanho - 1)
    cena[..., 2] = 90

    centro = tamanho * 0.35
    circulo = (x - centro) ** 2 + (y - centro) ** 2 <= (tamanho * 0.2) ** 2
    cena[circulo] = (230, 200, 60)
    retangulo = (x > tamanho * 0.55) & (x < tamanho * 0.9)
    retangulo &= (y > tamanho * 0.5) & (y < tamanho * 0.85)
    cena[retangulo] = (40, 60, 200)

    cena += rng.normal(0, 6, cena.shape)
    return np.clip(cena, 0, 255).round().astype(np.uint8)

Tons: cinza, contraste, equalização e Otsu

A equalização é a parte mais elegante: calcula-se uma tabela de conversão de 256 entradas (a soma acumulada do histograma) e aplica-se com tabela[img], indexação fancy, que troca cada pixel pelo valor da sua entrada de uma vez. O método de Otsu testa os 256 limiares possíveis ao mesmo tempo, com somas acumuladas, sem laço:

projetos/imagens/src/imagens/tons.py
import numpy as np
import numpy.typing as npt

from imagens.tipos import Bytes

PESOS_CINZA = np.array([0.299, 0.587, 0.114])


def para_cinza(rgb: Bytes) -> Bytes:
    """Média ponderada dos canais, com os pesos da percepção humana (verde pesa mais)."""
    cinza: Bytes = np.clip((rgb[..., :3] @ PESOS_CINZA).round(), 0, 255).astype(np.uint8)
    return cinza


def esticar_contraste(img: Bytes) -> Bytes:
    """Leva o menor valor a 0 e o maior a 255, mantendo a proporção entre os demais."""
    menor, maior = int(img.min()), int(img.max())
    if maior == menor:
        return img.copy()
    esticada = (img.astype(np.float64) - menor) * (255 / (maior - menor))
    return np.clip(esticada.round(), 0, 255).astype(np.uint8)


def histograma(img: Bytes) -> npt.NDArray[np.intp]:
    return np.bincount(img.ravel(), minlength=256)


def equalizar(img: Bytes) -> Bytes:
    """Redistribui os níveis para o histograma ficar o mais uniforme possível."""
    cdf = histograma(img).cumsum()
    cdf_min = int(cdf[np.flatnonzero(histograma(img))[0]])
    if cdf[-1] == cdf_min:
        return img.copy()
    tabela = np.clip(np.round((cdf - cdf_min) / (cdf[-1] - cdf_min) * 255), 0, 255).astype(np.uint8)
    equalizada: Bytes = tabela[img]
    return equalizada


def limiar_otsu(img: Bytes) -> int:
    """Limiar que separa a imagem em dois grupos com a maior variância entre eles (Otsu)."""
    prob = histograma(img) / img.size
    niveis = np.arange(256)
    omega = np.cumsum(prob)
    mu = np.cumsum(prob * niveis)
    with np.errstate(divide="ignore", invalid="ignore"):
        variancia_entre = (mu[-1] * omega - mu) ** 2 / (omega * (1 - omega))
    return int(np.argmax(np.nan_to_num(variancia_entre)))


def binarizar(img: Bytes, limiar: int) -> Bytes:
    return np.where(img > limiar, 255, 0).astype(np.uint8)

Filtros: o que muda é só o kernel

Desfocar, afiar e achar bordas são a mesma operação com kernels diferentes: cada pixel vira a soma ponderada da sua vizinhança. O sliding_window_view entrega todas as vizinhanças como uma visão (capítulo 28, sem cópia), e o einsum as multiplica pelo kernel de uma vez (capítulo 30). Os kernels de Sobel medem quanto o brilho muda na horizontal e na vertical, e a magnitude hypot(gx, gy) é grande exatamente nas bordas:

projetos/imagens/src/imagens/filtros.py
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view

from imagens.tipos import Bytes, Reais


def aplicar_kernel(img: Reais, kernel: Reais) -> Reais:
    """Correlação 2D: cada pixel vira a soma ponderada da sua vizinhança.

    As bordas são tratadas repetindo o pixel mais próximo (``mode="edge"``). As janelas são uma
    visão (sem cópia) da imagem, e o ``einsum`` multiplica cada uma pelo kernel de uma vez.
    """
    altura_k, largura_k = kernel.shape
    if altura_k % 2 == 0 or largura_k % 2 == 0:
        raise ValueError("o kernel precisa ter dimensões ímpares")
    margem = ((altura_k // 2, altura_k // 2), (largura_k // 2, largura_k // 2))
    janelas = sliding_window_view(np.pad(img, margem, mode="edge"), (altura_k, largura_k))
    resultado: Reais = np.einsum("ijkl,kl->ij", janelas, kernel)
    return resultado


def kernel_caixa(k: int) -> Reais:
    return np.full((k, k), 1.0 / (k * k))


def kernel_gaussiano(k: int, sigma: float) -> Reais:
    eixo = np.arange(k) - k // 2
    g = np.exp(-(eixo**2) / (2 * sigma**2))
    kernel: Reais = np.outer(g, g)
    return kernel / kernel.sum()


SOBEL_X = np.array([[-1.0, 0.0, 1.0], [-2.0, 0.0, 2.0], [-1.0, 0.0, 1.0]])


def bordas_sobel(img: Bytes) -> Reais:
    """Magnitude do gradiente: grande onde o brilho muda de repente (as bordas)."""
    f = img.astype(np.float64)
    gx = aplicar_kernel(f, SOBEL_X)
    gy = aplicar_kernel(f, SOBEL_X.T)
    return np.hypot(gx, gy)


def para_uint8(img: Reais) -> Bytes:
    """Reescala qualquer array decimal para 0..255, para poder gravar como imagem."""
    menor, maior = float(img.min()), float(img.max())
    if maior == menor:
        return np.zeros(img.shape, dtype=np.uint8)
    return np.clip((img - menor) / (maior - menor) * 255, 0, 255).round().astype(np.uint8)

A linha de comando

O comando processar encadeia as etapas e grava cada uma. Repare no desfoque: eu não reescalo o resultado para 0 a 255, porque isso mudaria o brilho. Desfocar suaviza, mas preserva a média da imagem (e há um teste para isso):

projetos/imagens/src/imagens/cli.py
import argparse
from pathlib import Path

import numpy as np

from imagens.filtros import aplicar_kernel, bordas_sobel, kernel_gaussiano, para_uint8
from imagens.pngio import ler_png, salvar_png
from imagens.sintetica import criar_cena
from imagens.tons import binarizar, equalizar, esticar_contraste, limiar_otsu, para_cinza


def criar_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="imagens", description="Processamento de imagens")
    sub = parser.add_subparsers(dest="comando", required=True)
    gerar = sub.add_parser("gerar", help="grava uma imagem de teste")
    gerar.add_argument("pasta", type=Path)
    gerar.add_argument("--tamanho", type=int, default=128)
    processar = sub.add_parser("processar", help="aplica uma sequência de operações")
    processar.add_argument("entrada", type=Path)
    processar.add_argument("pasta", type=Path)
    return parser


def main(argv: list[str] | None = None) -> int:
    args = criar_parser().parse_args(argv)
    args.pasta.mkdir(parents=True, exist_ok=True)
    if args.comando == "gerar":
        destino = args.pasta / "cena.png"
        salvar_png(destino, criar_cena(args.tamanho))
        print(f"imagem gravada em {destino}")
        return 0

    try:
        rgb = ler_png(args.entrada)
    except (OSError, ValueError) as erro:
        print(f"Erro: {erro}")
        return 1
    cinza = para_cinza(rgb) if rgb.ndim == 3 else rgb
    suave = aplicar_kernel(cinza.astype(float), kernel_gaussiano(5, 1.2))
    desfocada = np.clip(suave.round(), 0, 255).astype(np.uint8)
    limiar = limiar_otsu(cinza)
    etapas = {
        "cinza": cinza,
        "contraste": esticar_contraste(cinza),
        "equalizada": equalizar(cinza),
        "desfoque": desfocada,
        "bordas": para_uint8(bordas_sobel(desfocada)),
        "binaria": binarizar(cinza, limiar),
    }
    for nome, imagem in etapas.items():
        salvar_png(args.pasta / f"{nome}.png", imagem)
        minimo, maximo, media = int(imagem.min()), int(imagem.max()), imagem.mean()
        print(f"{nome:<11} min={minimo:>3} max={maximo:>3} média={media:6.1f}")
    original = rgb if rgb.ndim == 3 else np.stack([rgb] * 3, axis=-1)
    nomes = ("cinza", "equalizada", "desfoque", "bordas", "binaria")
    tiras = [np.stack([etapas[nome]] * 3, axis=-1) for nome in nomes]
    salvar_png(args.pasta / "montagem.png", np.hstack([original, *tiras]))
    print("montagem.png: original, " + ", ".join(nomes))
    print(f"limiar de Otsu: {limiar}")
    return 0

Os testes

O mais importante é o de aplicar_kernel: ele compara a versão vetorizada (janelas + einsum) com uma versão por laços, lenta e óbvia, em uma imagem pequena. Os outros verificam propriedades: a equalização aumenta a dispersão de uma imagem sem contraste, o Otsu separa duas populações, e o Sobel acha a borda vertical exatamente onde ela está, sem acusar nada nas regiões planas:

projetos/imagens/tests/test_png.py
from pathlib import Path

import numpy as np
import pytest

from imagens.pngio import ASSINATURA, ler_png, salvar_png


def test_ida_e_volta_em_cinza_e_em_rgb(tmp_path: Path) -> None:
    rng = np.random.default_rng(0)
    cinza = rng.integers(0, 256, (7, 9), dtype=np.uint8)
    rgb = rng.integers(0, 256, (5, 6, 3), dtype=np.uint8)
    salvar_png(tmp_path / "c.png", cinza)
    salvar_png(tmp_path / "r.png", rgb)
    assert np.array_equal(ler_png(tmp_path / "c.png"), cinza)
    assert np.array_equal(ler_png(tmp_path / "r.png"), rgb)


def test_o_arquivo_tem_a_assinatura_do_png(tmp_path: Path) -> None:
    salvar_png(tmp_path / "x.png", np.zeros((2, 2), dtype=np.uint8))
    assert (tmp_path / "x.png").read_bytes().startswith(ASSINATURA)


def test_entradas_invalidas(tmp_path: Path) -> None:
    with pytest.raises(ValueError, match="uint8"):
        salvar_png(tmp_path / "a.png", np.zeros((2, 2)))
    with pytest.raises(ValueError, match="RGB"):
        salvar_png(tmp_path / "b.png", np.zeros((2, 2, 4), dtype=np.uint8))
    (tmp_path / "texto.png").write_text("não sou uma imagem", encoding="utf-8")
    with pytest.raises(ValueError, match="PNG"):
        ler_png(tmp_path / "texto.png")
projetos/imagens/tests/test_filtros.py
import numpy as np
import pytest

from imagens.filtros import (
    aplicar_kernel,
    bordas_sobel,
    kernel_caixa,
    kernel_gaussiano,
    para_uint8,
)


def aplicar_por_laco(img: np.ndarray, kernel: np.ndarray) -> np.ndarray:  # type: ignore[type-arg]
    """Versão lenta e obviamente correta, usada para validar a vetorizada."""
    k = kernel.shape[0] // 2
    padded = np.pad(img, k, mode="edge")
    saida = np.zeros_like(img)
    for i in range(img.shape[0]):
        for j in range(img.shape[1]):
            saida[i, j] = (padded[i : i + 2 * k + 1, j : j + 2 * k + 1] * kernel).sum()
    return saida


def test_confere_com_a_versao_por_laco() -> None:
    rng = np.random.default_rng(0)
    img = rng.random((6, 7))
    kernel = rng.random((3, 3))
    assert np.allclose(aplicar_kernel(img, kernel), aplicar_por_laco(img, kernel))


def test_kernel_de_tamanho_par_e_recusado() -> None:
    with pytest.raises(ValueError, match="ímpares"):
        aplicar_kernel(np.zeros((4, 4)), np.ones((2, 2)))


def test_desfoque_preserva_imagem_constante_e_reduz_ruido() -> None:
    assert np.allclose(aplicar_kernel(np.full((5, 5), 7.0), kernel_caixa(3)), 7.0)
    ruido = np.random.default_rng(1).normal(0, 10, (40, 40))
    assert aplicar_kernel(ruido, kernel_gaussiano(5, 1.2)).std() < 0.5 * ruido.std()


def test_kernel_gaussiano_soma_1_e_e_simetrico() -> None:
    k = kernel_gaussiano(5, 1.0)
    assert np.isclose(k.sum(), 1.0)
    assert np.allclose(k, k.T) and np.allclose(k, k[::-1, ::-1])


def test_sobel_acha_a_borda_vertical() -> None:
    img = np.zeros((9, 9), dtype=np.uint8)
    img[:, 5:] = 255
    mag = bordas_sobel(img)
    assert set(np.argmax(mag, axis=1)) <= {4, 5}
    assert np.allclose(mag[:, :3], 0) and np.allclose(mag[:, 7:], 0)


def test_para_uint8_reescala_para_0_255() -> None:
    r = para_uint8(np.array([[-5.0, 0.0], [5.0, 10.0]]))
    assert r.dtype == np.uint8 and r.min() == 0 and r.max() == 255
    assert para_uint8(np.ones((2, 2))).max() == 0
projetos/imagens/tests/test_tons.py
import numpy as np

from imagens.tons import (
    binarizar,
    equalizar,
    esticar_contraste,
    histograma,
    limiar_otsu,
    para_cinza,
)


def test_cinza_de_cores_conhecidas() -> None:
    vermelho = np.array([[[255, 0, 0]]], dtype=np.uint8)
    branco = np.array([[[255, 255, 255]]], dtype=np.uint8)
    assert para_cinza(vermelho)[0, 0] == 76
    assert para_cinza(branco)[0, 0] == 255


def test_esticar_contraste_leva_aos_extremos() -> None:
    img = np.array([[100, 120], [140, 150]], dtype=np.uint8)
    r = esticar_contraste(img)
    assert r.min() == 0 and r.max() == 255
    assert np.array_equal(esticar_contraste(np.full((2, 2), 9, dtype=np.uint8)), np.full((2, 2), 9))


def test_histograma_soma_o_numero_de_pixels() -> None:
    img = np.random.default_rng(0).integers(0, 256, (10, 10), dtype=np.uint8)
    assert histograma(img).sum() == img.size


def test_equalizar_aumenta_a_dispersao_de_uma_imagem_de_baixo_contraste() -> None:
    rng = np.random.default_rng(2)
    img = rng.integers(100, 140, (40, 40), dtype=np.uint8)
    r = equalizar(img)
    assert r.std() > 2 * img.std()
    assert r.min() == 0 and r.max() == 255


def test_otsu_separa_duas_populacoes() -> None:
    rng = np.random.default_rng(3)
    claro = np.clip(rng.normal(200, 10, 500), 0, 255)
    escuro = np.clip(rng.normal(50, 10, 500), 0, 255)
    img = np.concatenate([claro, escuro]).astype(np.uint8).reshape(25, 40)
    assert 80 < limiar_otsu(img) < 170
    assert set(np.unique(binarizar(img, limiar_otsu(img)))) == {0, 255}
projetos/imagens/tests/test_cli.py
from pathlib import Path

import pytest

from imagens.cli import main


def test_gerar_e_processar_de_ponta_a_ponta(
    tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
    assert main(["gerar", str(tmp_path), "--tamanho", "64"]) == 0
    assert main(["processar", str(tmp_path / "cena.png"), str(tmp_path / "saida")]) == 0
    saida = capsys.readouterr().out
    assert "limiar de Otsu" in saida
    nomes = {p.name for p in (tmp_path / "saida").glob("*.png")}
    assert nomes == {
        "cinza.png",
        "contraste.png",
        "equalizada.png",
        "desfoque.png",
        "bordas.png",
        "binaria.png",
        "montagem.png",
    }


def test_arquivo_que_nao_e_png_devolve_codigo_1(
    tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
    falso = tmp_path / "falso.png"
    falso.write_text("nada", encoding="utf-8")
    assert main(["processar", str(falso), str(tmp_path / "s")]) == 1
    assert "Erro" in capsys.readouterr().out

Rodar

Terminal
cd projetos/imagens
uv sync
uv run pytest
uv run imagens gerar saida
uv run imagens processar saida/cena.png saida

Saída

................                                                         [100%]
16 passed
Saída do processamento (execução real)
imagem gravada em saida/cena.png
cinza       min= 52 max=205 média= 106.8
contraste   min=  0 max=255 média=  91.3
equalizada  min=  0 max=255 média= 129.2
desfoque    min= 60 max=196 média= 106.8
bordas      min=  0 max=255 média=  14.8
binaria     min=  0 max=255 média=  32.1
montagem.png: original, cinza, equalizada, desfoque, bordas, binaria
limiar de Otsu: 143

O resultado é o arquivo montagem.png, com as etapas lado a lado, que foi gerado por este mesmo código:

Da esquerda para a direita: a cena original, em cinza, equalizada, desfocada, as bordas (Sobel) e a versão binária pelo limiar de Otsu.
Da esquerda para a direita: a cena original, em cinza, equalizada, desfocada, as bordas (Sobel) e a versão binária pelo limiar de Otsu.

Lendo os números

Os números contam a mesma história que a imagem. O desfoque preserva a média (106,8 antes e depois) e reduz a faixa de 52 a 205 para 60 a 196: ele tira os extremos, que são o ruído. A equalização sobe a média (de 106,8 para 129,2) porque redistribui os níveis, e o contraste esticado só espalha os valores (mínimo 0 e máximo 255) sem os redistribuir. A imagem das bordas é quase toda escura (média 14,8), porque a maior parte da imagem é plana, e o contorno do círculo é o que acende. E a binária tem média 32,1: cerca de 12,6% dos pixels, os do círculo claro, passaram do limiar de 143.

Desafios

  1. Afiar. Acrescente o kernel de nitidez [[0, -1, 0], [-1, 5, -1], [0, -1, 0]] e compare com a imagem original. Em que região a diferença aparece?
  2. Filtro da mediana. Use sliding_window_view com np.median(..., axis=(-1, -2)) para remover ruído do tipo "sal e pimenta" sem borrar as bordas. Compare com o desfoque gaussiano.
  3. Redimensionar. Escreva a redução pela média de blocos (use reshape com 4 dimensões e mean) e depois a ampliação por interpolação bilinear.
  4. Canais. Separe os canais vermelho, verde e azul, equalize cada um e junte de volta com np.stack. O resultado fica melhor ou pior do que equalizar o cinza?

Capítulo 43, parte Projetos

Projeto Avançado: uma rede neural do zero

Propagação, retropropagação, softmax estável, otimizador com momento, uma verificação de gradiente por diferenças finitas, e uma espiral que nenhuma reta separa. Tudo só com NumPy, em cerca de 130 linhas de código (sem contar comentários e linhas em branco). Faça depois do capítulo 37.

Os arquivos deste capítulo estão em projetos/rede_neural/.

O problema

Um classificador separa pontos em classes. Quando uma reta basta, a regressão resolve. A espiral do projeto tem três braços entrelaçados, e nenhuma reta (nem nenhuma combinação simples de retas) os separa. Uma rede neural resolve porque compõe transformações lineares com uma não linearidade (a ReLU), e cada camada dobra o espaço um pouco mais. O que o projeto prova é que não há nada de misterioso: são multiplicações de matrizes e um gradiente.

Peça O que faz Capítulo
Camadas densas a @ W + b, em lote 23
ReLU np.maximum(z, 0) 21
Softmax estável Probabilidades sem overflow 32, 36
Entropia cruzada Mede o erro, com log seguro 15, 36
Retropropagação A regra da cadeia, com transpostas 23
Verificação do gradiente Diferenças finitas contra o analítico 36
Inicialização Gerador com semente, escala de He 16, 35

A matemática, em quatro linhas

Para um lote de n exemplos, com a₀ = X:

  1. Frente: zᵢ = aᵢ₋₁ @ Wᵢ + bᵢ, e aᵢ = ReLU(zᵢ) (a última camada fica sem ReLU, e é ela que alimenta o softmax).
  2. Perda: a entropia cruzada média, −log(p do rótulo certo), mais um termo L2 que desencoraja pesos grandes.
  3. O gradiente que simplifica tudo: na saída, o gradiente da entropia cruzada com softmax é (probabilidades − rótulos) / n. Uma subtração, sem derivar nada.
  4. Trás: o gradiente do peso é aᵢ₋₁ᵀ @ dz, o do viés é a soma de dz nas linhas, e o gradiente que volta para a camada anterior é (dz @ Wᵢᵀ) · (zᵢ₋₁ > 0), onde a máscara é a derivada da ReLU.

O código

Os dados: a espiral, o ou exclusivo (o problema mais simples que uma reta não resolve) e a divisão em treino e teste. Repare que o espiral gera as três classes sem laço, com broadcasting entre (1, n) e (classes, 1):

projetos/rede_neural/src/rede_neural/dados.py
import numpy as np

from rede_neural.tipos import Array, Inteiros


def espiral(
    n_por_classe: int, classes: int, ruido: float, rng: np.random.Generator
) -> tuple[Array, Inteiros]:
    """Braços de espiral entrelaçados: um problema que nenhuma reta separa."""
    raio = np.linspace(0.0, 1.0, n_por_classe)
    angulo = np.linspace(0.0, 4.0, n_por_classe)[None, :] + 4.0 * np.arange(classes)[:, None]
    angulo = angulo + rng.normal(0.0, ruido, size=(classes, n_por_classe))
    pontos = np.stack([raio * np.sin(angulo), raio * np.cos(angulo)], axis=-1)
    X: Array = pontos.reshape(-1, 2)
    y: Inteiros = np.repeat(np.arange(classes), n_por_classe)
    return X, y


def ou_exclusivo(n: int, rng: np.random.Generator) -> tuple[Array, Inteiros]:
    """Pontos em [-1, 1]²: a classe é 1 quando os sinais das coordenadas são diferentes."""
    X: Array = rng.uniform(-1.0, 1.0, size=(n, 2))
    y: Inteiros = ((X[:, 0] > 0) != (X[:, 1] > 0)).astype(np.int64)
    return X, y


def dividir(
    X: Array, y: Inteiros, fracao_teste: float, rng: np.random.Generator
) -> tuple[Array, Array, Inteiros, Inteiros]:
    """Separa treino e teste depois de embaralhar (os dados vêm ordenados por classe)."""
    ordem = rng.permutation(len(X))
    corte = int(len(X) * (1 - fracao_teste))
    treino, teste = ordem[:corte], ordem[corte:]
    return X[treino], X[teste], y[treino], y[teste]

A rede. O método perda_e_gradientes é o coração: a frente guarda as entradas e as pré-ativações de cada camada, e a volta as reutiliza. A inicialização de He escala os pesos pelo inverso da raiz do número de entradas, o que mantém a variância dos sinais estável de camada para camada:

projetos/rede_neural/src/rede_neural/rede.py
import numpy as np

from rede_neural.tipos import Array, Inteiros


def relu(z: Array) -> Array:
    resultado: Array = np.maximum(z, 0.0)
    return resultado


def softmax(z: Array) -> Array:
    """Probabilidades por linha. Subtrair o máximo evita o overflow do exp (capítulo 36)."""
    e = np.exp(z - z.max(axis=1, keepdims=True))
    resultado: Array = e / e.sum(axis=1, keepdims=True)
    return resultado


class RedeDensa:
    """Camadas totalmente conectadas com ReLU e uma saída softmax.

    Convenção de formas: ``X`` é ``(n, entradas)``, o peso da camada i é ``(entradas_i, saidas_i)``
    e a saída é ``(n, classes)``. A forma segue a do scikit-learn: uma linha por exemplo.
    """

    def __init__(self, tamanhos: list[int], rng: np.random.Generator, l2: float = 0.0) -> None:
        if len(tamanhos) < 2:
            raise ValueError("informe ao menos a entrada e a saída")
        self.l2 = l2
        # Inicialização de He: o desvio cresce com o inverso da raiz do número de entradas.
        self.pesos: list[Array] = [
            rng.normal(0.0, np.sqrt(2.0 / entrada), size=(entrada, saida))
            for entrada, saida in zip(tamanhos[:-1], tamanhos[1:], strict=True)
        ]
        self.vieses: list[Array] = [np.zeros(saida) for saida in tamanhos[1:]]

    def parametros(self) -> list[Array]:
        """Os próprios arrays (não cópias): o otimizador e o teste de gradiente os alteram."""
        return [*self.pesos, *self.vieses]

    def _frente(self, X: Array) -> tuple[list[Array], list[Array]]:
        """Devolve a entrada de cada camada e as pré-ativações (antes da ReLU)."""
        entradas = [X]
        pre_ativacoes: list[Array] = []
        a = X
        ultima = len(self.pesos) - 1
        for i, (W, b) in enumerate(zip(self.pesos, self.vieses, strict=True)):
            z = a @ W + b
            pre_ativacoes.append(z)
            a = z if i == ultima else relu(z)
            entradas.append(a)
        return entradas, pre_ativacoes

    def probabilidades(self, X: Array) -> Array:
        _, pre_ativacoes = self._frente(X)
        return softmax(pre_ativacoes[-1])

    def prever(self, X: Array) -> Inteiros:
        previsto: Inteiros = self.probabilidades(X).argmax(axis=1)
        return previsto

    def perda_e_gradientes(self, X: Array, y: Inteiros) -> tuple[float, list[Array]]:
        """Perda de entropia cruzada (mais L2) e o gradiente em relação a cada parâmetro."""
        n = len(X)
        entradas, pre_ativacoes = self._frente(X)
        probs = softmax(pre_ativacoes[-1])
        perda = -np.log(np.maximum(probs[np.arange(n), y], 1e-12)).mean()
        perda += 0.5 * self.l2 * sum(float((W**2).sum()) for W in self.pesos)

        # O gradiente da entropia cruzada com softmax é simplesmente (probabilidade - rótulo) / n.
        dz = probs.copy()
        dz[np.arange(n), y] -= 1.0
        dz /= n

        grad_pesos: list[Array] = []
        grad_vieses: list[Array] = []
        for i in range(len(self.pesos) - 1, -1, -1):
            grad_pesos.insert(0, entradas[i].T @ dz + self.l2 * self.pesos[i])
            grad_vieses.insert(0, dz.sum(axis=0))
            if i > 0:
                dz = (dz @ self.pesos[i].T) * (pre_ativacoes[i - 1] > 0)
        return float(perda), [*grad_pesos, *grad_vieses]

O otimizador, com momento: a velocidade acumula os passos anteriores, e isso atravessa vales estreitos da função de perda mais depressa do que o gradiente puro. Ele altera os parâmetros no lugar, e é por isso que parametros() devolve os próprios arrays, e não cópias:

projetos/rede_neural/src/rede_neural/otimizador.py
import numpy as np

from rede_neural.tipos import Array


class SGDMomento:
    """Gradiente descendente com momento: a velocidade acumula os passos anteriores."""

    def __init__(self, parametros: list[Array], taxa: float = 0.1, momento: float = 0.9) -> None:
        self.parametros = parametros
        self.taxa = taxa
        self.momento = momento
        self.velocidades = [np.zeros_like(p) for p in parametros]

    def passo(self, gradientes: list[Array]) -> None:
        for p, v, g in zip(self.parametros, self.velocidades, gradientes, strict=True):
            v *= self.momento
            v -= self.taxa * g
            p += v

O laço de treino:

projetos/rede_neural/src/rede_neural/treino.py
from dataclasses import dataclass, field

import numpy as np

from rede_neural.otimizador import SGDMomento
from rede_neural.rede import RedeDensa
from rede_neural.tipos import Array, Inteiros


@dataclass
class Historico:
    perdas: list[float] = field(default_factory=list)
    acuracias: list[float] = field(default_factory=list)


def acuracia(rede: RedeDensa, X: Array, y: Inteiros) -> float:
    return float((rede.prever(X) == y).mean())


def treinar(
    rede: RedeDensa,
    X: Array,
    y: Inteiros,
    *,
    epocas: int,
    taxa: float = 0.1,
    momento: float = 0.9,
    tamanho_lote: int | None = None,
    rng: np.random.Generator | None = None,
) -> Historico:
    """Treina a rede. Sem ``tamanho_lote``, cada época é um único passo com todos os dados."""
    otimizador = SGDMomento(rede.parametros(), taxa, momento)
    rng = rng or np.random.default_rng(0)
    historico = Historico()
    for _ in range(epocas):
        if tamanho_lote is None:
            _, gradientes = rede.perda_e_gradientes(X, y)
            otimizador.passo(gradientes)
        else:
            ordem = rng.permutation(len(X))
            for inicio in range(0, len(X), tamanho_lote):
                lote = ordem[inicio : inicio + tamanho_lote]
                _, gradientes = rede.perda_e_gradientes(X[lote], y[lote])
                otimizador.passo(gradientes)
        perda, _ = rede.perda_e_gradientes(X, y)
        historico.perdas.append(perda)
        historico.acuracias.append(acuracia(rede, X, y))
    return historico

E a linha de comando:

projetos/rede_neural/src/rede_neural/cli.py
import argparse

import numpy as np

from rede_neural.dados import dividir, espiral, ou_exclusivo
from rede_neural.rede import RedeDensa
from rede_neural.treino import acuracia, treinar


def criar_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="rede", description="Rede neural só com NumPy")
    parser.add_argument("--semente", type=int, default=0)
    sub = parser.add_subparsers(dest="problema", required=True)
    espiral_p = sub.add_parser("espiral", help="3 braços de espiral entrelaçados")
    espiral_p.add_argument("--epocas", type=int, default=1000)
    espiral_p.add_argument("--oculta", type=int, default=64, help="neurônios por camada oculta")
    espiral_p.add_argument("--taxa", type=float, default=0.1)
    xor_p = sub.add_parser("xor", help="o ou exclusivo, que nenhuma reta resolve")
    xor_p.add_argument("--epocas", type=int, default=500)
    return parser


def main(argv: list[str] | None = None) -> int:
    args = criar_parser().parse_args(argv)
    rng = np.random.default_rng(args.semente)
    if args.problema == "espiral":
        X, y = espiral(100, 3, 0.2, rng)
        tamanhos = [2, args.oculta, args.oculta, 3]
        taxa = args.taxa
    else:
        X, y = ou_exclusivo(200, rng)
        tamanhos = [2, 8, 2]
        taxa = 0.1
    X_treino, X_teste, y_treino, y_teste = dividir(X, y, 0.2, rng)
    rede = RedeDensa(tamanhos, rng, l2=1e-4)

    passo = max(args.epocas // 5, 1)
    historico = treinar(rede, X_treino, y_treino, epocas=args.epocas, taxa=taxa)
    for epoca in range(passo, args.epocas + 1, passo):
        perda, acc = historico.perdas[epoca - 1], historico.acuracias[epoca - 1]
        print(f"época {epoca:>5}  perda {perda:.4f}  acurácia de treino {acc:.3f}")
    print(f"acurácia no treino: {acuracia(rede, X_treino, y_treino):.3f}")
    print(f"acurácia no teste:  {acuracia(rede, X_teste, y_teste):.3f}")
    return 0

Os testes

O teste mais importante deste projeto é o que verifica o gradiente. Escrever a retropropagação à mão é fácil de errar (um sinal, uma transposta), e o erro não derruba nada: a rede apenas aprende mal. A verificação por diferenças finitas, do capítulo 36, perturba cada parâmetro para cima e para baixo, mede a variação da perda e compara com o gradiente calculado. Se concordam em todos os parâmetros, a retropropagação está certa:

projetos/rede_neural/tests/test_rede.py
import numpy as np

from rede_neural.dados import dividir, espiral, ou_exclusivo
from rede_neural.otimizador import SGDMomento
from rede_neural.rede import RedeDensa, softmax
from rede_neural.tipos import Array
from rede_neural.treino import acuracia, treinar


def test_probabilidades_tem_a_forma_certa_e_somam_1() -> None:
    rede = RedeDensa([4, 6, 3], np.random.default_rng(0))
    p = rede.probabilidades(np.random.default_rng(1).normal(size=(5, 4)))
    assert p.shape == (5, 3)
    assert np.allclose(p.sum(axis=1), 1.0)


def test_softmax_e_estavel_com_valores_enormes() -> None:
    p = softmax(np.array([[1000.0, 1001.0, 1002.0]]))
    assert np.isfinite(p).all() and np.isclose(p.sum(), 1.0)


def test_gradiente_confere_com_diferencas_finitas() -> None:
    """O teste mais importante: o gradiente escrito à mão bate com a derivada numérica."""
    rng = np.random.default_rng(3)
    rede = RedeDensa([3, 5, 4, 2], rng, l2=0.01)
    X = rng.normal(size=(6, 3))
    y = rng.integers(0, 2, size=6)
    _, analiticos = rede.perda_e_gradientes(X, y)

    h = 1e-5
    for parametro, analitico in zip(rede.parametros(), analiticos, strict=True):
        numerico = np.zeros_like(parametro)
        for indice in np.ndindex(parametro.shape):
            original = parametro[indice]
            parametro[indice] = original + h
            mais, _ = rede.perda_e_gradientes(X, y)
            parametro[indice] = original - h
            menos, _ = rede.perda_e_gradientes(X, y)
            parametro[indice] = original
            numerico[indice] = (mais - menos) / (2 * h)
        assert np.allclose(analitico, numerico, rtol=1e-4, atol=1e-7)


def test_otimizador_passo_simples_e_momento() -> None:
    p: Array = np.array([1.0])
    sgd = SGDMomento([p], taxa=0.1, momento=0.0)
    sgd.passo([np.array([2.0])])
    assert np.allclose(p, [0.8])

    q: Array = np.array([1.0])
    com_momento = SGDMomento([q], taxa=0.1, momento=0.5)
    com_momento.passo([np.array([2.0])])
    com_momento.passo([np.array([2.0])])
    assert np.allclose(q, [1.0 - 0.2 - 0.3])


def test_a_perda_cai_durante_o_treino() -> None:
    rng = np.random.default_rng(0)
    X, y = espiral(60, 3, 0.2, rng)
    rede = RedeDensa([2, 32, 3], rng)
    historico = treinar(rede, X, y, epocas=200)
    assert historico.perdas[-1] < 0.5 * historico.perdas[0]


def test_aprende_o_ou_exclusivo() -> None:
    rng = np.random.default_rng(0)
    X, y = ou_exclusivo(300, rng)
    Xt, Xv, yt, yv = dividir(X, y, 0.2, rng)
    rede = RedeDensa([2, 8, 2], rng, l2=1e-4)
    treinar(rede, Xt, yt, epocas=500)
    assert acuracia(rede, Xv, yv) >= 0.95


def test_resolve_a_espiral_em_dados_novos() -> None:
    rng = np.random.default_rng(0)
    X, y = espiral(100, 3, 0.2, rng)
    Xt, Xv, yt, yv = dividir(X, y, 0.2, rng)
    rede = RedeDensa([2, 64, 64, 3], rng, l2=1e-4)
    treinar(rede, Xt, yt, epocas=1000)
    assert acuracia(rede, Xt, yt) > 0.97
    assert acuracia(rede, Xv, yv) > 0.95


def test_mesma_semente_mesmo_resultado() -> None:
    def rodar() -> float:
        rng = np.random.default_rng(5)
        X, y = espiral(40, 3, 0.2, rng)
        rede = RedeDensa([2, 16, 3], rng)
        return treinar(rede, X, y, epocas=50).perdas[-1]

    assert rodar() == rodar()
projetos/rede_neural/tests/test_dados.py
import numpy as np

from rede_neural.dados import dividir, espiral, ou_exclusivo


def test_espiral_formas_e_classes() -> None:
    X, y = espiral(50, 3, 0.2, np.random.default_rng(0))
    assert X.shape == (150, 2) and y.shape == (150,)
    assert np.bincount(y).tolist() == [50, 50, 50]


def test_ou_exclusivo_rotulos() -> None:
    X, y = ou_exclusivo(300, np.random.default_rng(1))
    esperado = ((X[:, 0] > 0) != (X[:, 1] > 0)).astype(int)
    assert np.array_equal(y, esperado)
    assert set(np.unique(y)) == {0, 1}


def test_dividir_e_disjunto_e_embaralha() -> None:
    X, y = espiral(50, 3, 0.2, np.random.default_rng(0))
    Xt, Xv, yt, yv = dividir(X, y, 0.2, np.random.default_rng(2))
    assert len(Xt) == 120 and len(Xv) == 30
    assert len(np.unique(yv)) > 1  # o teste não é só de uma classe, porque embaralhou antes
projetos/rede_neural/tests/test_cli.py
import pytest

from rede_neural.cli import main


def test_xor_pela_linha_de_comando(capsys: pytest.CaptureFixture[str]) -> None:
    assert main(["xor", "--epocas", "300"]) == 0
    saida = capsys.readouterr().out
    assert "acurácia no teste" in saida
    assert "época" in saida

Rodar

Terminal
cd projetos/rede_neural
uv sync
uv run pytest
uv run rede espiral

Saída

............                                                             [100%]
12 passed
Treino na espiral (execução real)
época   200  perda 0.0558  acurácia de treino 0.996
época   400  perda 0.0445  acurácia de treino 0.996
época   600  perda 0.0407  acurácia de treino 0.996
época   800  perda 0.0387  acurácia de treino 0.996
época  1000  perda 0.0374  acurácia de treino 0.996
acurácia no treino: 0.996
acurácia no teste:  0.983

E no ou exclusivo, que uma reta jamais resolve:

Terminal
uv run rede xor
Ou exclusivo (execução real)
época   100  perda 0.0994  acurácia de treino 0.981
época   200  perda 0.0707  acurácia de treino 0.981
época   300  perda 0.0603  acurácia de treino 0.988
época   400  perda 0.0547  acurácia de treino 0.988
época   500  perda 0.0511  acurácia de treino 0.994
acurácia no treino: 0.994
acurácia no teste:  1.000

A acurácia no teste (98,3%) é a que importa, porque são pontos que a rede nunca viu. Treino e teste próximos indicam que ela generalizou, em vez de decorar. Eu não escolhi a semente 0 por sorte: com estes parâmetros (duas camadas ocultas de 64 neurônios, taxa 0,1, momento 0,9, 1000 épocas), testei seis sementes diferentes e todas passaram de 98% no teste.

Ver o que a rede aprendeu

O fronteira.py classifica todos os pontos de uma malha do plano de uma vez (o meshgrid do capítulo 27, mais uma única chamada vetorizada a prever) e pinta cada região com a classe prevista. Precisa do matplotlib: uv run --with matplotlib python fronteira.py:

projetos/rede_neural/fronteira.py
"""Desenha a fronteira de decisão que a rede aprendeu na espiral.

Precisa do matplotlib, que não é dependência do projeto:
    uv run --with matplotlib python fronteira.py
"""

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

from rede_neural.dados import dividir, espiral
from rede_neural.rede import RedeDensa
from rede_neural.treino import treinar

rng = np.random.default_rng(0)
X, y = espiral(100, 3, 0.2, rng)
X_treino, X_teste, y_treino, y_teste = dividir(X, y, 0.2, rng)
rede = RedeDensa([2, 64, 64, 3], rng, l2=1e-4)
treinar(rede, X_treino, y_treino, epocas=1000)

# Uma malha de pontos cobrindo o plano: a rede classifica todos de uma vez (vetorizado).
xs = np.linspace(-1.2, 1.2, 300)
malha_x, malha_y = np.meshgrid(xs, xs)
pontos = np.column_stack([malha_x.ravel(), malha_y.ravel()])
classes = rede.prever(pontos).reshape(malha_x.shape)

fig, ax = plt.subplots(figsize=(5, 5), dpi=100)
ax.contourf(malha_x, malha_y, classes, alpha=0.35, levels=[-0.5, 0.5, 1.5, 2.5])
ax.scatter(X_treino[:, 0], X_treino[:, 1], c=y_treino, s=10, edgecolors="none")
ax.scatter(X_teste[:, 0], X_teste[:, 1], c=y_teste, s=28, edgecolors="black", linewidths=0.6)
ax.set_title("Fronteira aprendida (pontos com borda: teste)")
ax.set_aspect("equal")
fig.savefig("fronteira.png")
As regiões que a rede aprendeu para as três classes. Os pontos com borda preta são de teste, que a rede nunca viu durante o treino.
As regiões que a rede aprendeu para as três classes. Os pontos com borda preta são de teste, que a rede nunca viu durante o treino.

A fronteira acompanha o enrolar dos braços, e os pontos de teste caem, em quase todos os casos, na região da própria cor.

O que este projeto não é

É um exercício para entender, e não um substituto de um framework. Ele só tem camadas densas, e a retropropagação foi escrita à mão para esta arquitetura. Para qualquer outra, o PyTorch e o JAX derivam o gradiente automaticamente (e rodam em GPU), e é por isso que ninguém escreve isso à mão em produção. A vantagem de ter escrito uma vez é que, quando um treino não converge, você sabe onde olhar: o gradiente, a escala da inicialização, a taxa de aprendizado.

Desafios

  1. Adam. Troque o otimizador pelo Adam (médias móveis do gradiente e do seu quadrado) e compare a perda por época com o momento.
  2. Mini-lotes. O treinar já aceita tamanho_lote. Compare a convergência com lotes de 32 e com o lote completo, em tempo e em perda.
  3. Dropout. Acrescente a regularização por dropout (zerar aleatoriamente uma fração dos neurônios no treino) e verifique o gradiente de novo: o teste ainda passa?
  4. Parada antecipada. Pare o treino quando a perda no conjunto de validação deixar de cair.
  5. Comparar com o scikit-learn. Treine um MLPClassifier (capítulo 40) nos mesmos dados e compare as acurácias. A sua rede chega perto?

O que eu aprendi com este tipo de projeto

Escrever a retropropagação uma vez desmistifica o aprendizado profundo, e a verificação do gradiente é o hábito que mais evita horas de depuração. Em qualquer código numérico escrito à mão, eu comparo o resultado com uma versão lenta e obviamente correta antes de confiar nele. É o mesmo princípio dos testes dos outros dois projetos.