Fase 1 — Fundamentos | Pré-requisitos: Aulas 0 a 4 | Duração estimada: 75 minutos
Introdução
NumPy é a fundação numérica do ecossistema científico Python. pandas é construído sobre NumPy. matplotlib usa NumPy. scikit-learn usa NumPy. Entender NumPy não é apenas aprender mais uma biblioteca — é entender o substrato sobre o qual toda análise de dados em Python opera.
O conceito central do NumPy é o array: uma estrutura de dados homogênea e multidimensional otimizada para operações numéricas. Homogênea significa que todos os elementos têm o mesmo tipo. Isso contrasta com listas Python, que podem misturar inteiros, strings e objetos no mesmo container. Essa restrição é justamente o que torna o NumPy rápido: quando todos os elementos têm o mesmo tipo e tamanho em memória, operações podem ser executadas em blocos usando instruções de baixo nível do processador, sem o overhead de verificar o tipo de cada elemento individualmente.
Esta aula cobre a criação e manipulação de arrays, operações vetorizadas, broadcasting, e quando usar NumPy versus pandas. Ao final você vai entender por que loops explícitos em Python são quase sempre a abordagem errada para dados numéricos.
O problema com loops em Python
Antes de entrar no NumPy, é importante entender o problema que ele resolve.
Suponha que você tem uma lista com um milhão de números e quer multiplicar cada um por 2. Em Python puro:
import time
numeros = list(range(1_000_000))
inicio = time.time()
resultado = [x * 2 for x in numeros]
fim = time.time()
print(f"Python list comprehension: {fim - inicio:.4f} segundos")
Agora com NumPy:
import numpy as np
array = np.arange(1_000_000)
inicio = time.time()
resultado = array * 2
fim = time.time()
print(f"NumPy: {fim - inicio:.4f} segundos")
O NumPy é tipicamente 10 a 100 vezes mais rápido para operações numéricas. A razão é que array * 2 não é um loop Python — é uma chamada a código C compilado que opera sobre o bloco inteiro de memória de uma vez. Python mal entra no caminho.
Isso tem uma consequência prática importante: em análise de dados, sempre que você se pegar escrevendo um loop for sobre linhas de um DataFrame ou elementos de um array, pergunte primeiro se existe uma operação vetorizada que faz a mesma coisa. Quase sempre existe, e quase sempre é muito mais rápida.
Criando arrays
import numpy as np
# A partir de uma lista Python
a = np.array([1, 2, 3, 4, 5])
print(a) # [1 2 3 4 5]
print(a.dtype) # int64
# Array de floats (o ponto decimal força float)
b = np.array([1.0, 2.0, 3.0])
print(b.dtype) # float64
# Array bidimensional (matriz)
m = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
print(m.shape) # (3, 3)
# Sequências
np.arange(0, 10, 2) # [0 2 4 6 8] — início, fim (exclusivo), passo
np.linspace(0, 1, 5) # [0. 0.25 0.5 0.75 1. ] — n pontos igualmente espaçados
# Arrays especiais
np.zeros((3, 4)) # matriz 3x4 de zeros
np.ones((2, 3)) # matriz 2x3 de uns
np.eye(3) # matriz identidade 3x3
np.full((2, 2), 7) # matriz 2x2 preenchida com 7
# Números aleatórios
np.random.seed(42) # semente para reprodutibilidade
np.random.rand(3, 3) # uniformes entre 0 e 1
np.random.randn(3, 3) # distribuição normal padrão (média 0, std 1)
np.random.randint(0, 100, size=(3, 3)) # inteiros aleatórios entre 0 e 99
np.random.choice([10, 20, 30, 40], size=5) # amostragem com reposição
Atributos fundamentais de um array
a = np.array([[1, 2, 3], [4, 5, 6]])
a.shape # (2, 3) — dimensões: 2 linhas, 3 colunas
a.ndim # 2 — número de dimensões
a.size # 6 — total de elementos
a.dtype # dtype('int64') — tipo dos elementos
a.nbytes # 48 — tamanho total em bytes (6 elementos × 8 bytes cada)
Indexação e fatiamento
A indexação em NumPy é similar à de listas Python, mas se estende a múltiplas dimensões:
a = np.array([10, 20, 30, 40, 50])
a[0] # 10 — primeiro elemento
a[-1] # 50 — último elemento
a[1:4] # [20 30 40] — do índice 1 ao 3 (4 exclusivo)
a[::2] # [10 30 50] — de 2 em 2
a[::-1] # [50 40 30 20 10] — invertido
# Array 2D
m = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
m[0, 0] # 1 — linha 0, coluna 0
m[1, 2] # 6 — linha 1, coluna 2
m[0, :] # [1 2 3] — linha 0 inteira
m[:, 1] # [2 5 8] — coluna 1 inteira
m[0:2, 0:2] # [[1 2] [4 5]] — submatriz das duas primeiras linhas e colunas
Indexação booleana
Assim como no pandas (que herdou esse comportamento do NumPy), você pode usar arrays booleanos como máscaras:
a = np.array([15, 3, 28, 7, 42, 11])
# Criar máscara booleana
mascara = a > 10
print(mascara) # [ True False True False True True]
# Aplicar máscara
a[mascara] # [15 28 42 11]
# Diretamente
a[a > 10] # [15 28 42 11]
# Múltiplas condições
a[(a > 10) & (a < 30)] # [15 28 11]
Cópia versus visão
Este é um dos pontos mais importantes do NumPy e fonte de muitos bugs sutis.
Quando você fatia um array NumPy, o resultado é uma visão (view) — não uma cópia. Modificar a visão modifica o array original:
a = np.array([1, 2, 3, 4, 5])
b = a[1:4] # b é uma visão de a, não uma cópia
b[0] = 99
print(a) # [ 1 99 3 4 5] — a foi modificado!
# Para criar uma cópia independente:
b = a[1:4].copy()
b[0] = 99
print(a) # [1 2 3 4 5] — a não foi modificado
Esse comportamento é intencional e eficiente — visões evitam duplicação de dados na memória. Mas você precisa estar ciente dele. A regra prática: se você vai modificar um subarray e não quer afetar o original, use .copy().
Operações vetorizadas
Operações aritméticas entre arrays NumPy são aplicadas elemento a elemento:
a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])
a + b # [11 22 33 44]
a * b # [10 40 90 160]
a ** 2 # [ 1 4 9 16]
b / a # [10. 10. 10. 10.]
np.sqrt(a) # [1. 1.41 1.73 2. ]
Funções matemáticas universais (ufuncs) operam elemento a elemento sobre arrays inteiros:
a = np.array([0, np.pi/6, np.pi/4, np.pi/2, np.pi])
np.sin(a) # seno de cada elemento
np.cos(a) # cosseno de cada elemento
np.exp(a) # e^x de cada elemento
np.log(a) # logaritmo natural (cuidado com 0 — retorna -inf)
np.log10(a) # logaritmo base 10
np.abs(a) # valor absoluto
np.floor(a) # arredondamento para baixo
np.ceil(a) # arredondamento para cima
np.round(a, 2) # arredondamento com casas decimais
Funções de agregação
a = np.array([3, 1, 4, 1, 5, 9, 2, 6, 5, 3])
np.sum(a) # 39
np.mean(a) # 3.9
np.median(a) # 3.5
np.std(a) # 2.31... — desvio padrão
np.var(a) # 5.29... — variância
np.min(a) # 1
np.max(a) # 9
np.argmin(a) # 1 — índice do mínimo
np.argmax(a) # 5 — índice do máximo
np.cumsum(a) # soma acumulada
np.sort(a) # cópia ordenada
# Em matrizes, especifique o eixo
m = np.array([[1, 2, 3],
[4, 5, 6]])
np.sum(m, axis=0) # [5 7 9] — soma por coluna
np.sum(m, axis=1) # [ 6 15] — soma por linha
np.mean(m, axis=0) # [2.5 3.5 4.5] — média por coluna
O parâmetro axis é a fonte de muita confusão. Uma forma de lembrar: axis=0 opera ao longo das linhas (colapsa as linhas, resultado tem as dimensões das colunas). axis=1 opera ao longo das colunas (colapsa as colunas, resultado tem as dimensões das linhas).
Broadcasting
Broadcasting é a regra que define como NumPy opera sobre arrays de formas diferentes. É um dos conceitos mais poderosos e, inicialmente, mais confusos do NumPy.
A ideia básica é que arrays de formas diferentes podem ser combinados se suas dimensões forem compatíveis. NumPy "expande" virtualmente o array menor para combinar com o maior, sem duplicar dados na memória.
# Escalar × array: o escalar é "expandido" para o tamanho do array
a = np.array([1, 2, 3, 4])
a * 10 # [10 20 30 40]
# Array 1D adicionado a cada linha de uma matriz 2D
m = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
v = np.array([10, 20, 30])
m + v
# [[11 22 33]
# [14 25 36]
# [17 28 39]]
# v foi "expandido" para 3 linhas, adicionado a cada linha de m
As regras de broadcasting são:
Regra 1: se dois arrays têm número de dimensões diferentes, o shape do array com menos dimensões é preenchido com 1 à esquerda.
Regra 2: arrays com tamanho 1 numa dimensão são expandidos para combinar com o tamanho do outro array nessa dimensão.
Regra 3: se os tamanhos são incompatíveis em qualquer dimensão (diferentes e nenhum é 1), ocorre um erro.
# Exemplo prático: normalizar cada coluna de uma matriz
m = np.array([[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0],
[7.0, 8.0, 9.0]])
medias = m.mean(axis=0) # [4. 5. 6.] — média de cada coluna
stds = m.std(axis=0) # [2.45 2.45 2.45]
m_normalizado = (m - medias) / stds
# Broadcasting: medias tem shape (3,), m tem shape (3,3)
# medias é expandido para (3,3) antes da subtração
Reshape e manipulação de forma
a = np.arange(12) # [ 0 1 2 3 4 5 6 7 8 9 10 11]
# Reshape: mudar a forma sem alterar os dados
a.reshape(3, 4) # matriz 3x4
a.reshape(2, 6) # matriz 2x6
a.reshape(2, 2, 3) # tensor 3D 2x2x3
a.reshape(-1, 3) # -1 significa "calcule esta dimensão automaticamente" → (4, 3)
# Transpor (trocar linhas por colunas)
m = np.array([[1, 2, 3], [4, 5, 6]])
m.T # [[1 4] [2 5] [3 6]] — shape (2,3) vira (3,2)
# Achatar para 1D
m.flatten() # cria uma cópia
m.ravel() # retorna uma visão quando possível (mais eficiente)
# Adicionar uma dimensão
a = np.array([1, 2, 3])
a[:, np.newaxis] # shape (3,) → (3,1) — coluna
a[np.newaxis, :] # shape (3,) → (1,3) — linha
O -1 no reshape é muito útil: você especifica as dimensões que conhece e deixa o NumPy calcular a que falta. a.reshape(-1, 3) significa "organize em colunas de 3, calcule quantas linhas são necessárias".
NumPy e pandas: quando usar cada um
NumPy e pandas se complementam. Entender quando usar cada um evita código desnecessariamente complicado.
Use NumPy quando:
- Você está fazendo computação numérica pura — álgebra linear, estatística, simulação.
- Os dados são homogêneos (todos do mesmo tipo).
- Performance é crítica e você precisa de operações de baixo nível.
- Está trabalhando com arrays multidimensionais (tensores, imagens, sinais).
Use pandas quando:
- Os dados são tabulares com colunas de tipos diferentes.
- Você precisa de rótulos nas linhas e colunas.
- Está fazendo operações de banco de dados: filtragem, agrupamento, junção.
- Está trabalhando com séries temporais com índice de data.
- Precisa ler ou escrever dados em arquivos.
Na prática, os dois trabalham juntos o tempo todo. pandas usa arrays NumPy internamente para armazenar os dados de cada coluna. Você pode acessar os dados de um DataFrame como um array NumPy com .values ou .to_numpy():
# Extrair valores de uma coluna como array NumPy
array_valores = df['valor'].to_numpy()
# Extrair o DataFrame inteiro como matriz NumPy
matriz = df[['col1', 'col2', 'col3']].to_numpy()
Valores especiais: NaN e inf
NumPy tem representações para valores especiais que aparecem em dados reais:
np.nan # Not a Number — representa valor ausente em arrays float
np.inf # infinito positivo
-np.inf # infinito negativo
# Operações com NaN propagam NaN
np.nan + 5 # nan
np.nan > 0 # False (cuidado: comparações com NaN sempre retornam False)
np.nan == np.nan # False (!) — use np.isnan() para verificar
# Verificar NaN
np.isnan(np.nan) # True
np.isnan(5.0) # False
# Funções que ignoram NaN
a = np.array([1.0, 2.0, np.nan, 4.0, 5.0])
np.nanmean(a) # 3.0 — ignora NaN
np.nansum(a) # 12.0
np.nanmax(a) # 5.0
np.nanstd(a) # desvio padrão ignorando NaN
O fato de np.nan == np.nan retornar False é contraintuitivo mas correto pela especificação IEEE 754 para ponto flutuante. Nunca compare com NaN usando == — use np.isnan() ou pd.isna().
Exemplo aplicado: cálculo de estatísticas sem pandas
Para consolidar os conceitos, veja um exemplo completo de análise usando NumPy puro:
import numpy as np
# Simular dados de vendas diárias de 52 semanas
np.random.seed(42)
vendas_diarias = np.random.normal(loc=5000, scale=1200, size=365)
vendas_diarias = np.abs(vendas_diarias) # não há vendas negativas
# Reorganizar em semanas (52 semanas × 7 dias, ignorar os 1-2 dias restantes)
vendas_semanais = vendas_diarias[:364].reshape(52, 7)
# Total por semana
total_semana = vendas_semanais.sum(axis=1)
# Semana com maior e menor receita
semana_melhor = np.argmax(total_semana) + 1 # +1 para índice 1-based
semana_pior = np.argmin(total_semana) + 1
# Estatísticas das vendas diárias
media = np.mean(vendas_diarias)
mediana = np.median(vendas_diarias)
desvio = np.std(vendas_diarias)
percentil_90 = np.percentile(vendas_diarias, 90)
print(f"Média diária: R$ {media:,.2f}")
print(f"Mediana diária: R$ {mediana:,.2f}")
print(f"Desvio padrão: R$ {desvio:,.2f}")
print(f"Percentil 90: R$ {percentil_90:,.2f}")
print(f"Melhor semana: semana {semana_melhor} (R$ {total_semana[semana_melhor-1]:,.2f})")
print(f"Pior semana: semana {semana_pior} (R$ {total_semana[semana_pior-1]:,.2f})")
# Dias acima de R$ 6000 (top performers)
dias_acima = np.sum(vendas_diarias > 6000)
print(f"Dias com vendas acima de R$ 6.000: {dias_acima} ({dias_acima/365*100:.1f}%)")
Resumo
NumPy é a base numérica do Python científico. Seus arrays são homogêneos, multidimensionais e otimizados para performance via operações vetorizadas em código C. Loops Python explícitos sobre dados numéricos devem ser substituídos por operações vetorizadas sempre que possível — a diferença de performance é de uma a duas ordens de magnitude. Broadcasting permite operar sobre arrays de formas diferentes com regras bem definidas. Fatiamentos retornam visões, não cópias — use .copy() quando precisar de independência. np.nan propaga em operações e nunca é igual a si mesmo — use np.isnan() para verificar. pandas usa NumPy internamente e os dois trabalham bem juntos.
Exercícios
-
Por que arrays NumPy são mais rápidos que listas Python para operações numéricas? A resposta deve mencionar pelo menos dois fatores.
✓ Resposta:Fator 1: homogeneidade de tipo. Listas Python podem conter qualquer tipo de objeto — cada elemento é um ponteiro para um objeto Python que carrega metadados de tipo, referência e valor. Para somar uma lista de inteiros, Python precisa desembrulhar cada objeto, verificar seu tipo e extrair o valor. Arrays NumPy armazenam os valores diretamente em memória contígua, todos do mesmo tipo e tamanho. Sem desembrulhamento, sem verificação de tipo.
Fator 2: execução vetorizada em código compilado. Operações NumPy chamam funções implementadas em C (e às vezes Fortran) que processam blocos inteiros de memória usando instruções de baixo nível do processador, incluindo instruções SIMD (Single Instruction Multiple Data) que operam sobre vários elementos simultaneamente. Python puro executa um bytecode interpretado, com overhead de interpretação por operação.
Um terceiro fator é a localidade de cache: dados contíguos em memória são carregados no cache do processador de forma eficiente. Listas Python armazenam ponteiros que apontam para objetos espalhados pelo heap, causando muitos cache misses.
-
O que acontece no código abaixo? Há um bug? Se sim, corrija.
import numpy as np a = np.array([10, 20, 30, 40, 50]) b = a[1:4] b[:] = 0 print(a)✓ Resposta:Sim, há um comportamento que pode ser um bug dependendo da intenção.
b = a[1:4]não cria uma cópia — cria uma visão do array original. Quandob[:] = 0zera todos os elementos deb, está modificando diretamente os elementos deanas posições 1, 2 e 3.O output é
[10 0 0 0 50], não[10 20 30 40 50].Se a intenção era zerar
bsem modificara, a correção é criar uma cópia explícita:b = a[1:4].copy() b[:] = 0 print(a) # [10 20 30 40 50] — a não foi modificado print(b) # [0 0 0] -
Explique o que é broadcasting com suas próprias palavras e descreva o resultado da operação abaixo sem executá-la:
m = np.array([[1, 2, 3], [4, 5, 6]]) v = np.array([10, 20, 30]) resultado = m + v✓ Resposta:Broadcasting é o mecanismo pelo qual NumPy opera sobre arrays de formas diferentes, expandindo virtualmente o array de menor dimensão para combinar com o maior. A expansão é virtual — não há duplicação real de dados na memória.
No exemplo,
mtem shape(2, 3)evtem shape(3,). Pela regra de broadcasting,vé tratado como se tivesse shape(1, 3)e depois expandido para(2, 3)— ou seja, a mesma linha é somada a cada linha dem.O resultado é:
[[11 22 33] [14 25 36]]Linha 0:
[1+10, 2+20, 3+30]=[11, 22, 33]Linha 1:[4+10, 5+20, 6+30]=[14, 25, 36] -
Dado o array abaixo, escreva o código NumPy para: (a) calcular a média ignorando os NaN, (b) substituir todos os NaN pela mediana dos valores não ausentes, (c) contar quantos valores estão acima de 7.
a = np.array([3.0, 7.0, np.nan, 5.0, 9.0, np.nan, 2.0, 8.0, np.nan, 6.0])✓ Resposta:import numpy as np a = np.array([3.0, 7.0, np.nan, 5.0, 9.0, np.nan, 2.0, 8.0, np.nan, 6.0]) # (a) Média ignorando NaN media = np.nanmean(a) print(f"Média (sem NaN): {media}") # 5.714... # (b) Substituir NaN pela mediana dos valores presentes mediana = np.nanmedian(a) a_preenchido = np.where(np.isnan(a), mediana, a) print(f"Array com NaN substituídos: {a_preenchido}") # (c) Contar valores acima de 7 # Nota: comparações com NaN retornam False, então NaN não é contado acima_de_7 = np.sum(a > 7) print(f"Valores acima de 7: {acima_de_7}") # 2 (os valores 9.0 e 8.0)O
np.where(condição, valor_se_verdadeiro, valor_se_falso)é a forma vetorizada de um if-else elemento a elemento.np.isnan(a)retorna um array booleano True onde há NaN, enp.wheresubstitui esses elementos pela mediana, mantendo os demais. -
Você tem uma matriz de notas de 30 alunos em 5 provas, representada como um array NumPy de shape
(30, 5). Escreva o código para: (a) calcular a média de cada aluno, (b) calcular a média de cada prova, (c) identificar os índices dos 3 alunos com maior média geral, (d) contar quantos alunos têm média geral acima de 7.✓ Resposta:import numpy as np # Simular os dados (em uma situação real, viria de um arquivo) np.random.seed(0) notas = np.random.uniform(4, 10, size=(30, 5)).round(1) # (a) Média de cada aluno (média ao longo das colunas — axis=1) media_alunos = notas.mean(axis=1) print("Médias dos alunos:") print(media_alunos.round(2)) # (b) Média de cada prova (média ao longo das linhas — axis=0) media_provas = notas.mean(axis=0) print("\nMédias das provas:") for i, m in enumerate(media_provas, 1): print(f" Prova {i}: {m:.2f}") # (c) Índices dos 3 alunos com maior média geral # argsort retorna os índices que ordenariam o array # [-3:] pega os 3 últimos (maiores) top3_indices = np.argsort(media_alunos)[-3:] print(f"\nTop 3 alunos (índices): {top3_indices}") print(f"Suas médias: {media_alunos[top3_indices].round(2)}") # (d) Quantos alunos têm média acima de 7 aprovados = np.sum(media_alunos > 7) print(f"\nAlunos com média acima de 7: {aprovados} ({aprovados/30*100:.1f}%)")Nota sobre o item (c):
np.argsort()retorna os índices que ordenariam o array em ordem crescente. Pegando os últimos 3 com[-3:]você obtém os índices dos 3 maiores valores. Para ter os índices do maior para o menor, inverta:np.argsort(media_alunos)[-3:][::-1].
Referências
- Harris, Charles R. et al. "Array programming with NumPy". Nature, 2020. O artigo de referência do NumPy, descrevendo sua arquitetura e impacto. Disponível em nature.com/articles/s41586-020-2649-2
- VanderPlas, Jake. Python Data Science Handbook. O'Reilly, 2016. Capítulo 2 cobre NumPy com profundidade, incluindo broadcasting com visualizações. Disponível em jakevdp.github.io/PythonDataScienceHandbook
- Documentação oficial do NumPy: numpy.org/doc Inclui o guia do usuário, referência de API e tutoriais.
- Documentação do NumPy — Broadcasting: numpy.org/doc/stable/user/basics.broadcasting.html Explicação visual das regras de broadcasting com diagramas.
- Oliphant, Travis. A Guide to NumPy. Trelgol Publishing, 2006. O livro original do criador do NumPy, disponível gratuitamente em numpy.org/doc/stable/numpy-ref.pdf
- McKinney, Wes. Python for Data Analysis, 3ª edição. O'Reilly, 2022. Capítulo 4 cobre NumPy no contexto de análise de dados.