Objetivo
Entender como redes neurais funcionam desde o neurônio biológico até redes profundas multicamadas. Implementar uma rede neural do zero com NumPy para entender cada operação, e introduzir os conceitos de forward pass, funções de ativação e arquitetura que serão a base para o PyTorch nas próximas aulas.
1. Da Biologia à Matemática
O neurônio biológico recebe sinais elétricos de outros neurônios através dos dendritos, processa esses sinais no corpo celular e, se o estímulo for suficientemente forte, dispara um sinal pelo axônio para os próximos neurônios.
O neurônio artificial é uma abstração matemática desse processo:
Entradas (x₁, x₂, ..., xₙ)
↓
Soma ponderada: z = w₁x₁ + w₂x₂ + ... + wₙxₙ + b
↓
Função de ativação: a = f(z)
↓
Saída (a)
Onde: - xᵢ são as entradas (features) - wᵢ são os pesos (o que o neurônio aprende) - b é o bias (intercepto) - f é a função de ativação (introduz não linearidade) - a é a saída do neurônio
2. O Perceptron
O perceptron é o neurônio artificial mais simples, proposto por Frank Rosenblatt em 1958.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import make_classification, make_moons
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score
np.random.seed(42)
sns.set_theme(style="whitegrid")
class Perceptron:
"""
Implementação manual do Perceptron de Rosenblatt.
Aprende apenas problemas linearmente separáveis.
"""
def __init__(self, learning_rate: float = 0.01,
n_iteracoes: int = 100):
self.lr = learning_rate
self.n_iter = n_iteracoes
self.pesos = None
self.bias = None
self.erros_ = []
def fit(self, X: np.ndarray,
y: np.ndarray) -> "Perceptron":
n_exemplos, n_features = X.shape
self.pesos = np.zeros(n_features)
self.bias = 0.0
for _ in range(self.n_iter):
erros_epoca = 0
for xi, yi in zip(X, y):
# Predição: step function (degrau)
z = np.dot(xi, self.pesos) + self.bias
y_pred = 1 if z >= 0 else 0
# Atualização: só atualiza quando erra
if y_pred != yi:
delta = self.lr * (yi - y_pred)
self.pesos += delta * xi
self.bias += delta
erros_epoca += 1
self.erros_.append(erros_epoca)
return self
def predict(self, X: np.ndarray) -> np.ndarray:
z = X @ self.pesos + self.bias
return (z >= 0).astype(int)
# Testando o Perceptron em dados linearmente separáveis
X_lin, y_lin = make_classification(
n_samples=200, n_features=2,
n_redundant=0, n_informative=2,
n_clusters_per_class=1,
class_sep=2.0, random_state=42
)
X_tr_l, X_te_l, y_tr_l, y_te_l = train_test_split(
X_lin, y_lin, test_size=0.2, random_state=42
)
perc = Perceptron(learning_rate=0.01, n_iteracoes=50)
perc.fit(X_tr_l, y_tr_l)
acc_perc = accuracy_score(y_te_l, perc.predict(X_te_l))
print(f"Perceptron — Dados linearmente separáveis:")
print(f" Acurácia: {acc_perc:.4f}")
# Testando em XOR — problema NÃO linearmente separável
X_xor = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y_xor = np.array([0, 1, 1, 0]) # XOR
perc_xor = Perceptron(n_iteracoes=100)
perc_xor.fit(X_xor, y_xor)
y_pred_xor = perc_xor.predict(X_xor)
print(f"\nPerceptron — Problema XOR (não linear):")
print(f" Real: {y_xor}")
print(f" Predito:{y_pred_xor}")
print(f" Acurácia: {accuracy_score(y_xor, y_pred_xor):.4f}")
print(f" → Perceptron falha em XOR: precisa de múltiplas camadas!")
Saída:
Perceptron — Dados linearmente separáveis:
Acurácia: 1.0000
Perceptron — Problema XOR (não linear):
Real: [0 1 1 0]
Predito:[1 1 1 1]
Acurácia: 0.5000
→ Perceptron falha em XOR: precisa de múltiplas camadas!
3. Funções de Ativação
As funções de ativação introduzem não linearidade na rede. Sem elas, empilhar camadas lineares resultaria em apenas uma transformação linear — equivalente a uma única camada.
def step(z):
"""Degrau — usada no Perceptron. Não diferenciável."""
return (z >= 0).astype(float)
def sigmoid(z):
"""Sigmoid — saída entre 0 e 1. Usada em classificação binária."""
return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
def tanh(z):
"""Tangente hiperbólica — saída entre -1 e 1. Centrada em zero."""
return np.tanh(z)
def relu(z):
"""ReLU — mais usada em camadas ocultas. Simples e eficaz."""
return np.maximum(0, z)
def leaky_relu(z, alpha=0.01):
"""Leaky ReLU — evita o 'neurônio morto' do ReLU."""
return np.where(z > 0, z, alpha * z)
def elu(z, alpha=1.0):
"""ELU — suave para negativos, evita neurônio morto."""
return np.where(z > 0, z, alpha * (np.exp(z) - 1))
def softmax(z):
"""Softmax — saída como distribuição de probabilidade."""
e_z = np.exp(z - np.max(z))
return e_z / e_z.sum()
# Visualizar todas as funções de ativação
z = np.linspace(-4, 4, 200)
funcoes = {
"Step (Degrau)": step(z),
"Sigmoid": sigmoid(z),
"Tanh": tanh(z),
"ReLU": relu(z),
"Leaky ReLU": leaky_relu(z),
"ELU": elu(z),
}
fig, axes = plt.subplots(2, 3, figsize=(15, 8))
cores = ["gray", "steelblue", "coral",
"mediumseagreen", "mediumpurple", "darkorange"]
for ax, (nome, valores), cor in zip(axes.flat,
funcoes.items(),
cores):
ax.plot(z, valores, color=cor, linewidth=2.5)
ax.axhline(y=0, color="black", linewidth=0.5, alpha=0.5)
ax.axvline(x=0, color="black", linewidth=0.5, alpha=0.5)
ax.set_title(nome, fontsize=12)
ax.set_xlabel("z")
ax.set_ylabel("f(z)")
ax.grid(True, alpha=0.3)
ax.set_xlim(-4, 4)
plt.suptitle("Funções de Ativação em Redes Neurais", fontsize=14)
plt.tight_layout()
plt.show()
# Tabela comparativa
print("\nComparação das funções de ativação:")
print(f"{'Função':<15} {'Range':<15} {'Diferenciável':<15} {'Uso principal'}")
print("-" * 70)
comparacoes = [
("Step", "0 ou 1", "Não", "Perceptron (histórico)"),
("Sigmoid", "(0, 1)", "Sim", "Saída binária"),
("Tanh", "(-1, 1)", "Sim", "LSTM, RNN"),
("ReLU", "[0, +∞)", "Quase","Camadas ocultas (padrão)"),
("Leaky ReLU", "(-∞, +∞)", "Sim", "Quando ReLU falha"),
("ELU", "(-α, +∞)", "Sim", "Alternativa ao ReLU"),
("Softmax", "(0,1) soma=1", "Sim", "Saída multiclasse"),
]
for nome, rng, dif, uso in comparacoes:
print(f"{nome:<15} {rng:<15} {dif:<15} {uso}")
4. Arquitetura de Redes Neurais
Uma rede neural feedforward (MLP — Multi-Layer Perceptron) tem:
- Camada de entrada: recebe as features
- Camadas ocultas: aprendem representações intermediárias
- Camada de saída: produz a predição final
def visualizar_arquitetura():
"""Visualiza a arquitetura de uma rede neural simples."""
fig, ax = plt.subplots(1, 1, figsize=(14, 8))
ax.set_xlim(0, 10)
ax.set_ylim(0, 10)
ax.axis("off")
# Posições das camadas
camadas = {
"Entrada\n(3 neurônios)": (1.5, [3, 5, 7]),
"Oculta 1\n(4 neurônios)": (4.0, [2, 4, 6, 8]),
"Oculta 2\n(3 neurônios)": (6.5, [3, 5, 7]),
"Saída\n(2 neurônios)": (9.0, [4, 6]),
}
cores_camadas = {
"Entrada\n(3 neurônios)": "#B0C4DE",
"Oculta 1\n(4 neurônios)": "#90EE90",
"Oculta 2\n(3 neurônios)": "#90EE90",
"Saída\n(2 neurônios)": "#FFB6C1",
}
posicoes = {}
for nome, (x, ys) in camadas.items():
for y in ys:
circulo = plt.Circle(
(x, y), 0.35,
color=cores_camadas[nome],
ec="gray", linewidth=1.5, zorder=3
)
ax.add_patch(circulo)
posicoes[(nome, y)] = (x, y)
ax.text(x, 0.8, nome, ha="center", va="center",
fontsize=9, fontweight="bold")
# Conexões entre camadas
nomes = list(camadas.keys())
for i in range(len(nomes) - 1):
nome_a, nome_b = nomes[i], nomes[i + 1]
_, ys_a = camadas[nome_a]
_, ys_b = camadas[nome_b]
for ya in ys_a:
for yb in ys_b:
xa, _ = posicoes[(nome_a, ya)]
xb, _ = posicoes[(nome_b, yb)]
ax.plot([xa + 0.35, xb - 0.35],
[ya, yb],
"gray", linewidth=0.4, alpha=0.4, zorder=1)
# Labels
ax.text(5.25, 9.3, "Rede Neural Feedforward (MLP)",
ha="center", fontsize=13, fontweight="bold")
from matplotlib.patches import Patch
legenda = [
Patch(color="#B0C4DE", label="Camada de Entrada"),
Patch(color="#90EE90", label="Camadas Ocultas"),
Patch(color="#FFB6C1", label="Camada de Saída"),
]
ax.legend(handles=legenda, loc="upper right", fontsize=9)
plt.tight_layout()
plt.show()
visualizar_arquitetura()
5. Forward Pass — Propagação para Frente
O forward pass é o cálculo das predições: os dados fluem da entrada até a saída, camada por camada.
class CamadaDensa:
"""
Camada densa (fully connected) com forward pass.
"""
def __init__(self, n_entradas: int, n_saidas: int,
ativacao: str = "relu"):
# Inicialização de He (recomendada para ReLU)
escala = np.sqrt(2.0 / n_entradas)
self.W = np.random.randn(n_entradas, n_saidas) * escala
self.b = np.zeros(n_saidas)
self.ativacao = ativacao
# Cache para backward pass (aula 19)
self.entrada = None
self.z = None
self.saida = None
def _aplicar_ativacao(self, z: np.ndarray) -> np.ndarray:
if self.ativacao == "relu":
return np.maximum(0, z)
elif self.ativacao == "sigmoid":
return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
elif self.ativacao == "tanh":
return np.tanh(z)
elif self.ativacao == "softmax":
e_z = np.exp(z - z.max(axis=1, keepdims=True))
return e_z / e_z.sum(axis=1, keepdims=True)
elif self.ativacao == "linear":
return z
else:
raise ValueError(f"Ativação desconhecida: {self.ativacao}")
def forward(self, X: np.ndarray) -> np.ndarray:
"""Propaga X pela camada."""
self.entrada = X
self.z = X @ self.W + self.b
self.saida = self._aplicar_ativacao(self.z)
return self.saida
@property
def n_parametros(self) -> int:
return self.W.size + self.b.size
def __repr__(self) -> str:
return (f"CamadaDensa({self.W.shape[0]} → {self.W.shape[1]}, "
f"ativação={self.ativacao})")
class RedeNeural:
"""
Rede neural feedforward com múltiplas camadas.
Forward pass completo.
"""
def __init__(self, camadas: list[CamadaDensa]):
self.camadas = camadas
def forward(self, X: np.ndarray) -> np.ndarray:
"""Propaga X por todas as camadas."""
saida = X
for camada in self.camadas:
saida = camada.forward(saida)
return saida
def predict(self, X: np.ndarray) -> np.ndarray:
probs = self.forward(X)
if probs.shape[1] == 1:
return (probs >= 0.5).astype(int).ravel()
return probs.argmax(axis=1)
def predict_proba(self, X: np.ndarray) -> np.ndarray:
return self.forward(X)
@property
def n_parametros_total(self) -> int:
return sum(c.n_parametros for c in self.camadas)
def resumo(self):
print("Arquitetura da Rede Neural:")
print("-" * 45)
total = 0
for i, camada in enumerate(self.camadas):
n = camada.n_parametros
total += n
print(f" Camada {i+1}: {camada} | parâmetros: {n:,}")
print("-" * 45)
print(f" Total de parâmetros: {total:,}")
# Construindo e testando o forward pass
np.random.seed(42)
rede = RedeNeural([
CamadaDensa(4, 8, ativacao="relu"), # entrada → oculta 1
CamadaDensa(8, 8, ativacao="relu"), # oculta 1 → oculta 2
CamadaDensa(8, 1, ativacao="sigmoid"), # oculta 2 → saída
])
rede.resumo()
# Teste com um batch de exemplos
X_teste_fw = np.random.randn(5, 4) # 5 exemplos, 4 features
saida_fw = rede.forward(X_teste_fw)
print(f"\nForward Pass:")
print(f" Entrada: {X_teste_fw.shape}")
print(f" Saída: {saida_fw.shape}")
print(f" Probabilidades:\n {saida_fw.round(4)}")
print(f" Classes preditas: {rede.predict(X_teste_fw).ravel()}")
Saída:
Arquitetura da Rede Neural:
─────────────────────────────────────────────
Camada 1: CamadaDensa(4 → 8, ativação=relu) | parâmetros: 40
Camada 2: CamadaDensa(8 → 8, ativação=relu) | parâmetros: 72
Camada 3: CamadaDensa(8 → 1, ativação=sigmoid)| parâmetros: 9
─────────────────────────────────────────────
Total de parâmetros: 121
6. Funções de Perda
A função de perda mede o quão errado o modelo está. O treinamento é o processo de minimizar essa função.
def binary_cross_entropy(y_real: np.ndarray,
y_pred: np.ndarray,
eps: float = 1e-15) -> float:
"""
Binary Cross-Entropy para classificação binária.
BCE = -(1/n) Σ [y·log(ŷ) + (1-y)·log(1-ŷ)]
"""
y_pred = np.clip(y_pred, eps, 1 - eps)
return -np.mean(
y_real * np.log(y_pred) +
(1 - y_real) * np.log(1 - y_pred)
)
def categorical_cross_entropy(y_real: np.ndarray,
y_pred: np.ndarray,
eps: float = 1e-15) -> float:
"""
Categorical Cross-Entropy para classificação multiclasse.
CCE = -(1/n) Σᵢ Σⱼ yᵢⱼ·log(ŷᵢⱼ)
"""
y_pred = np.clip(y_pred, eps, 1.0)
return -np.mean(np.sum(y_real * np.log(y_pred), axis=1))
def mse_loss(y_real: np.ndarray,
y_pred: np.ndarray) -> float:
"""Mean Squared Error para regressão."""
return np.mean((y_real - y_pred) ** 2)
def mae_loss(y_real: np.ndarray,
y_pred: np.ndarray) -> float:
"""Mean Absolute Error para regressão."""
return np.mean(np.abs(y_real - y_pred))
# Comportamento das funções de perda
print("Comportamento da Binary Cross-Entropy:")
print("\nQuando o modelo acerta com alta confiança:")
print(f" y=1, ŷ=0.99: BCE = {binary_cross_entropy(np.array([1]), np.array([0.99])):.4f}")
print(f" y=0, ŷ=0.01: BCE = {binary_cross_entropy(np.array([0]), np.array([0.01])):.4f}")
print("\nQuando o modelo acerta com baixa confiança:")
print(f" y=1, ŷ=0.55: BCE = {binary_cross_entropy(np.array([1]), np.array([0.55])):.4f}")
print(f" y=0, ŷ=0.45: BCE = {binary_cross_entropy(np.array([0]), np.array([0.45])):.4f}")
print("\nQuando o modelo erra com alta confiança (punição severa):")
print(f" y=1, ŷ=0.01: BCE = {binary_cross_entropy(np.array([1]), np.array([0.01])):.4f}")
print(f" y=0, ŷ=0.99: BCE = {binary_cross_entropy(np.array([0]), np.array([0.99])):.4f}")
Saída:
Comportamento da Binary Cross-Entropy:
Quando o modelo acerta com alta confiança:
y=1, ŷ=0.99: BCE = 0.0101
y=0, ŷ=0.01: BCE = 0.0101
Quando o modelo acerta com baixa confiança:
y=1, ŷ=0.55: BCE = 0.5978
y=0, ŷ=0.45: BCE = 0.5978
Quando o modelo erra com alta confiança (punição severa):
y=1, ŷ=0.01: BCE = 4.6052
y=0, ŷ=0.99: BCE = 4.6052
7. Inicialização de Pesos
A inicialização dos pesos é crucial para o treinamento. Pesos inicializados de forma inadequada podem causar gradientes que desaparecem ou explodem.
def comparar_inicializacoes():
"""Compara o efeito de diferentes estratégias de inicialização."""
n = 1000 # número de neurônios por camada
n_camadas = 10
estrategias = {
"Zeros": lambda shape: np.zeros(shape),
"Uniforme [-1,1]": lambda shape: np.random.uniform(-1, 1, shape),
"Normal σ=1": lambda shape: np.random.randn(*shape),
"Xavier/Glorot": lambda shape: np.random.randn(*shape) * np.sqrt(1/shape[0]),
"He": lambda shape: np.random.randn(*shape) * np.sqrt(2/shape[0]),
}
fig, axes = plt.subplots(1, len(estrategias),
figsize=(18, 4))
for ax, (nome, init_fn) in zip(axes, estrategias.items()):
ativacoes = [np.random.randn(1, n)] # entrada
for _ in range(n_camadas):
W = init_fn((n, n))
z = ativacoes[-1] @ W
a = np.tanh(z) # usar tanh para demonstrar saturação
ativacoes.append(a)
# Plotar distribuição das ativações na última camada
vals = ativacoes[-1].ravel()
ax.hist(vals, bins=30, density=True,
color="steelblue", alpha=0.7,
edgecolor="white")
ax.set_title(f"{nome}\nstd={vals.std():.3f}",
fontsize=9)
ax.set_xlabel("Valor de ativação")
ax.set_xlim(-2, 2)
plt.suptitle("Distribuição de Ativações após 10 Camadas\n"
"(Inicialização afeta propagação do sinal)",
fontsize=12)
plt.tight_layout()
plt.show()
print("Observações:")
print(" Zeros: Todos os neurônios aprendem a mesma coisa (simetria)")
print(" Uniforme grande: Explode — saturação total nas ativações")
print(" Normal σ=1: Encolhe — gradientes desaparecem nas camadas profundas")
print(" Xavier/Glorot: Bom para tanh/sigmoid")
print(" He: Bom para ReLU (padrão moderno)")
comparar_inicializacoes()
8. Rede Neural Completa com Treinamento Manual
Agora vamos implementar uma rede neural completa com forward pass, cálculo de perda e atualização simples de pesos (sem backpropagation completa — isso fica para a próxima aula).
class RedeNeuralTreinavel:
"""
Rede neural com treinamento via gradiente descendente.
Implementação completa do zero com NumPy.
Inclui forward pass, loss e atualização de pesos.
"""
def __init__(self, arquitetura: list[dict],
learning_rate: float = 0.01):
"""
arquitetura: lista de dicts com 'n_saidas' e 'ativacao'
Exemplo: [{'n_saidas': 8, 'ativacao': 'relu'},
{'n_saidas': 1, 'ativacao': 'sigmoid'}]
"""
self.lr = learning_rate
self.arquitetura = arquitetura
self.pesos = []
self.biases = []
self.historico_loss = {"treino": [], "val": []}
self._inicializado = False
def _inicializar(self, n_entradas: int):
"""Inicializa pesos com estratégia de He."""
self.pesos = []
self.biases = []
tam_anterior = n_entradas
for camada in self.arquitetura:
n_saidas = camada["n_saidas"]
escala = np.sqrt(2.0 / tam_anterior)
W = np.random.randn(tam_anterior, n_saidas) * escala
b = np.zeros(n_saidas)
self.pesos.append(W)
self.biases.append(b)
tam_anterior = n_saidas
self._inicializado = True
def _ativacao(self, z: np.ndarray,
nome: str) -> np.ndarray:
if nome == "relu":
return np.maximum(0, z)
elif nome == "sigmoid":
return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
elif nome == "tanh":
return np.tanh(z)
elif nome == "softmax":
e_z = np.exp(z - z.max(axis=1, keepdims=True))
return e_z / e_z.sum(axis=1, keepdims=True)
elif nome == "linear":
return z
def _d_ativacao(self, a: np.ndarray,
nome: str) -> np.ndarray:
"""Derivada da função de ativação (para backprop)."""
if nome == "relu":
return (a > 0).astype(float)
elif nome == "sigmoid":
return a * (1 - a)
elif nome == "tanh":
return 1 - a ** 2
elif nome in ("softmax", "linear"):
return np.ones_like(a)
def _forward(self, X: np.ndarray) -> tuple:
"""Forward pass. Retorna ativações de todas as camadas."""
ativacoes = [X]
entrada = X
for W, b, cam in zip(self.pesos, self.biases,
self.arquitetura):
z = entrada @ W + b
a = self._ativacao(z, cam["ativacao"])
ativacoes.append(a)
entrada = a
return ativacoes
def _loss(self, y_real: np.ndarray,
y_pred: np.ndarray) -> float:
"""Binary Cross-Entropy."""
eps = 1e-15
y_pred = np.clip(y_pred, eps, 1 - eps)
return -np.mean(
y_real * np.log(y_pred) +
(1 - y_real) * np.log(1 - y_pred)
)
def _backward(self, ativacoes: list,
y_real: np.ndarray):
"""
Backpropagation: calcula gradientes e atualiza pesos.
"""
n = y_real.shape[0]
grads_W = [None] * len(self.pesos)
grads_b = [None] * len(self.biases)
# Gradiente da última camada (BCE + sigmoid simplificado)
delta = ativacoes[-1] - y_real.reshape(-1, 1)
# Propagar de trás para frente
for i in reversed(range(len(self.pesos))):
entrada_camada = ativacoes[i]
grads_W[i] = (1 / n) * entrada_camada.T @ delta
grads_b[i] = (1 / n) * delta.sum(axis=0)
if i > 0:
# Propagar gradiente para camada anterior
delta_entrada = delta @ self.pesos[i].T
d_ativ = self._d_ativacao(
ativacoes[i],
self.arquitetura[i - 1]["ativacao"]
)
delta = delta_entrada * d_ativ
# Atualizar pesos
for i in range(len(self.pesos)):
self.pesos[i] -= self.lr * grads_W[i]
self.biases[i] -= self.lr * grads_b[i]
def fit(self, X_train: np.ndarray,
y_train: np.ndarray,
X_val: np.ndarray = None,
y_val: np.ndarray = None,
epocas: int = 100,
batch_size: int = 32,
verbose: int = 10) -> "RedeNeuralTreinavel":
if not self._inicializado:
self._inicializar(X_train.shape[1])
n = len(X_train)
for epoca in range(epocas):
# Mini-batch shuffle
idx = np.random.permutation(n)
X_sh, y_sh = X_train[idx], y_train[idx]
for start in range(0, n, batch_size):
end = min(start + batch_size, n)
X_b = X_sh[start:end]
y_b = y_sh[start:end]
ativacoes = self._forward(X_b)
self._backward(ativacoes, y_b)
# Loss da época
ativacoes_tr = self._forward(X_train)
loss_tr = self._loss(y_train, ativacoes_tr[-1])
self.historico_loss["treino"].append(loss_tr)
if X_val is not None:
ativacoes_va = self._forward(X_val)
loss_va = self._loss(y_val, ativacoes_va[-1])
self.historico_loss["val"].append(loss_va)
if verbose > 0 and (epoca + 1) % verbose == 0:
msg = f"Época {epoca+1:4d}/{epocas} — Loss Treino: {loss_tr:.4f}"
if X_val is not None:
msg += f" — Loss Val: {loss_va:.4f}"
print(msg)
return self
def predict_proba(self, X: np.ndarray) -> np.ndarray:
return self._forward(X)[-1]
def predict(self, X: np.ndarray,
limiar: float = 0.5) -> np.ndarray:
probs = self.predict_proba(X)
return (probs >= limiar).astype(int).ravel()
def plotar_historico(self):
fig, ax = plt.subplots(figsize=(10, 5))
epocas = range(1, len(self.historico_loss["treino"]) + 1)
ax.plot(epocas, self.historico_loss["treino"],
label="Treino", color="steelblue", linewidth=2)
if self.historico_loss["val"]:
ax.plot(epocas, self.historico_loss["val"],
label="Validação", color="coral",
linewidth=2, linestyle="--")
ax.set_xlabel("Época")
ax.set_ylabel("Binary Cross-Entropy Loss")
ax.set_title("Curva de Aprendizado da Rede Neural")
ax.legend()
plt.tight_layout()
plt.show()
9. Treinando a Rede Neural Manual
# Dataset: moons (não linearmente separável — requer rede neural)
np.random.seed(42)
X_moons, y_moons = make_moons(
n_samples=800, noise=0.2, random_state=42
)
X_tr_mn, X_te_mn, y_tr_mn, y_te_mn = train_test_split(
X_moons, y_moons, test_size=0.2,
random_state=42, stratify=y_moons
)
# Normalizar
scaler_mn = StandardScaler()
X_tr_mns = scaler_mn.fit_transform(X_tr_mn)
X_te_mns = scaler_mn.transform(X_te_mn)
# Dividir treino em treino/validação
X_tr2, X_va, y_tr2, y_va = train_test_split(
X_tr_mns, y_tr_mn, test_size=0.2, random_state=42
)
# Construir e treinar a rede
print("Treinando rede neural manual...")
rede_manual = RedeNeuralTreinavel(
arquitetura=[
{"n_saidas": 16, "ativacao": "relu"},
{"n_saidas": 8, "ativacao": "relu"},
{"n_saidas": 1, "ativacao": "sigmoid"},
],
learning_rate=0.05
)
rede_manual.fit(
X_tr2, y_tr2,
X_val=X_va, y_val=y_va,
epocas=200,
batch_size=32,
verbose=50
)
# Avaliar
y_pred_mn = rede_manual.predict(X_te_mns)
acc_manual = accuracy_score(y_te_mn, y_pred_mn)
print(f"\nAcurácia no teste: {acc_manual:.4f}")
rede_manual.plotar_historico()
# Comparar com Perceptron (que deve falhar nesse dataset)
perc_moons = Perceptron(n_iteracoes=200, learning_rate=0.01)
perc_moons.fit(X_tr2, y_tr2)
acc_perc_mn = accuracy_score(y_te_mn, perc_moons.predict(X_te_mns))
print(f"Perceptron (linear): {acc_perc_mn:.4f}")
print(f"Rede Neural (MLP): {acc_manual:.4f}")
print(f"→ Rede neural captura padrão não linear!")
# Visualizar fronteira de decisão
def plotar_fronteira(modelo, X, y, titulo, ax, is_nn=True):
h = 0.02
x_min, x_max = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5
y_min, y_max = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5
xx, yy = np.meshgrid(
np.arange(x_min, x_max, h),
np.arange(y_min, y_max, h)
)
grid = np.column_stack([xx.ravel(), yy.ravel()])
if is_nn:
Z = modelo.predict(grid).reshape(xx.shape)
else:
Z = modelo.predict(grid).reshape(xx.shape)
ax.contourf(xx, yy, Z, alpha=0.3,
cmap=plt.cm.RdYlBu)
scatter = ax.scatter(X[:, 0], X[:, 1], c=y,
cmap=plt.cm.RdYlBu,
edgecolors="white",
s=30, linewidth=0.5)
ax.set_title(titulo)
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
plotar_fronteira(perc_moons, X_te_mns, y_te_mn,
f"Perceptron\nAcc={acc_perc_mn:.3f}",
axes[0], is_nn=False)
plotar_fronteira(rede_manual, X_te_mns, y_te_mn,
f"Rede Neural (MLP)\nAcc={acc_manual:.3f}",
axes[1], is_nn=True)
plt.suptitle("Fronteira de Decisão: Perceptron vs MLP",
fontsize=13)
plt.tight_layout()
plt.show()
10. Exemplo Completo: Comparação de Arquiteturas
print("=" * 60)
print("COMPARAÇÃO DE ARQUITETURAS DE REDES NEURAIS")
print("=" * 60)
# Dataset mais complexo
np.random.seed(42)
X_comp, y_comp = make_classification(
n_samples=1000, n_features=10,
n_informative=6, n_redundant=2,
n_clusters_per_class=2,
random_state=42
)
X_tr_c, X_te_c, y_tr_c, y_te_c = train_test_split(
X_comp, y_comp, test_size=0.2,
random_state=42, stratify=y_comp
)
X_tr_c2, X_va_c, y_tr_c2, y_va_c = train_test_split(
X_tr_c, y_tr_c, test_size=0.2, random_state=42
)
scaler_c = StandardScaler()
X_tr_cs = scaler_c.fit_transform(X_tr_c2)
X_va_cs = scaler_c.transform(X_va_c)
X_te_cs = scaler_c.transform(X_te_c)
# Diferentes arquiteturas para comparar
arquiteturas = {
"Shallow (1 camada oculta)": [
{"n_saidas": 32, "ativacao": "relu"},
{"n_saidas": 1, "ativacao": "sigmoid"},
],
"Medium (2 camadas ocultas)": [
{"n_saidas": 16, "ativacao": "relu"},
{"n_saidas": 16, "ativacao": "relu"},
{"n_saidas": 1, "ativacao": "sigmoid"},
],
"Deep (3 camadas ocultas)": [
{"n_saidas": 16, "ativacao": "relu"},
{"n_saidas": 8, "ativacao": "relu"},
{"n_saidas": 8, "ativacao": "relu"},
{"n_saidas": 1, "ativacao": "sigmoid"},
],
"Wide (1 camada grande)": [
{"n_saidas": 64, "ativacao": "relu"},
{"n_saidas": 1, "ativacao": "sigmoid"},
],
}
print(f"\n{'Arquitetura':<28} {'Params':>8} "
f"{'Acc Treino':>12} {'Acc Teste':>12} {'Loss Final':>12}")
print("-" * 76)
resultados_arq = {}
for nome, arq in arquiteturas.items():
np.random.seed(42)
rede_arq = RedeNeuralTreinavel(
arquitetura=arq, learning_rate=0.05
)
rede_arq.fit(
X_tr_cs, y_tr_c2,
X_val=X_va_cs, y_val=y_va_c,
epocas=150, batch_size=32, verbose=0
)
acc_tr = accuracy_score(y_tr_c2, rede_arq.predict(X_tr_cs))
acc_te = accuracy_score(y_te_c, rede_arq.predict(X_te_cs))
loss_f = rede_arq.historico_loss["val"][-1]
n_pars = sum(W.size + b.size
for W, b in zip(rede_arq.pesos, rede_arq.biases))
resultados_arq[nome] = {
"acc_te": acc_te,
"rede": rede_arq
}
print(f"{nome:<28} {n_pars:>8,} "
f"{acc_tr:>12.4f} {acc_te:>12.4f} {loss_f:>12.4f}")
melhor_arq = max(resultados_arq, key=lambda k: resultados_arq[k]["acc_te"])
print(f"\nMelhor arquitetura: {melhor_arq}")
print(f"Acurácia: {resultados_arq[melhor_arq]['acc_te']:.4f}")
# Plotar curvas de aprendizado comparadas
fig, ax = plt.subplots(figsize=(12, 6))
cores_plot = ["steelblue", "coral", "mediumseagreen", "mediumpurple"]
for (nome, res), cor in zip(resultados_arq.items(), cores_plot):
val_losses = res["rede"].historico_loss["val"]
ax.plot(val_losses, label=nome, color=cor, linewidth=2)
ax.set_xlabel("Época")
ax.set_ylabel("Validation Loss")
ax.set_title("Comparação de Arquiteturas — Validation Loss")
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
Resumo da Aula
- O perceptron é o neurônio artificial mais simples: soma ponderada + função de ativação
- Perceptrons não conseguem resolver problemas não lineares como XOR — precisamos de múltiplas camadas
- Funções de ativação introduzem não linearidade: ReLU é o padrão para camadas ocultas, sigmoid para saída binária, softmax para multiclasse
- O forward pass propaga dados da entrada até a saída camada por camada:
a = f(XW + b) - Inicialização de pesos importa: zeros causam simetria, He é ideal para ReLU, Xavier para sigmoid/tanh
- Binary Cross-Entropy é a função de perda padrão para classificação binária
- Redes mais profundas podem aprender representações mais abstratas, mas precisam de dados suficientes
- Mini-batch training divide os dados em lotes para gradientes mais estáveis e treino mais eficiente
- A próxima aula cobrirá backpropagation — o algoritmo que calcula os gradientes para atualizar os pesos
Exercícios
-
Explique por que um único perceptron não consegue resolver o problema XOR. Use argumentos geométricos (pense em separação linear no espaço 2D) e mostre como uma rede com uma camada oculta resolveria.
✓ Resposta:O perceptron cria uma fronteira de decisão linear — uma linha reta em 2D. O problema XOR tem os pontos (0,0)→0, (0,1)→1, (1,0)→1, (1,1)→0. Se você plotar esses 4 pontos no plano, verá que não existe nenhuma linha reta que separe os positivos (0,1) e (1,0) dos negativos (0,0) e (1,1) — os positivos ficam em cantos diagonalmente opostos.
Uma rede com uma camada oculta resolve porque cria múltiplas fronteiras lineares e as combina. Conceptualmente: um neurônio pode aprender "x₁ OR x₂" (ativa quando pelo menos um é 1) e outro pode aprender "x₁ AND x₂" (ativa quando ambos são 1). A camada de saída combina esses dois: "OR mas não AND", que é exatamente XOR. Cada neurônio oculto cria uma divisão linear diferente do espaço, e a camada de saída aprende a combinar essas divisões para criar regiões não convexas.
-
Por que precisamos de funções de ativação não lineares em redes neurais? Prove matematicamente que uma rede com múltiplas camadas lineares (sem ativação) é equivalente a uma única camada linear.
✓ Resposta:Sem funções de ativação, cada camada seria apenas uma transformação linear
y = Wx + b. Compondo duas camadas lineares:y = W₂(W₁x + b₁) + b₂ y = W₂W₁x + W₂b₁ + b₂ y = W_efetivo × x + b_efetivoO produto de matrizes
W₂W₁é apenas outra matriz, eW₂b₁ + b₂é apenas outro vetor. Portanto, independentemente de quantas camadas lineares você empilhe, o resultado é sempre equivalente a uma única camada lineary = Wx + b. A rede nunca conseguiria aprender relações não lineares nos dados, como XOR, ou reconhecer padrões complexos como rostos em imagens.As funções de ativação quebram essa linearidade: após
a₁ = ReLU(W₁x + b₁),a₁não é mais uma transformação linear de x — o ReLU introduziu uma não linearidade. A composiçãoW₂·ReLU(W₁x + b₁)não pode ser simplificada para uma única matriz multiplicada por x. -
Compare ReLU e Sigmoid como funções de ativação para camadas ocultas. Explique o problema do "neurônio morto" do ReLU e o problema do "vanishing gradient" do Sigmoid. Por que ReLU é preferida na prática?
✓ Resposta:ReLU:
f(z) = max(0, z). Simples, computacionalmente barata, gradiente constante de 1 para z > 0 (não causa vanishing gradient). Problema do "neurônio morto": se z < 0, a saída é 0 e o gradiente é 0. Se um neurônio recebe gradiente zero consistentemente, seus pesos nunca são atualizados e ele "morre" permanentemente, não contribuindo para o aprendizado. Estimativas sugerem que 10-40% dos neurônios ReLU podem morrer durante o treino.Sigmoid:
f(z) = 1/(1+e⁻ᶻ). Mapeia para (0,1), interpretável como probabilidade. Problema do vanishing gradient: para z muito grande ou muito pequeno, a derivada é quase zero (a curva fica plana). Em redes profundas, multiplicar muitos gradientes próximos de zero faz o gradiente encolher exponencialmente nas camadas iniciais — elas aprendem extremamente devagar ou não aprendem.ReLU é preferida porque o gradiente constante (1) para entradas positivas evita o vanishing gradient, e o cálculo é trivial (só compara com zero). As variantes Leaky ReLU e ELU resolvem o problema do neurônio morto mantendo os benefícios do gradiente constante.
-
Explique a inicialização de He e de Xavier. Por que inicializar todos os pesos com zero é um problema crítico? O que é o problema de "quebra de simetria"?
✓ Resposta:Inicialização He:
W ~ N(0, √(2/n_entrada)). Projetada para ReLU: como ReLU zera metade das ativações (as negativas), a variância efetiva é reduzida pela metade. Multiplicar por√2compensa, mantendo a variância das ativações constante através das camadas.Inicialização Xavier/Glorot:
W ~ N(0, √(1/n_entrada))ou√(2/(n_entrada + n_saída)). Projetada para tanh/sigmoid: mantém a variância dos gradientes e ativações similar nas duas direções (forward e backward).Pesos todos zero — problema crítico: todos os neurônios da mesma camada recebem o mesmo gradiente e fazem a mesma atualização. Eles nunca se diferenciam — a rede inteira se comporta como se tivesse apenas um neurônio por camada. Isso é o problema de "quebra de simetria": os pesos precisam começar diferentes para que cada neurônio aprenda algo diferente. Com aleatoriedade na inicialização, cada neurônio parte de um ponto diferente no espaço de parâmetros e, durante o treino, especializa-se em detectar padrões diferentes, criando representações ricas nas camadas ocultas.
-
O que é mini-batch gradient descent? Compare com batch gradient descent (todos os dados de uma vez) e stochastic gradient descent (um exemplo por vez). Quais são as vantagens e desvantagens de cada abordagem?
✓ Resposta:Batch gradient descent usa todos os N exemplos de treino para calcular o gradiente antes de atualizar os pesos. O gradiente é preciso (sem ruído), mas cada update requer processar todo o dataset. Para 1 milhão de exemplos, você faz apenas uma atualização de pesos por passagem pelos dados — extremamente lento.
Stochastic gradient descent (SGD) usa um único exemplo aleatório por atualização. Muito rápido por update, mas o gradiente é ruidoso — cada exemplo pode apontar em direção ligeiramente diferente do gradiente verdadeiro. O ruído pode ajudar a escapar de mínimos locais, mas dificulta a convergência precisa.
Mini-batch gradient descent usa N exemplos (tipicamente 32, 64 ou 128) por atualização. É o padrão na prática porque combina vantagens: gradientes menos ruidosos que SGD puro, muito mais rápido por época que batch completo, e aproveita operações matriciais paralelas em GPU de forma eficiente. O tamanho do batch é um hiperparâmetro: batches maiores dão gradientes mais precisos mas generalizam ligeiramente pior; batches menores são mais ruidosos mas frequentemente encontram melhores soluções e treinam mais rápido por época.
-
Explique o que acontece durante o forward pass de uma rede neural com 3 camadas (entrada → oculta → saída) para um exemplo de entrada. Descreva cada operação matemática que ocorre e o que cada número representa.
✓ Resposta:Seja uma rede com entrada (3 features), camada oculta (4 neurônios, ReLU) e saída (1 neurônio, sigmoid).
Para um exemplo x = [0.5, -1.2, 0.8]:
Camada oculta — operação linear:
z₁ = W₁ × x + b₁. W₁ tem shape (3, 4), então z₁ tem shape (4,). Cada elemento de z₁ é a soma ponderada de todos os inputs com os pesos de um neurônio específico: z₁[0] = w₁₁×0.5 + w₂₁×(-1.2) + w₃₁×0.8 + b₁. Matematicamente é uma projeção do espaço 3D para um espaço 4D.Camada oculta — ativação:
a₁ = ReLU(z₁) = max(0, z₁). Cada elemento negativo de z₁ é zerado. a₁ representa quais "detectores de padrão" foram ativados para esse exemplo.Camada de saída — operação linear:
z₂ = W₂ × a₁ + b₂. W₂ tem shape (4, 1), então z₂ é um escalar. É a combinação ponderada das ativações da camada oculta.Camada de saída — ativação:
a₂ = sigmoid(z₂) = 1/(1+e⁻ᶻ²). a₂ é um número entre 0 e 1, interpretado como P(classe = 1). Se a₂ ≥ 0.5, prediz classe 1, caso contrário classe 0.
Referências
- Neural Networks and Deep Learning (Michael Nielsen, livro gratuito)
- 3Blue1Brown — But what is a Neural Network? (YouTube)
- Stanford CS231n — Neural Networks
- StatQuest — Neural Networks (playlist)
- Deep Learning Book (Goodfellow et al.) — Capítulo 6
- Scikit-learn — MLPClassifier
- He et al. (2015) — Delving Deep into Rectifiers
- Glorot & Bengio (2010) — Understanding the difficulty of training deep networks