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

  1. 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.

  2. 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_efetivo
    

    O produto de matrizes W₂W₁ é apenas outra matriz, e W₂b₁ + b₂ é apenas outro vetor. Portanto, independentemente de quantas camadas lineares você empilhe, o resultado é sempre equivalente a uma única camada linear y = 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ção W₂·ReLU(W₁x + b₁) não pode ser simplificada para uma única matriz multiplicada por x.

  3. 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.

  4. 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 √2 compensa, 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.

  5. 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.

  6. 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