Objetivo

Dominar o NumPy, a biblioteca fundamental de computação numérica em Python. Entender arrays multidimensionais, operações vetorizadas, broadcasting e como o NumPy é a base de todas as bibliotecas de ML e IA.


1. Por que NumPy é Fundamental em IA?

Tudo em Machine Learning e Deep Learning é matemática com números. Matrizes de pixels de imagens, embeddings de palavras, pesos de redes neurais, datasets inteiros — tudo é representado como arrays numéricos.

NumPy é a biblioteca que torna essas operações:

  • Rápidas: operações vetorizadas em C, muito mais rápidas que loops Python
  • Convenientes: sintaxe concisa para operações complexas de álgebra linear
  • Universais: PyTorch, TensorFlow, Pandas, Scikit-learn são todos construídos sobre NumPy

Um tensor do PyTorch é conceitualmente um array NumPy com suporte a GPU e gradientes automáticos. Entender NumPy é entender a base de toda IA moderna.


2. Importando NumPy

A convenção universal é importar NumPy com o alias np:

import numpy as np

print(np.__version__)

3. Criando Arrays

3.1 A partir de listas Python

# Array 1D (vetor)
vetor = np.array([1, 2, 3, 4, 5])
print(vetor)
print(type(vetor))   # <class 'numpy.ndarray'>
print(vetor.dtype)   # int64
print(vetor.shape)   # (5,)
print(vetor.ndim)    # 1

# Array 2D (matriz)
matriz = np.array([
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
])
print(matriz.shape)  # (3, 3) — 3 linhas, 3 colunas
print(matriz.ndim)   # 2

# Array 3D (tensor)
tensor = np.array([
    [[1, 2], [3, 4]],
    [[5, 6], [7, 8]]
])
print(tensor.shape)  # (2, 2, 2)
print(tensor.ndim)   # 3

3.2 Tipos de dados (dtype)

# Inteiros
inteiros = np.array([1, 2, 3], dtype=np.int32)
print(inteiros.dtype)   # int32

# Ponto flutuante — padrão em ML
floats = np.array([1.0, 2.5, 3.14], dtype=np.float64)
print(floats.dtype)     # float64

# Float32 — mais comum em deep learning (usa menos memória)
floats32 = np.array([1.0, 2.5, 3.14], dtype=np.float32)
print(floats32.dtype)   # float32

# Booleanos
booleanos = np.array([True, False, True])
print(booleanos.dtype)  # bool

# Conversão de tipo
x = np.array([1, 2, 3])
y = x.astype(np.float32)
print(y)        # [1. 2. 3.]
print(y.dtype)  # float32

Em deep learning, float32 é o padrão porque ocupa metade da memória de float64 com precisão suficiente para treinar redes neurais.

3.3 Funções de criação

# zeros: array preenchido com zeros
print(np.zeros(5))
print(np.zeros((3, 4)))       # matriz 3x4 de zeros

# ones: array preenchido com uns
print(np.ones(5))
print(np.ones((2, 3, 4)))     # tensor 3D de uns

# full: array preenchido com valor específico
print(np.full((3, 3), 7))     # matriz 3x3 de setes

# eye: matriz identidade
print(np.eye(4))               # identidade 4x4

# arange: sequência de números (como range, mas retorna array)
print(np.arange(10))           # [0 1 2 3 4 5 6 7 8 9]
print(np.arange(1, 10, 2))    # [1 3 5 7 9]
print(np.arange(0, 1, 0.1))   # [0.  0.1 0.2 ... 0.9]

# linspace: N pontos igualmente espaçados entre a e b
print(np.linspace(0, 1, 5))   # [0.   0.25 0.5  0.75 1.  ]
print(np.linspace(0, 10, 11)) # [0. 1. 2. ... 10.]

3.4 Arrays aleatórios

np.random.seed(42)  # sempre defina seed para reprodutibilidade

# Uniformes entre 0 e 1
print(np.random.random((3, 3)))

# Normais (média 0, desvio 1) — inicialização de pesos em redes neurais
print(np.random.randn(3, 3))

# Inteiros aleatórios
print(np.random.randint(0, 10, size=(3, 4)))

# Normal com média e desvio específicos
pesos = np.random.normal(loc=0.0, scale=0.01, size=(128, 64))
print(f"Pesos inicializados: shape={pesos.shape}, média={pesos.mean():.6f}")

# Embaralhar array
dados = np.arange(10)
np.random.shuffle(dados)
print(dados)

4. Atributos Essenciais de Arrays

arr = np.array([[1.0, 2.0, 3.0],
                [4.0, 5.0, 6.0]])

print(arr.shape)    # (2, 3) — dimensões
print(arr.ndim)     # 2 — número de dimensões
print(arr.size)     # 6 — total de elementos
print(arr.dtype)    # float64 — tipo dos dados
print(arr.nbytes)   # 48 — bytes na memória (6 * 8 bytes)

5. Indexação e Fatiamento

5.1 Arrays 1D

arr = np.array([10, 20, 30, 40, 50, 60, 70, 80, 90])

print(arr[0])      # 10
print(arr[-1])     # 90
print(arr[2:6])    # [30 40 50 60]
print(arr[::2])    # [10 30 50 70 90]
print(arr[::-1])   # [90 80 70 ... 10]

5.2 Arrays 2D

m = np.array([
    [1,  2,  3,  4],
    [5,  6,  7,  8],
    [9, 10, 11, 12]
])

# Elemento específico: [linha, coluna]
print(m[0, 0])    # 1
print(m[1, 2])    # 7
print(m[-1, -1])  # 12

# Linha inteira
print(m[1, :])    # [5 6 7 8]
print(m[1])       # equivalente

# Coluna inteira
print(m[:, 2])    # [3 7 11]

# Submatriz
print(m[0:2, 1:3])
# [[2 3]
#  [6 7]]

# Linhas específicas
print(m[[0, 2], :])
# [[ 1  2  3  4]
#  [ 9 10 11 12]]

5.3 Indexação booleana (muito usada em ML)

arr = np.array([3, -1, 4, -1, 5, -9, 2, 6])

# Máscara booleana
mascara = arr > 0
print(mascara)  # [ True False  True False  True False  True  True]

# Filtrar elementos positivos
print(arr[mascara])    # [3 4 5 2 6]
print(arr[arr > 0])    # equivalente, mais conciso

# Modificar elementos com condição
arr[arr < 0] = 0
print(arr)  # [3 0 4 0 5 0 2 6]

Exemplo em ML — filtrar exemplos de uma classe específica:

features = np.array([[1.2, 3.4], [2.1, 1.5], [0.8, 4.2], [3.3, 2.8]])
labels   = np.array([0, 1, 0, 1])

# Selecionar apenas exemplos da classe 1
classe_1 = features[labels == 1]
print(classe_1)
# [[2.1 1.5]
#  [3.3 2.8]]

6. Operações Matemáticas

6.1 Operações elemento a elemento

a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])

print(a + b)    # [11 22 33 44]
print(a - b)    # [-9 -18 -27 -36]
print(a * b)    # [10 40 90 160]
print(a / b)    # [0.1 0.1 0.1 0.1]
print(a ** 2)   # [1 4 9 16]
print(np.sqrt(a))  # [1. 1.41 1.73 2.]

6.2 Operações com escalares

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

print(arr + 10)    # [11 12 13 14 15]
print(arr * 2)     # [2 4 6 8 10]
print(arr / 5)     # [0.2 0.4 0.6 0.8 1.0]
print(arr > 3)     # [False False False  True  True]

6.3 Funções universais (ufuncs)

Funções que operam elemento a elemento de forma vetorizada:

arr = np.array([-2, -1, 0, 1, 2])

print(np.abs(arr))     # [2 1 0 1 2]
print(np.exp(arr))     # [0.135 0.368 1. 2.718 7.389]
print(np.log(np.abs(arr) + 1))  # log de valores positivos

# Funções trigonométricas
angulos = np.linspace(0, np.pi, 5)
print(np.sin(angulos))  # [0. 0.707 1. 0.707 0.]
print(np.cos(angulos))  # [1. 0.707 0. -0.707 -1.]

# Funções de ativação implementadas com NumPy
def relu(x):
    return np.maximum(0, x)

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

def softmax(x):
    e_x = np.exp(x - np.max(x))  # subtrai max para estabilidade numérica
    return e_x / e_x.sum()

entradas = np.array([-2.0, -1.0, 0.0, 1.0, 2.0])
print("ReLU:   ", relu(entradas))
print("Sigmoid:", np.round(sigmoid(entradas), 4))
print("Softmax:", np.round(softmax(entradas), 4))

Saída:

ReLU:    [0. 0. 0. 1. 2.]
Sigmoid: [0.1192 0.2689 0.5    0.7311 0.8808]
Softmax: [0.0117 0.0317 0.0861 0.2341 0.6364]

7. Operações de Agregação

arr = np.array([[1, 2, 3],
                [4, 5, 6],
                [7, 8, 9]])

# Sem eixo — opera sobre todos os elementos
print(np.sum(arr))    # 45
print(np.mean(arr))   # 5.0
print(np.min(arr))    # 1
print(np.max(arr))    # 9
print(np.std(arr))    # 2.581...

# Com eixo — opera ao longo de um eixo
# axis=0: opera ao longo das linhas (resultado por coluna)
print(np.sum(arr, axis=0))   # [12 15 18]
print(np.mean(arr, axis=0))  # [4. 5. 6.]

# axis=1: opera ao longo das colunas (resultado por linha)
print(np.sum(arr, axis=1))   # [ 6 15 24]
print(np.mean(arr, axis=1))  # [2. 5. 8.]

# argmin e argmax: índice do mínimo e máximo
print(np.argmin(arr))        # 0 (índice do menor elemento)
print(np.argmax(arr))        # 8 (índice do maior elemento)
print(np.argmax(arr, axis=1)) # [2 2 2] — índice do max em cada linha

8. Reshape e Manipulação de Forma

8.1 reshape

arr = np.arange(12)
print(arr)        # [ 0  1  2  3  4  5  6  7  8  9 10 11]
print(arr.shape)  # (12,)

# Reshape para 3x4
m34 = arr.reshape(3, 4)
print(m34)
print(m34.shape)  # (3, 4)

# Reshape para 2x2x3
t223 = arr.reshape(2, 2, 3)
print(t223.shape)  # (2, 2, 3)

# -1 infere a dimensão automaticamente
m26 = arr.reshape(2, -1)   # (2, 6)
m43 = arr.reshape(-1, 3)   # (4, 3)
print(m26.shape, m43.shape)

8.2 flatten e ravel

m = np.array([[1, 2, 3], [4, 5, 6]])

# flatten: retorna cópia 1D
flat = m.flatten()
print(flat)         # [1 2 3 4 5 6]

# ravel: retorna view 1D (mais eficiente, sem cópia)
rav = m.ravel()
print(rav)          # [1 2 3 4 5 6]

8.3 transpose

m = np.array([[1, 2, 3],
              [4, 5, 6]])
print(m.shape)      # (2, 3)

mt = m.T            # transposta
print(mt.shape)     # (3, 2)
print(mt)
# [[1 4]
#  [2 5]
#  [3 6]]

8.4 expand_dims e squeeze

Muito usados para ajustar dimensões em deep learning:

arr = np.array([1, 2, 3, 4, 5])
print(arr.shape)  # (5,)

# Adicionar dimensão — transformar em linha ou coluna
linha  = np.expand_dims(arr, axis=0)
coluna = np.expand_dims(arr, axis=1)
print(linha.shape)   # (1, 5)
print(coluna.shape)  # (5, 1)

# Ou com None no índice (equivalente)
print(arr[np.newaxis, :].shape)  # (1, 5)
print(arr[:, np.newaxis].shape)  # (5, 1)

# squeeze: remover dimensões de tamanho 1
batch = np.zeros((1, 28, 28))    # uma imagem de 28x28
imagem = np.squeeze(batch)
print(imagem.shape)  # (28, 28)

9. Broadcasting

Broadcasting é uma das funcionalidades mais poderosas e importantes do NumPy. Permite operar entre arrays de formas diferentes, expandindo automaticamente as dimensões menores.

9.1 Regras de broadcasting

Duas dimensões são compatíveis se: 1. São iguais, ou 2. Uma delas é 1

O NumPy compara as formas da direita para a esquerda.

# Escalar com array — o caso mais simples
arr = np.array([1, 2, 3, 4, 5])
print(arr + 10)   # [11 12 13 14 15]
# O escalar 10 é "expandido" para [10, 10, 10, 10, 10]

# Vetor com matriz
m = np.array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9]])

v = np.array([10, 20, 30])  # shape (3,)
print(m + v)
# [[11 22 33]
#  [14 25 36]
#  [17 28 39]]
# v é somado a cada linha da matriz

9.2 Normalização com broadcasting

dados = np.array([
    [1.0, 200.0, 0.5],
    [2.0, 150.0, 0.8],
    [3.0, 300.0, 0.2],
    [1.5, 250.0, 0.6]
])

# Calcular média e desvio padrão por coluna (feature)
media   = dados.mean(axis=0)   # shape (3,)
desvio  = dados.std(axis=0)    # shape (3,)

print("Médias:", media)
print("Desvios:", desvio)

# Normalização Z-score — broadcasting aplica automaticamente
dados_norm = (dados - media) / desvio
print("\nDados normalizados:")
print(dados_norm)
print(f"\nMédia após normalização: {dados_norm.mean(axis=0)}")
print(f"Desvio após normalização: {dados_norm.std(axis=0)}")

Saída:

Médias: [  1.875 225.     0.525]
Desvios: [  0.726  55.9    0.216]

Dados normalizados:
[[-1.033 -0.447 -0.116]
 [ 0.172 -1.342  1.273]
 [ 1.378  1.342 -1.505]
 [-0.516  0.447  0.348]]

Média após normalização: [0. 0. 0.]
Desvio após normalização: [1. 1. 1.]

9.3 Visualizando broadcasting

# Exemplo clássico: tabela de distâncias
pontos_a = np.array([[0], [1], [2], [3]])    # shape (4, 1)
pontos_b = np.array([[0, 1, 2, 3, 4]])       # shape (1, 5)

distancias = np.abs(pontos_a - pontos_b)     # shape (4, 5) — broadcasting!
print(distancias)
# [[0 1 2 3 4]
#  [1 0 1 2 3]
#  [2 1 0 1 2]
#  [3 2 1 0 1]]

10. Álgebra Linear com NumPy

Essencial para entender redes neurais, onde tudo é multiplicação de matrizes.

a = np.array([[1, 2], [3, 4]])
b = np.array([[5, 6], [7, 8]])

# Multiplicação elemento a elemento (NÃO é produto de matrizes)
print(a * b)
# [[ 5 12]
#  [21 32]]

# Produto de matrizes (matrix multiplication)
print(np.dot(a, b))
print(a @ b)          # equivalente, mais moderno
# [[19 22]
#  [43 50]]

# Vetor * matriz
v = np.array([1, 2])
print(a @ v)          # [5 11]

# Determinante
print(np.linalg.det(a))    # -2.0

# Inversa
print(np.linalg.inv(a))
# [[-2.   1. ]
#  [ 1.5 -0.5]]

# Autovalores e autovetores
vals, vecs = np.linalg.eig(a)
print("Autovalores:", vals)
print("Autovetores:\n", vecs)

# Norma de um vetor
v = np.array([3.0, 4.0])
print(np.linalg.norm(v))   # 5.0 — norma L2

11. Concatenação e Empilhamento

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

b = np.array([[7,  8,  9],
              [10, 11, 12]])

# concatenate ao longo das linhas (axis=0 — empilha verticalmente)
print(np.concatenate([a, b], axis=0))
# [[ 1  2  3]
#  [ 4  5  6]
#  [ 7  8  9]
#  [10 11 12]]

# concatenate ao longo das colunas (axis=1 — empilha horizontalmente)
print(np.concatenate([a, b], axis=1))
# [[ 1  2  3  7  8  9]
#  [ 4  5  6 10 11 12]]

# vstack: vertical stack (alias para axis=0)
print(np.vstack([a, b]))

# hstack: horizontal stack (alias para axis=1)
print(np.hstack([a, b]))

# stack: cria nova dimensão
c = np.stack([a, b])         # shape (2, 2, 3)
print(c.shape)

12. Exemplo Completo: Implementando uma Camada Linear do Zero

Vamos implementar uma camada densa (fully connected) de rede neural usando apenas NumPy — exatamente o que o PyTorch faz por baixo dos panos.

import numpy as np

np.random.seed(42)

class CamadaLinear:
    """
    Camada linear (fully connected): y = X @ W + b
    Implementação manual usando NumPy para fins didáticos.
    """

    def __init__(self, n_entradas: int, n_saidas: int):
        # Inicialização de Xavier/Glorot — padrão em redes neurais
        escala = np.sqrt(2.0 / n_entradas)
        self.W = np.random.randn(n_entradas, n_saidas) * escala
        self.b = np.zeros(n_saidas)

        print(f"Camada linear criada: {n_entradas} → {n_saidas}")
        print(f"  Pesos W: shape={self.W.shape}, média={self.W.mean():.4f}")
        print(f"  Bias  b: shape={self.b.shape}")

    def forward(self, X: np.ndarray) -> np.ndarray:
        """Passagem forward: y = X @ W + b"""
        return X @ self.W + self.b

    def contar_parametros(self) -> int:
        return self.W.size + self.b.size

def relu(x: np.ndarray) -> np.ndarray:
    return np.maximum(0, x)

def softmax(x: np.ndarray) -> np.ndarray:
    e_x = np.exp(x - x.max(axis=1, keepdims=True))
    return e_x / e_x.sum(axis=1, keepdims=True)

# Simulando uma rede neural de 2 camadas
# Input: batch de 4 exemplos, cada um com 3 features
X = np.array([
    [0.5, 1.2, -0.8],
    [1.1, -0.3,  0.9],
    [-0.2, 0.7,  1.5],
    [0.8,  0.4, -0.1]
], dtype=np.float32)

print(f"Input: shape={X.shape}\n")

# Camada 1: 3 entradas → 4 neurônios
camada1 = CamadaLinear(3, 4)
print()

# Camada 2: 4 entradas → 2 saídas (classificação binária)
camada2 = CamadaLinear(4, 2)
print()

# Forward pass
saida1 = relu(camada1.forward(X))     # aplicando ReLU após camada 1
saida2 = softmax(camada2.forward(saida1))  # softmax na camada final

print("Forward pass:")
print(f"  X:      shape={X.shape}")
print(f"  Saída1: shape={saida1.shape}  (após ReLU)")
print(f"  Saída2: shape={saida2.shape}  (após Softmax)")
print()
print("Probabilidades por classe:")
for i, probs in enumerate(saida2):
    classe = np.argmax(probs)
    print(f"  Exemplo {i}: classe 0 = {probs[0]:.4f}, "
          f"classe 1 = {probs[1]:.4f}  → predição: classe {classe}")

total_params = camada1.contar_parametros() + camada2.contar_parametros()
print(f"\nTotal de parâmetros: {total_params}")

Saída:

Input: shape=(4, 3)

Camada linear criada: 3 → 4
  Pesos W: shape=(3, 4), média=0.0312
  Bias  b: shape=(4,)

Camada linear criada: 4 → 2
  Pesos W: shape=(4, 2), média=-0.0521
  Bias  b: shape=(2,)

Forward pass:
  X:      shape=(4, 3)
  Saída1: shape=(4, 4)  (após ReLU)
  Saída2: shape=(4, 2)  (após Softmax)

Probabilidades por classe:
  Exemplo 0: classe 0 = 0.4823, classe 1 = 0.5177  → predição: classe 1
  Exemplo 1: classe 0 = 0.5341, classe 1 = 0.4659  → predição: classe 0
  Exemplo 2: classe 0 = 0.4912, classe 1 = 0.5088  → predição: classe 1
  Exemplo 3: classe 0 = 0.5103, classe 1 = 0.4897  → predição: classe 0

Total de parâmetros: 22

Resumo da Aula

  • NumPy é a base de toda computação numérica em Python para IA
  • ndarray é o objeto central: array N-dimensional tipado e eficiente
  • Atributos essenciais: shape, dtype, ndim, size
  • Indexação booleana permite filtrar dados por condição
  • Operações são vetorizadas: muito mais rápidas que loops Python
  • axis=0 opera sobre linhas (resultado por coluna), axis=1 opera sobre colunas (resultado por linha)
  • reshape(-1, n) infere dimensões automaticamente
  • Broadcasting permite operar entre arrays de formas compatíveis
  • @ é o operador de multiplicação de matrizes
  • Funções de ativação (ReLU, sigmoid, softmax) são facilmente implementadas com NumPy
  • Normalização Z-score com broadcasting é o pré-processamento mais comum em ML

Exercícios

  1. Crie um array NumPy com os números de 1 a 20. Em seguida, sem usar loops, extraia: todos os números pares, todos os múltiplos de 3, e os números que são ao mesmo tempo maiores que 5 e menores que 15.

    ✓ Resposta:
    arr = np.arange(1, 21)
    print("Array original:", arr)
    
    pares = arr[arr % 2 == 0]
    print("Pares:", pares)
    # [ 2  4  6  8 10 12 14 16 18 20]
    
    multiplos_3 = arr[arr % 3 == 0]
    print("Múltiplos de 3:", multiplos_3)
    # [ 3  6  9 12 15 18]
    
    intervalo = arr[(arr > 5) & (arr < 15)]
    print("Entre 5 e 15:", intervalo)
    # [ 6  7  8  9 10 11 12 13 14]
    

    Note o uso de & em vez de and para operações booleanas elemento a elemento em NumPy. O and Python não funciona com arrays.

  2. Dada a matriz abaixo, calcule sem loops: a soma de cada linha, a média de cada coluna, o elemento máximo de toda a matriz e em qual posição (linha, coluna) ele se encontra.

    m = np.array([
        [4,  7,  2,  1],
        [3,  8,  5,  9],
        [6,  1,  3,  2],
        [8,  4,  7,  5]
    ])
    

    ✓ Resposta:
    m = np.array([
        [4,  7,  2,  1],
        [3,  8,  5,  9],
        [6,  1,  3,  2],
        [8,  4,  7,  5]
    ])
    
    soma_linhas = m.sum(axis=1)
    print("Soma por linha:", soma_linhas)
    # [14 25 12 24]
    
    media_colunas = m.mean(axis=0)
    print("Média por coluna:", media_colunas)
    # [5.25 5.   4.25 4.25]
    
    maximo = m.max()
    print("Máximo:", maximo)
    # 9
    
    posicao = np.unravel_index(m.argmax(), m.shape)
    print(f"Posição do máximo: linha={posicao[0]}, coluna={posicao[1]}")
    # linha=1, coluna=3
    

    np.unravel_index converte o índice linear retornado por argmax() para coordenadas (linha, coluna) na matriz.

  3. Explique o que é broadcasting em NumPy com suas próprias palavras. Por que a operação abaixo funciona sem erro, e qual é o resultado?

    a = np.array([[1, 2, 3],
                  [4, 5, 6]])       # shape (2, 3)
    
    b = np.array([10, 20, 30])     # shape (3,)
    
    print(a + b)
    

    ✓ Resposta:

    Broadcasting é um mecanismo que permite ao NumPy executar operações entre arrays de formas diferentes, expandindo automaticamente as dimensões menores para que as formas sejam compatíveis.

    A operação a + b funciona porque o NumPy compara as formas da direita para a esquerda: a tem shape (2, 3) e b tem shape (3,). O NumPy "expande" b para shape (2, 3) repetindo-o duas vezes, uma para cada linha de a.

    O resultado é:

    # b = [10, 20, 30] é somado a cada linha de a
    # [[1+10, 2+20, 3+30],   = [[11, 22, 33],
    #  [4+10, 5+20, 6+30]]      [14, 25, 36]]
    

    Nenhuma cópia real de b é criada na memória — o NumPy simula a expansão virtualmente, tornando a operação eficiente.

  4. Implemente a função de normalização min-max usando NumPy e broadcasting. A fórmula é: x_norm = (x - x_min) / (x_max - x_min). Aplique em cada coluna (feature) independentemente para a matriz abaixo:

    dados = np.array([
        [2.0,  100.0, 0.3],
        [5.0,  200.0, 0.7],
        [1.0,   50.0, 0.1],
        [8.0,  400.0, 0.9],
        [3.0,  150.0, 0.5]
    ])
    

    ✓ Resposta:
    dados = np.array([
        [2.0,  100.0, 0.3],
        [5.0,  200.0, 0.7],
        [1.0,   50.0, 0.1],
        [8.0,  400.0, 0.9],
        [3.0,  150.0, 0.5]
    ])
    
    # Mínimo e máximo por coluna (feature)
    minimo = dados.min(axis=0)   # shape (3,)
    maximo = dados.max(axis=0)   # shape (3,)
    
    print("Mínimos por coluna:", minimo)
    print("Máximos por coluna:", maximo)
    
    # Normalização — broadcasting aplica automaticamente
    dados_norm = (dados - minimo) / (maximo - minimo)
    
    print("\nDados normalizados:")
    print(np.round(dados_norm, 4))
    print(f"\nMínimo após norm: {dados_norm.min(axis=0)}")
    print(f"Máximo após norm: {dados_norm.max(axis=0)}")
    

    Saída:

    Mínimos por coluna: [  1.  50.   0.1]
    Máximos por coluna: [  8.  400.   0.9]
    
    Dados normalizados:
    [[0.1429 0.1429 0.25  ]
     [0.5714 0.4286 0.75  ]
     [0.     0.     0.    ]
     [1.     1.     1.    ]
     [0.2857 0.2857 0.5   ]]
    
    Mínimo após norm: [0. 0. 0.]
    Máximo após norm: [1. 1. 1.]
    
  5. Explique a diferença entre arr * b e arr @ b (ou np.dot(arr, b)) quando arr e b são matrizes 2D. Por que essa distinção é crítica em redes neurais?

    ✓ Resposta:

    arr * b realiza multiplicação elemento a elemento. Cada elemento de arr é multiplicado pelo elemento correspondente de b na mesma posição. As formas precisam ser iguais (ou compatíveis via broadcasting). O resultado tem a mesma forma das entradas.

    arr @ b (ou np.dot(arr, b)) realiza o produto matricial. Para duas matrizes (m, n) e (n, p), o resultado tem shape (m, p). Cada elemento do resultado é o produto escalar de uma linha de arr com uma coluna de b.

    Essa distinção é crítica em redes neurais porque a transformação linear de uma camada é y = X @ W + b — um produto matricial. Se você usasse * por engano, estaria multiplicando pesos por features de posição correspondente, o que não implementa nenhuma transformação útil. O produto matricial é o que permite que cada neurônio da camada seguinte receba contribuições de todos os neurônios da camada anterior com pesos diferentes.

  6. Dado o array abaixo representando as saídas (logits) de uma rede neural para 4 exemplos com 3 classes, aplique a função softmax e determine a classe predita para cada exemplo. Explique por que subtraímos o máximo antes de calcular o exp na implementação da softmax.

    logits = np.array([
        [2.1, 0.5, 1.8],
        [0.3, 3.2, 0.8],
        [1.5, 1.5, 1.5],
        [4.0, 0.1, 0.2]
    ])
    

    ✓ Resposta:
    logits = np.array([
        [2.1, 0.5, 1.8],
        [0.3, 3.2, 0.8],
        [1.5, 1.5, 1.5],
        [4.0, 0.1, 0.2]
    ])
    
    def softmax(x):
        e_x = np.exp(x - x.max(axis=1, keepdims=True))
        return e_x / e_x.sum(axis=1, keepdims=True)
    
    probs = softmax(logits)
    print("Probabilidades:")
    print(np.round(probs, 4))
    
    classes = np.argmax(probs, axis=1)
    print("\nClasses preditas:", classes)
    

    Saída:

    Probabilidades:
    [[0.5395 0.0674 0.3931]
     [0.0547 0.8909 0.0544]
     [0.3333 0.3333 0.3333]
     [0.9682 0.0162 0.0156]]
    
    Classes preditas: [0 1 0 0]
    

    Subtraímos o máximo antes do exp por estabilidade numérica. Se os logits forem grandes (ex: 1000), np.exp(1000) causa overflow e retorna inf. Subtrair o máximo garante que o maior valor passado para exp seja sempre 0, então exp(0) = 1. Matematicamente, o resultado do softmax é idêntico com ou sem essa subtração, pois o fator cancela no numerador e denominador. Para o terceiro exemplo, os três logits são iguais (1.5), então o softmax distribui a probabilidade igualmente: 1/3 para cada classe.

Referências