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.
Cada arquivo roda sozinho, a partir da raiz do repositório. Com o uv, sem instalar nada antes, o NumPy vem junto:
Terminal
uvrunbásico/cap05_criando_arrays.py
Os capítulos da parte Ecossistema usam mais bibliotecas, que o grupo ecossistema instala de uma vez:
Terminal
uvsync--groupecossistema
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".
Alexsander Valente
Software & AI Architecture, Product Engineering e Data Engineering.
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.”
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
importimportlib.utilimportsysimportnumpyasnpprint("numpy faz parte da biblioteca padrão?","numpy"insys.stdlib_module_names)print("numpy está instalado aqui?",importlib.util.find_spec("numpy")isnotNone)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
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
uvinitmeu-projeto
cdmeu-projeto
uvaddnumpy
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
importsysfrompathlibimportPathimportnumpyasnpprint("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:
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.
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
uvadd--devjupyterlabipykernel
uvrunjupyterlab
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={}forcelulaincelulas:exec(celula,kernel)celulas[0]="total = 99"exec(celulas[0],kernel)print("o que a tela mostra:",kernel["dobro"])novo_kernel={}forcelulaincelulas: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).# %%importnumpyasnpvendas=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
importsysimportnumpyasnplista=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)forxinlista)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:
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
importtimedefmedir(funcao,repeticoes=5):melhor=float("inf")for_inrange(repeticoes):inicio=time.perf_counter()funcao()melhor=min(melhor,time.perf_counter()-inicio)returnmelhorgrande_lista=list(range(1_000_000))grande_array=np.arange(1_000_000)t_lista=medir(lambda:[x*2forxingrande_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.
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:
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]])exceptValueErroraserro:print("erro de forma irregular:","inhomogeneous"instr(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:
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.
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.
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:
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:
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)).
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:
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:
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:
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:
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:
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.
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":
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:
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:
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]exceptIndexErroraserro: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]].
É 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:
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:
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:
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:
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:
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:
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:
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.
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ê":
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:
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:
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)).
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:
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:
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
defmedir(funcao,repeticoes=3):melhor=float("inf")for_inrange(repeticoes):inicio=time.perf_counter()funcao()melhor=min(melhor,time.perf_counter()-inicio)returnmelhordados=np.arange(200_000,dtype=np.float64)defcom_laco(a):saida=np.empty_like(a)foriinrange(a.size):saida[i]=a[i]**2+1returnsaidadefvetorizado(a):returna**2+1print(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
importmathtry:math.sqrt(np.array([4.0,9.0]))exceptTypeErroraserro: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?":
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².
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:
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:
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:
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:
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:
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:
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.
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**.
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:
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:
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).
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:
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:
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:
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:
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.
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:
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:
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:
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:
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:
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:
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.
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
importtimeitimportnumpyasnpa=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
defrazao(n):lista=list(range(n))array=np.arange(n)t_lista=min(timeit.repeat(lambda:[x*2forxinlista],number=50,repeat=5))t_array=min(timeit.repeat(lambda:array*2,number=50,repeat=5))returnt_lista/t_arrayprint("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:
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().
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
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
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
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
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
importtimeitdefcom_append(n):a=np.array([])foriinrange(n):a=np.append(a,i)returnadefcom_lista(n):returnnp.array([iforiinrange(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
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
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
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
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
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
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:
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:
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:
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.whereavalia 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:
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.
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):
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):
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:
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 weightssoma valores por grupo. É um GROUP BY do SQL em uma linha, para categorias numeradas de 0 em diante:
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:
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*IQRou acima de Q3 + k*IQR.
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
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
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
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
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
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:
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:
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:
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).
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
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
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
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
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
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
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
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 datetime64nã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
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
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
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
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
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
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
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
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
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
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:
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:
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
importtimeitx=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 dtypenã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:
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:
O as_stridednã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.
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):
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:
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 é:
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.
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):
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_000print(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:
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:
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:
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.
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 (+=, -=):
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
defpico_de_memoria(funcao):tracemalloc.start()funcao()_,pico=tracemalloc.get_traced_memory()tracemalloc.stop()returnpicopico_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:
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
defmedir(funcao,repeticoes=3):melhor=float("inf")for_inrange(repeticoes):inicio=time.perf_counter()funcao()melhor=min(melhor,time.perf_counter()-inicio)returnmelhorx=np.arange(100_000)print("iterar a lista é mais rápido:",medir(lambda:[vforvinx.tolist()])<medir(lambda:[vforvinx]))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.
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:
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:
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:
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.
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:
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] += 1nã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:
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:
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
importtimeitdefpassos_collatz(n):passos=0whilen!=1:n=n//2ifn%2==0else3*n+1passos+=1returnpassosvetorizada=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))forninentradas],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:
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.
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
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
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
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
defdispersao(n,repeticoes=30):rng=np.random.default_rng(3)returnnp.std([estimar_pi(n,rng)for_inrange(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
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
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
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:
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:
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:
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:
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-10print(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:
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:
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).
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:
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
defexigir_forma(a,esperada):ifa.ndim!=len(esperada)orany(eisnotNoneande!=sfore,sinzip(esperada,a.shape)):raiseValueError(f"forma {a.shape} incompatível com {esperada}")returnatry:exigir_forma(np.zeros((3,4)),(None,5))exceptValueErroraserro: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:
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)exceptValueErroraserro:print("copy=False falha quando é preciso copiar:",type(erro).__name__)x=np.array([1.0,2.0])print(np.asarray(x,copy=False)isx,np.array(x,copy=None)isx)
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:
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
defvalidar_matriz(a):m=np.asarray(a,dtype=np.float64)ifm.ndim!=2:raiseValueError(f"esperava 2 dimensões, recebi {m.ndim}")ifnotnp.isfinite(m).all():raiseValueError("a matriz contém nan ou infinitos")returnmassertvalidar_matriz([[1,2],[3,4]]).dtype==np.float64forruimin([1,2,3],[[1.0,np.nan]],[[np.inf,1.0]]):try:validar_matriz(ruim)exceptValueError:passelse:raiseAssertionError(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.
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:
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:
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:
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:
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.
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
uvsync--groupecossistema
ecossistema/cap39_numpy_matplotlib.pylinhas 10 a 22
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
É 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
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
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
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
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
uvsync--groupecossistema
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:
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:
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):
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:
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:
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.
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
fromdataclassesimportdataclassimportnumpyasnpimportnumpy.typingasnptReais=npt.NDArray[np.float64]Inteiros=npt.NDArray[np.int64]@dataclass(frozen=True)classTurma:"""Cinco arrays do mesmo tamanho: a posição i descreve sempre o mesmo estudante."""ids:Inteirosnotas:Reaispresenca:Reaishoras_estudo:Reaistarefas:Inteirosdef__post_init__(self)->None:tamanhos={len(self.ids),len(self.notas),len(self.presenca)}tamanhos|={len(self.horas_estudo),len(self.tarefas)}iflen(tamanhos)!=1:raiseValueError("todos os arrays da turma precisam ter o mesmo tamanho")def__len__(self)->int:returnlen(self.ids)defgerar_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)returnTurma(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
importnumpyasnpfromestudantes.dadosimportInteiros,Turmadefnivel_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()),}defnivel_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],}defnivel_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)],}defcorrelacoes(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])fori,nomeinenumerate(variaveis)}defperfil_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)ifnotmascara.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()),}defmelhores(t:Turma,k:int=5)->Inteiros:"""Ids dos k estudantes com as maiores notas, da maior para a menor."""returnt.ids[np.argsort(-t.notas,kind="stable")[:k]]defestudante(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)ifposicoes.size==0:raiseKeyError(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
fromestudantes.analiseimport(correlacoes,melhores,nivel_1,nivel_2,nivel_3,perfil_do_grupo,)fromestudantes.dadosimportTurmadefmontar_relatorio(t:Turma)->str:linhas=[f"Turma com {len(t)} estudantes","","Nível 1: estatísticas das notas"]fornome,valorinnivel_1(t).items():linhas.append(f" {nome:<14}{valor:>8.2f}")linhas+=["","Nível 2: grupos por uma condição"]fornome,idsinnivel_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)fornome,idsingrupos.items():linhas.append(f" {nome:<28}{len(ids):>4} estudantes")linhas+=["","Correlação de cada variável com a nota"]fornome,valorincorrelacoes(t).items():linhas.append(f" {nome:<14}{valor:>7.2f}")perfil=perfil_do_grupo(t,grupos["presenca_alta_nota_baixa"])ifperfil: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))foriinmelhores(t,5))]return"\n".join(linhas)
projetos/estudantes/src/estudantes/cli.py
importargparsefrompathlibimportPathimportnumpyasnpfromestudantes.analiseimportestudantefromestudantes.dadosimportgerar_turmafromestudantes.relatorioimportmontar_relatoriodefcriar_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)returnparserdefmain(argv:list[str]|None=None)->int:args=criar_parser().parse_args(argv)turma=gerar_turma(args.n,args.semente)ifargs.comando=="relatorio":print(montar_relatorio(turma))elifargs.comando=="aluno":try:dados=estudante(turma,args.id)exceptKeyErroraserro:print(f"Erro: {erro.args[0]}")return1forchave,valorindados.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}")return0
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:
importnumpyasnpfromestudantes.analiseimport(correlacoes,estudante,melhores,nivel_1,nivel_2,nivel_3,perfil_do_grupo,)fromestudantes.dadosimportgerar_turmaT=gerar_turma(200)deftest_nivel_1_e_coerente()->None:r=nivel_1(T)assertr["menor"]<=r["mediana"]<=r["maior"]assertr["menor"]<=r["media"]<=r["maior"]assertnp.isclose(r["desvio_padrao"]**2,r["variancia"])deftest_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])foriinrange(len(T))ifT.notas[i]>90]assertgrupos["nota_acima_de_90"].tolist()==por_lacopor_laco=[int(T.ids[i])foriinrange(len(T))ifT.tarefas[i]>8]assertgrupos["mais_de_8_tarefas"].tolist()==por_lacodeftest_nivel_3_e_subconjunto_do_nivel_2()->None:n2,n3=nivel_2(T),nivel_3(T)assertset(n3["nota_alta_e_presenca_alta"])<=set(n2["presenca_acima_de_90"])assertset(n3["presenca_alta_nota_baixa"])<=set(n2["presenca_acima_de_90"])assertset(n3["nota_baixa_e_presenca_baixa"])<=set(n2["nota_abaixo_de_40"])deftest_a_nota_correlaciona_mais_com_as_horas_de_estudo()->None:c=correlacoes(T)assertc["horas_estudo"]>c["tarefas"]>c["presenca"]>0deftest_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"fornome,grupoin{**nivel_2(T),**nivel_3(T)}.items():ifnome!=excecao:assertlen(grupo)>0,nomedeftest_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)assertlen(n3["pouco_estudo_nota_alta"])<len(n3["presenca_alta_nota_baixa"])deftest_perfil_do_grupo()->None:ids=nivel_2(T)["nota_acima_de_90"]perfil=perfil_do_grupo(T,ids)assertperfil["tamanho"]==len(ids)assertperfil["nota_media"]>90assertperfil_do_grupo(T,np.array([],dtype=np.int64))=={}deftest_melhores_e_busca_por_id()->None:topo=melhores(T,3)notas_do_topo=[estudante(T,int(i))["nota"]foriintopo]assertnotas_do_topo==sorted(notas_do_topo,reverse=True)assertnotas_do_topo[0]==T.notas.max()assertestudante(T,1)["id"]==1
projetos/estudantes/tests/test_cli.py
frompathlibimportPathimportnumpyasnpimportpytestfromestudantes.cliimportmaindeftest_relatorio(capsys:pytest.CaptureFixture[str])->None:assertmain(["relatorio"])==0saida=capsys.readouterr().outassert"Turma com 200 estudantes"insaidaassert"Nível 3"insaidadeftest_aluno_inexistente_devolve_1(capsys:pytest.CaptureFixture[str])->None:assertmain(["aluno","9999"])==1assert"não existe o estudante 9999"incapsys.readouterr().outdeftest_exportar_csv(tmp_path:Path)->None:destino=tmp_path/"turma.csv"assertmain(["--n","30","exportar",str(destino)])==0lido=np.loadtxt(destino,delimiter=",",skiprows=1)assertlido.shape==(30,5)
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
uvrunestudantesaluno17
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
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.
Visualizar. Com o capítulo 39, desenhe a nota contra as horas de estudo (scatter) e o histograma das notas.
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?
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ê apenasesse mesmo formato, ou seja, os arquivos gravados por este módulo."""importstructimportzlibfrompathlibimportPathimportnumpyasnpfromimagens.tiposimportBytesASSINATURA=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)))defsalvar_png(caminho:Path,img:Bytes)->None:ifimg.dtype!=np.uint8:raiseValueError("a imagem precisa ser uint8")ifimg.ndim==2:tipo_cor=0elifimg.ndim==3andimg.shape[2]==3:tipo_cor=2else:raiseValueError("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)defler_png(caminho:Path)->Bytes:dados=caminho.read_bytes()ifnotdados.startswith(ASSINATURA):raiseValueError("não é um arquivo PNG")posicao=len(ASSINATURA)largura=altura=tipo_cor=0comprimidos=b""whileposicao<len(dados):(tamanho,)=struct.unpack(">I",dados[posicao:posicao+4])tipo=dados[posicao+4:posicao+8]corpo=dados[posicao+8:posicao+8+tamanho]iftipo==b"IHDR":largura,altura,profundidade,tipo_cor,*_=struct.unpack(">IIBBBBB",corpo)ifprofundidade!=8ortipo_cornotin(0,2):raiseValueError("só leio PNG de 8 bits em cinza ou RGB")eliftipo==b"IDAT":comprimidos+=corpoposicao+=12+tamanhocanais=1iftipo_cor==0else3bruto=np.frombuffer(zlib.decompress(comprimidos),dtype=np.uint8)linhas=bruto.reshape(altura,1+largura*canais)iflinhas[:,0].any():raiseValueError("só leio PNG gravado sem filtro por linha (como o deste módulo)")pixels=linhas[:,1:]returnpixels.reshape(altura,largura)ifcanais==1elsepixels.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
importnumpyasnpfromimagens.tiposimportBytesdefcriar_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]=90centro=tamanho*0.35circulo=(x-centro)**2+(y-centro)**2<=(tamanho*0.2)**2cena[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)returnnp.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
importnumpyasnpimportnumpy.typingasnptfromimagens.tiposimportBytesPESOS_CINZA=np.array([0.299,0.587,0.114])defpara_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)returncinzadefesticar_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())ifmaior==menor:returnimg.copy()esticada=(img.astype(np.float64)-menor)*(255/(maior-menor))returnnp.clip(esticada.round(),0,255).astype(np.uint8)defhistograma(img:Bytes)->npt.NDArray[np.intp]:returnnp.bincount(img.ravel(),minlength=256)defequalizar(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]])ifcdf[-1]==cdf_min:returnimg.copy()tabela=np.clip(np.round((cdf-cdf_min)/(cdf[-1]-cdf_min)*255),0,255).astype(np.uint8)equalizada:Bytes=tabela[img]returnequalizadadeflimiar_otsu(img:Bytes)->int:"""Limiar que separa a imagem em dois grupos com a maior variância entre eles (Otsu)."""prob=histograma(img)/img.sizeniveis=np.arange(256)omega=np.cumsum(prob)mu=np.cumsum(prob*niveis)withnp.errstate(divide="ignore",invalid="ignore"):variancia_entre=(mu[-1]*omega-mu)**2/(omega*(1-omega))returnint(np.argmax(np.nan_to_num(variancia_entre)))defbinarizar(img:Bytes,limiar:int)->Bytes:returnnp.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
importnumpyasnpfromnumpy.lib.stride_tricksimportsliding_window_viewfromimagens.tiposimportBytes,Reaisdefaplicar_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.shapeifaltura_k%2==0orlargura_k%2==0:raiseValueError("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)returnresultadodefkernel_caixa(k:int)->Reais:returnnp.full((k,k),1.0/(k*k))defkernel_gaussiano(k:int,sigma:float)->Reais:eixo=np.arange(k)-k//2g=np.exp(-(eixo**2)/(2*sigma**2))kernel:Reais=np.outer(g,g)returnkernel/kernel.sum()SOBEL_X=np.array([[-1.0,0.0,1.0],[-2.0,0.0,2.0],[-1.0,0.0,1.0]])defbordas_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)returnnp.hypot(gx,gy)defpara_uint8(img:Reais)->Bytes:"""Reescala qualquer array decimal para 0..255, para poder gravar como imagem."""menor,maior=float(img.min()),float(img.max())ifmaior==menor:returnnp.zeros(img.shape,dtype=np.uint8)returnnp.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
importargparsefrompathlibimportPathimportnumpyasnpfromimagens.filtrosimportaplicar_kernel,bordas_sobel,kernel_gaussiano,para_uint8fromimagens.pngioimportler_png,salvar_pngfromimagens.sinteticaimportcriar_cenafromimagens.tonsimportbinarizar,equalizar,esticar_contraste,limiar_otsu,para_cinzadefcriar_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)returnparserdefmain(argv:list[str]|None=None)->int:args=criar_parser().parse_args(argv)args.pasta.mkdir(parents=True,exist_ok=True)ifargs.comando=="gerar":destino=args.pasta/"cena.png"salvar_png(destino,criar_cena(args.tamanho))print(f"imagem gravada em {destino}")return0try:rgb=ler_png(args.entrada)except(OSError,ValueError)aserro:print(f"Erro: {erro}")return1cinza=para_cinza(rgb)ifrgb.ndim==3elsergbsuave=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),}fornome,imageminetapas.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=rgbifrgb.ndim==3elsenp.stack([rgb]*3,axis=-1)nomes=("cinza","equalizada","desfoque","bordas","binaria")tiras=[np.stack([etapas[nome]]*3,axis=-1)fornomeinnomes]salvar_png(args.pasta/"montagem.png",np.hstack([original,*tiras]))print("montagem.png: original, "+", ".join(nomes))print(f"limiar de Otsu: {limiar}")return0
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
frompathlibimportPathimportnumpyasnpimportpytestfromimagens.pngioimportASSINATURA,ler_png,salvar_pngdeftest_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)assertnp.array_equal(ler_png(tmp_path/"c.png"),cinza)assertnp.array_equal(ler_png(tmp_path/"r.png"),rgb)deftest_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)deftest_entradas_invalidas(tmp_path:Path)->None:withpytest.raises(ValueError,match="uint8"):salvar_png(tmp_path/"a.png",np.zeros((2,2)))withpytest.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")withpytest.raises(ValueError,match="PNG"):ler_png(tmp_path/"texto.png")
projetos/imagens/tests/test_filtros.py
importnumpyasnpimportpytestfromimagens.filtrosimport(aplicar_kernel,bordas_sobel,kernel_caixa,kernel_gaussiano,para_uint8,)defaplicar_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]//2padded=np.pad(img,k,mode="edge")saida=np.zeros_like(img)foriinrange(img.shape[0]):forjinrange(img.shape[1]):saida[i,j]=(padded[i:i+2*k+1,j:j+2*k+1]*kernel).sum()returnsaidadeftest_confere_com_a_versao_por_laco()->None:rng=np.random.default_rng(0)img=rng.random((6,7))kernel=rng.random((3,3))assertnp.allclose(aplicar_kernel(img,kernel),aplicar_por_laco(img,kernel))deftest_kernel_de_tamanho_par_e_recusado()->None:withpytest.raises(ValueError,match="ímpares"):aplicar_kernel(np.zeros((4,4)),np.ones((2,2)))deftest_desfoque_preserva_imagem_constante_e_reduz_ruido()->None:assertnp.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))assertaplicar_kernel(ruido,kernel_gaussiano(5,1.2)).std()<0.5*ruido.std()deftest_kernel_gaussiano_soma_1_e_e_simetrico()->None:k=kernel_gaussiano(5,1.0)assertnp.isclose(k.sum(),1.0)assertnp.allclose(k,k.T)andnp.allclose(k,k[::-1,::-1])deftest_sobel_acha_a_borda_vertical()->None:img=np.zeros((9,9),dtype=np.uint8)img[:,5:]=255mag=bordas_sobel(img)assertset(np.argmax(mag,axis=1))<={4,5}assertnp.allclose(mag[:,:3],0)andnp.allclose(mag[:,7:],0)deftest_para_uint8_reescala_para_0_255()->None:r=para_uint8(np.array([[-5.0,0.0],[5.0,10.0]]))assertr.dtype==np.uint8andr.min()==0andr.max()==255assertpara_uint8(np.ones((2,2))).max()==0
frompathlibimportPathimportpytestfromimagens.cliimportmaindeftest_gerar_e_processar_de_ponta_a_ponta(tmp_path:Path,capsys:pytest.CaptureFixture[str])->None:assertmain(["gerar",str(tmp_path),"--tamanho","64"])==0assertmain(["processar",str(tmp_path/"cena.png"),str(tmp_path/"saida")])==0saida=capsys.readouterr().outassert"limiar de Otsu"insaidanomes={p.nameforpin(tmp_path/"saida").glob("*.png")}assertnomes=={"cinza.png","contraste.png","equalizada.png","desfoque.png","bordas.png","binaria.png","montagem.png",}deftest_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")assertmain(["processar",str(falso),str(tmp_path/"s")])==1assert"Erro"incapsys.readouterr().out
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.
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
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?
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.
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.
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:
Frente:zᵢ = aᵢ₋₁ @ Wᵢ + bᵢ, e aᵢ = ReLU(zᵢ) (a última camada fica sem ReLU, e é ela que alimenta o softmax).
Perda: a entropia cruzada média, −log(p do rótulo certo), mais um termo L2 que desencoraja pesos grandes.
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.
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
importnumpyasnpfromrede_neural.tiposimportArray,Inteirosdefespiral(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)returnX,ydefou_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)returnX,ydefdividir(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:]returnX[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
importnumpyasnpfromrede_neural.tiposimportArray,Inteirosdefrelu(z:Array)->Array:resultado:Array=np.maximum(z,0.0)returnresultadodefsoftmax(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)returnresultadoclassRedeDensa:"""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:iflen(tamanhos)<2:raiseValueError("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))forentrada,saidainzip(tamanhos[:-1],tamanhos[1:],strict=True)]self.vieses:list[Array]=[np.zeros(saida)forsaidaintamanhos[1:]]defparametros(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=Xultima=len(self.pesos)-1fori,(W,b)inenumerate(zip(self.pesos,self.vieses,strict=True)):z=a@W+bpre_ativacoes.append(z)a=zifi==ultimaelserelu(z)entradas.append(a)returnentradas,pre_ativacoesdefprobabilidades(self,X:Array)->Array:_,pre_ativacoes=self._frente(X)returnsoftmax(pre_ativacoes[-1])defprever(self,X:Array)->Inteiros:previsto:Inteiros=self.probabilidades(X).argmax(axis=1)returnprevistodefperda_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())forWinself.pesos)# O gradiente da entropia cruzada com softmax é simplesmente (probabilidade - rótulo) / n.dz=probs.copy()dz[np.arange(n),y]-=1.0dz/=ngrad_pesos:list[Array]=[]grad_vieses:list[Array]=[]foriinrange(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))ifi>0:dz=(dz@self.pesos[i].T)*(pre_ativacoes[i-1]>0)returnfloat(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:
importnumpyasnpfromrede_neural.tiposimportArrayclassSGDMomento:"""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=parametrosself.taxa=taxaself.momento=momentoself.velocidades=[np.zeros_like(p)forpinparametros]defpasso(self,gradientes:list[Array])->None:forp,v,ginzip(self.parametros,self.velocidades,gradientes,strict=True):v*=self.momentov-=self.taxa*gp+=v
O laço de treino:
projetos/rede_neural/src/rede_neural/treino.py
fromdataclassesimportdataclass,fieldimportnumpyasnpfromrede_neural.otimizadorimportSGDMomentofromrede_neural.redeimportRedeDensafromrede_neural.tiposimportArray,Inteiros@dataclassclassHistorico:perdas:list[float]=field(default_factory=list)acuracias:list[float]=field(default_factory=list)defacuracia(rede:RedeDensa,X:Array,y:Inteiros)->float:returnfloat((rede.prever(X)==y).mean())deftreinar(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=rngornp.random.default_rng(0)historico=Historico()for_inrange(epocas):iftamanho_loteisNone:_,gradientes=rede.perda_e_gradientes(X,y)otimizador.passo(gradientes)else:ordem=rng.permutation(len(X))forinicioinrange(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))returnhistorico
E a linha de comando:
projetos/rede_neural/src/rede_neural/cli.py
importargparseimportnumpyasnpfromrede_neural.dadosimportdividir,espiral,ou_exclusivofromrede_neural.redeimportRedeDensafromrede_neural.treinoimportacuracia,treinardefcriar_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)returnparserdefmain(argv:list[str]|None=None)->int:args=criar_parser().parse_args(argv)rng=np.random.default_rng(args.semente)ifargs.problema=="espiral":X,y=espiral(100,3,0.2,rng)tamanhos=[2,args.oculta,args.oculta,3]taxa=args.taxaelse:X,y=ou_exclusivo(200,rng)tamanhos=[2,8,2]taxa=0.1X_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)forepocainrange(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}")return0
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
importnumpyasnpfromrede_neural.dadosimportdividir,espiral,ou_exclusivofromrede_neural.otimizadorimportSGDMomentofromrede_neural.redeimportRedeDensa,softmaxfromrede_neural.tiposimportArrayfromrede_neural.treinoimportacuracia,treinardeftest_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)))assertp.shape==(5,3)assertnp.allclose(p.sum(axis=1),1.0)deftest_softmax_e_estavel_com_valores_enormes()->None:p=softmax(np.array([[1000.0,1001.0,1002.0]]))assertnp.isfinite(p).all()andnp.isclose(p.sum(),1.0)deftest_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-5forparametro,analiticoinzip(rede.parametros(),analiticos,strict=True):numerico=np.zeros_like(parametro)forindiceinnp.ndindex(parametro.shape):original=parametro[indice]parametro[indice]=original+hmais,_=rede.perda_e_gradientes(X,y)parametro[indice]=original-hmenos,_=rede.perda_e_gradientes(X,y)parametro[indice]=originalnumerico[indice]=(mais-menos)/(2*h)assertnp.allclose(analitico,numerico,rtol=1e-4,atol=1e-7)deftest_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])])assertnp.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])])assertnp.allclose(q,[1.0-0.2-0.3])deftest_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)asserthistorico.perdas[-1]<0.5*historico.perdas[0]deftest_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)assertacuracia(rede,Xv,yv)>=0.95deftest_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)assertacuracia(rede,Xt,yt)>0.97assertacuracia(rede,Xv,yv)>0.95deftest_mesma_semente_mesmo_resultado()->None:defrodar()->float:rng=np.random.default_rng(5)X,y=espiral(40,3,0.2,rng)rede=RedeDensa([2,16,3],rng)returntreinar(rede,X,y,epocas=50).perdas[-1]assertrodar()==rodar()
projetos/rede_neural/tests/test_dados.py
importnumpyasnpfromrede_neural.dadosimportdividir,espiral,ou_exclusivodeftest_espiral_formas_e_classes()->None:X,y=espiral(50,3,0.2,np.random.default_rng(0))assertX.shape==(150,2)andy.shape==(150,)assertnp.bincount(y).tolist()==[50,50,50]deftest_ou_exclusivo_rotulos()->None:X,y=ou_exclusivo(300,np.random.default_rng(1))esperado=((X[:,0]>0)!=(X[:,1]>0)).astype(int)assertnp.array_equal(y,esperado)assertset(np.unique(y))=={0,1}deftest_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))assertlen(Xt)==120andlen(Xv)==30assertlen(np.unique(yv))>1# o teste não é só de uma classe, porque embaralhou antes
projetos/rede_neural/tests/test_cli.py
importpytestfromrede_neural.cliimportmaindeftest_xor_pela_linha_de_comando(capsys:pytest.CaptureFixture[str])->None:assertmain(["xor","--epocas","300"])==0saida=capsys.readouterr().outassert"acurácia no teste"insaidaassert"época"insaida
é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
uvrunredexor
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"""importmatplotlibmatplotlib.use("Agg")importmatplotlib.pyplotaspltimportnumpyasnpfromrede_neural.dadosimportdividir,espiralfromrede_neural.redeimportRedeDensafromrede_neural.treinoimporttreinarrng=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.
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
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.
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.
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?
Parada antecipada. Pare o treino quando a perda no conjunto de validação deixar de cair.
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.