Pular para o conteúdo

    Capítulo 43, Projetos

    Projeto Avançado: uma rede neural do zero

    Propagação, retropropagação, softmax estável, otimizador com momento, uma verificação de gradiente por diferenças finitas, e uma espiral que nenhuma reta separa. Tudo só com NumPy, em cerca de 130 linhas de código (sem contar comentários e linhas em branco). Faça depois do capítulo 37.

    Os arquivos deste capítulo estão em projetos/rede_neural/.

    O problema

    Um classificador separa pontos em classes. Quando uma reta basta, a regressão resolve. A espiral do projeto tem três braços entrelaçados, e nenhuma reta (nem nenhuma combinação simples de retas) os separa. Uma rede neural resolve porque compõe transformações lineares com uma não linearidade (a ReLU), e cada camada dobra o espaço um pouco mais. O que o projeto prova é que não há nada de misterioso: são multiplicações de matrizes e um gradiente.

    PeçaO que fazCapítulo
    Camadas densasa @ W + b, em lote23
    ReLUnp.maximum(z, 0)21
    Softmax estávelProbabilidades sem overflow32, 36
    Entropia cruzadaMede o erro, com log seguro15, 36
    RetropropagaçãoA regra da cadeia, com transpostas23
    Verificação do gradienteDiferenças finitas contra o analítico36
    InicializaçãoGerador com semente, escala de He16, 35

    A matemática, em quatro linhas

    Para um lote de n exemplos, com a₀ = X:

    1. Frente: zᵢ = aᵢ₋₁ @ Wᵢ + bᵢ, e aᵢ = ReLU(zᵢ) (a última camada fica sem ReLU, e é ela que alimenta o softmax).
    2. Perda: a entropia cruzada média, −log(p do rótulo certo), mais um termo L2 que desencoraja pesos grandes.
    3. O gradiente que simplifica tudo: na saída, o gradiente da entropia cruzada com softmax é (probabilidades − rótulos) / n. Uma subtração, sem derivar nada.
    4. Trás: o gradiente do peso é aᵢ₋₁ᵀ @ dz, o do viés é a soma de dz nas linhas, e o gradiente que volta para a camada anterior é (dz @ Wᵢᵀ) · (zᵢ₋₁ > 0), onde a máscara é a derivada da ReLU.

    O código

    Os dados: a espiral, o ou exclusivo (o problema mais simples que uma reta não resolve) e a divisão em treino e teste. Repare que o espiral gera as três classes sem laço, com broadcasting entre (1, n) e (classes, 1):

    projetos/rede_neural/src/rede_neural/dados.py
    import numpy as np
    
    from rede_neural.tipos import Array, Inteiros
    
    
    def espiral(
        n_por_classe: int, classes: int, ruido: float, rng: np.random.Generator
    ) -> tuple[Array, Inteiros]:
        """Braços de espiral entrelaçados: um problema que nenhuma reta separa."""
        raio = np.linspace(0.0, 1.0, n_por_classe)
        angulo = np.linspace(0.0, 4.0, n_por_classe)[None, :] + 4.0 * np.arange(classes)[:, None]
        angulo = angulo + rng.normal(0.0, ruido, size=(classes, n_por_classe))
        pontos = np.stack([raio * np.sin(angulo), raio * np.cos(angulo)], axis=-1)
        X: Array = pontos.reshape(-1, 2)
        y: Inteiros = np.repeat(np.arange(classes), n_por_classe)
        return X, y
    
    
    def ou_exclusivo(n: int, rng: np.random.Generator) -> tuple[Array, Inteiros]:
        """Pontos em [-1, 1]²: a classe é 1 quando os sinais das coordenadas são diferentes."""
        X: Array = rng.uniform(-1.0, 1.0, size=(n, 2))
        y: Inteiros = ((X[:, 0] > 0) != (X[:, 1] > 0)).astype(np.int64)
        return X, y
    
    
    def dividir(
        X: Array, y: Inteiros, fracao_teste: float, rng: np.random.Generator
    ) -> tuple[Array, Array, Inteiros, Inteiros]:
        """Separa treino e teste depois de embaralhar (os dados vêm ordenados por classe)."""
        ordem = rng.permutation(len(X))
        corte = int(len(X) * (1 - fracao_teste))
        treino, teste = ordem[:corte], ordem[corte:]
        return X[treino], X[teste], y[treino], y[teste]
    

    A rede. O método perda_e_gradientes é o coração: a frente guarda as entradas e as pré-ativações de cada camada, e a volta as reutiliza. A inicialização de He escala os pesos pelo inverso da raiz do número de entradas, o que mantém a variância dos sinais estável de camada para camada:

    projetos/rede_neural/src/rede_neural/rede.py
    import numpy as np
    
    from rede_neural.tipos import Array, Inteiros
    
    
    def relu(z: Array) -> Array:
        resultado: Array = np.maximum(z, 0.0)
        return resultado
    
    
    def softmax(z: Array) -> Array:
        """Probabilidades por linha. Subtrair o máximo evita o overflow do exp (capítulo 36)."""
        e = np.exp(z - z.max(axis=1, keepdims=True))
        resultado: Array = e / e.sum(axis=1, keepdims=True)
        return resultado
    
    
    class RedeDensa:
        """Camadas totalmente conectadas com ReLU e uma saída softmax.
    
        Convenção de formas: ``X`` é ``(n, entradas)``, o peso da camada i é ``(entradas_i, saidas_i)``
        e a saída é ``(n, classes)``. A forma segue a do scikit-learn: uma linha por exemplo.
        """
    
        def __init__(self, tamanhos: list[int], rng: np.random.Generator, l2: float = 0.0) -> None:
            if len(tamanhos) < 2:
                raise ValueError("informe ao menos a entrada e a saída")
            self.l2 = l2
            # Inicialização de He: o desvio cresce com o inverso da raiz do número de entradas.
            self.pesos: list[Array] = [
                rng.normal(0.0, np.sqrt(2.0 / entrada), size=(entrada, saida))
                for entrada, saida in zip(tamanhos[:-1], tamanhos[1:], strict=True)
            ]
            self.vieses: list[Array] = [np.zeros(saida) for saida in tamanhos[1:]]
    
        def parametros(self) -> list[Array]:
            """Os próprios arrays (não cópias): o otimizador e o teste de gradiente os alteram."""
            return [*self.pesos, *self.vieses]
    
        def _frente(self, X: Array) -> tuple[list[Array], list[Array]]:
            """Devolve a entrada de cada camada e as pré-ativações (antes da ReLU)."""
            entradas = [X]
            pre_ativacoes: list[Array] = []
            a = X
            ultima = len(self.pesos) - 1
            for i, (W, b) in enumerate(zip(self.pesos, self.vieses, strict=True)):
                z = a @ W + b
                pre_ativacoes.append(z)
                a = z if i == ultima else relu(z)
                entradas.append(a)
            return entradas, pre_ativacoes
    
        def probabilidades(self, X: Array) -> Array:
            _, pre_ativacoes = self._frente(X)
            return softmax(pre_ativacoes[-1])
    
        def prever(self, X: Array) -> Inteiros:
            previsto: Inteiros = self.probabilidades(X).argmax(axis=1)
            return previsto
    
        def perda_e_gradientes(self, X: Array, y: Inteiros) -> tuple[float, list[Array]]:
            """Perda de entropia cruzada (mais L2) e o gradiente em relação a cada parâmetro."""
            n = len(X)
            entradas, pre_ativacoes = self._frente(X)
            probs = softmax(pre_ativacoes[-1])
            perda = -np.log(np.maximum(probs[np.arange(n), y], 1e-12)).mean()
            perda += 0.5 * self.l2 * sum(float((W**2).sum()) for W in self.pesos)
    
            # O gradiente da entropia cruzada com softmax é simplesmente (probabilidade - rótulo) / n.
            dz = probs.copy()
            dz[np.arange(n), y] -= 1.0
            dz /= n
    
            grad_pesos: list[Array] = []
            grad_vieses: list[Array] = []
            for i in range(len(self.pesos) - 1, -1, -1):
                grad_pesos.insert(0, entradas[i].T @ dz + self.l2 * self.pesos[i])
                grad_vieses.insert(0, dz.sum(axis=0))
                if i > 0:
                    dz = (dz @ self.pesos[i].T) * (pre_ativacoes[i - 1] > 0)
            return float(perda), [*grad_pesos, *grad_vieses]
    

    O otimizador, com momento: a velocidade acumula os passos anteriores, e isso atravessa vales estreitos da função de perda mais depressa do que o gradiente puro. Ele altera os parâmetros no lugar, e é por isso que parametros() devolve os próprios arrays, e não cópias:

    projetos/rede_neural/src/rede_neural/otimizador.py
    import numpy as np
    
    from rede_neural.tipos import Array
    
    
    class SGDMomento:
        """Gradiente descendente com momento: a velocidade acumula os passos anteriores."""
    
        def __init__(self, parametros: list[Array], taxa: float = 0.1, momento: float = 0.9) -> None:
            self.parametros = parametros
            self.taxa = taxa
            self.momento = momento
            self.velocidades = [np.zeros_like(p) for p in parametros]
    
        def passo(self, gradientes: list[Array]) -> None:
            for p, v, g in zip(self.parametros, self.velocidades, gradientes, strict=True):
                v *= self.momento
                v -= self.taxa * g
                p += v
    

    O laço de treino:

    projetos/rede_neural/src/rede_neural/treino.py
    from dataclasses import dataclass, field
    
    import numpy as np
    
    from rede_neural.otimizador import SGDMomento
    from rede_neural.rede import RedeDensa
    from rede_neural.tipos import Array, Inteiros
    
    
    @dataclass
    class Historico:
        perdas: list[float] = field(default_factory=list)
        acuracias: list[float] = field(default_factory=list)
    
    
    def acuracia(rede: RedeDensa, X: Array, y: Inteiros) -> float:
        return float((rede.prever(X) == y).mean())
    
    
    def treinar(
        rede: RedeDensa,
        X: Array,
        y: Inteiros,
        *,
        epocas: int,
        taxa: float = 0.1,
        momento: float = 0.9,
        tamanho_lote: int | None = None,
        rng: np.random.Generator | None = None,
    ) -> Historico:
        """Treina a rede. Sem ``tamanho_lote``, cada época é um único passo com todos os dados."""
        otimizador = SGDMomento(rede.parametros(), taxa, momento)
        rng = rng or np.random.default_rng(0)
        historico = Historico()
        for _ in range(epocas):
            if tamanho_lote is None:
                _, gradientes = rede.perda_e_gradientes(X, y)
                otimizador.passo(gradientes)
            else:
                ordem = rng.permutation(len(X))
                for inicio in range(0, len(X), tamanho_lote):
                    lote = ordem[inicio : inicio + tamanho_lote]
                    _, gradientes = rede.perda_e_gradientes(X[lote], y[lote])
                    otimizador.passo(gradientes)
            perda, _ = rede.perda_e_gradientes(X, y)
            historico.perdas.append(perda)
            historico.acuracias.append(acuracia(rede, X, y))
        return historico
    

    E a linha de comando:

    projetos/rede_neural/src/rede_neural/cli.py
    import argparse
    
    import numpy as np
    
    from rede_neural.dados import dividir, espiral, ou_exclusivo
    from rede_neural.rede import RedeDensa
    from rede_neural.treino import acuracia, treinar
    
    
    def criar_parser() -> argparse.ArgumentParser:
        parser = argparse.ArgumentParser(prog="rede", description="Rede neural só com NumPy")
        parser.add_argument("--semente", type=int, default=0)
        sub = parser.add_subparsers(dest="problema", required=True)
        espiral_p = sub.add_parser("espiral", help="3 braços de espiral entrelaçados")
        espiral_p.add_argument("--epocas", type=int, default=1000)
        espiral_p.add_argument("--oculta", type=int, default=64, help="neurônios por camada oculta")
        espiral_p.add_argument("--taxa", type=float, default=0.1)
        xor_p = sub.add_parser("xor", help="o ou exclusivo, que nenhuma reta resolve")
        xor_p.add_argument("--epocas", type=int, default=500)
        return parser
    
    
    def main(argv: list[str] | None = None) -> int:
        args = criar_parser().parse_args(argv)
        rng = np.random.default_rng(args.semente)
        if args.problema == "espiral":
            X, y = espiral(100, 3, 0.2, rng)
            tamanhos = [2, args.oculta, args.oculta, 3]
            taxa = args.taxa
        else:
            X, y = ou_exclusivo(200, rng)
            tamanhos = [2, 8, 2]
            taxa = 0.1
        X_treino, X_teste, y_treino, y_teste = dividir(X, y, 0.2, rng)
        rede = RedeDensa(tamanhos, rng, l2=1e-4)
    
        passo = max(args.epocas // 5, 1)
        historico = treinar(rede, X_treino, y_treino, epocas=args.epocas, taxa=taxa)
        for epoca in range(passo, args.epocas + 1, passo):
            perda, acc = historico.perdas[epoca - 1], historico.acuracias[epoca - 1]
            print(f"época {epoca:>5}  perda {perda:.4f}  acurácia de treino {acc:.3f}")
        print(f"acurácia no treino: {acuracia(rede, X_treino, y_treino):.3f}")
        print(f"acurácia no teste:  {acuracia(rede, X_teste, y_teste):.3f}")
        return 0
    

    Os testes

    O teste mais importante deste projeto é o que verifica o gradiente. Escrever a retropropagação à mão é fácil de errar (um sinal, uma transposta), e o erro não derruba nada: a rede apenas aprende mal. A verificação por diferenças finitas, do capítulo 36, perturba cada parâmetro para cima e para baixo, mede a variação da perda e compara com o gradiente calculado. Se concordam em todos os parâmetros, a retropropagação está certa:

    projetos/rede_neural/tests/test_rede.py
    import numpy as np
    
    from rede_neural.dados import dividir, espiral, ou_exclusivo
    from rede_neural.otimizador import SGDMomento
    from rede_neural.rede import RedeDensa, softmax
    from rede_neural.tipos import Array
    from rede_neural.treino import acuracia, treinar
    
    
    def test_probabilidades_tem_a_forma_certa_e_somam_1() -> None:
        rede = RedeDensa([4, 6, 3], np.random.default_rng(0))
        p = rede.probabilidades(np.random.default_rng(1).normal(size=(5, 4)))
        assert p.shape == (5, 3)
        assert np.allclose(p.sum(axis=1), 1.0)
    
    
    def test_softmax_e_estavel_com_valores_enormes() -> None:
        p = softmax(np.array([[1000.0, 1001.0, 1002.0]]))
        assert np.isfinite(p).all() and np.isclose(p.sum(), 1.0)
    
    
    def test_gradiente_confere_com_diferencas_finitas() -> None:
        """O teste mais importante: o gradiente escrito à mão bate com a derivada numérica."""
        rng = np.random.default_rng(3)
        rede = RedeDensa([3, 5, 4, 2], rng, l2=0.01)
        X = rng.normal(size=(6, 3))
        y = rng.integers(0, 2, size=6)
        _, analiticos = rede.perda_e_gradientes(X, y)
    
        h = 1e-5
        for parametro, analitico in zip(rede.parametros(), analiticos, strict=True):
            numerico = np.zeros_like(parametro)
            for indice in np.ndindex(parametro.shape):
                original = parametro[indice]
                parametro[indice] = original + h
                mais, _ = rede.perda_e_gradientes(X, y)
                parametro[indice] = original - h
                menos, _ = rede.perda_e_gradientes(X, y)
                parametro[indice] = original
                numerico[indice] = (mais - menos) / (2 * h)
            assert np.allclose(analitico, numerico, rtol=1e-4, atol=1e-7)
    
    
    def test_otimizador_passo_simples_e_momento() -> None:
        p: Array = np.array([1.0])
        sgd = SGDMomento([p], taxa=0.1, momento=0.0)
        sgd.passo([np.array([2.0])])
        assert np.allclose(p, [0.8])
    
        q: Array = np.array([1.0])
        com_momento = SGDMomento([q], taxa=0.1, momento=0.5)
        com_momento.passo([np.array([2.0])])
        com_momento.passo([np.array([2.0])])
        assert np.allclose(q, [1.0 - 0.2 - 0.3])
    
    
    def test_a_perda_cai_durante_o_treino() -> None:
        rng = np.random.default_rng(0)
        X, y = espiral(60, 3, 0.2, rng)
        rede = RedeDensa([2, 32, 3], rng)
        historico = treinar(rede, X, y, epocas=200)
        assert historico.perdas[-1] < 0.5 * historico.perdas[0]
    
    
    def test_aprende_o_ou_exclusivo() -> None:
        rng = np.random.default_rng(0)
        X, y = ou_exclusivo(300, rng)
        Xt, Xv, yt, yv = dividir(X, y, 0.2, rng)
        rede = RedeDensa([2, 8, 2], rng, l2=1e-4)
        treinar(rede, Xt, yt, epocas=500)
        assert acuracia(rede, Xv, yv) >= 0.95
    
    
    def test_resolve_a_espiral_em_dados_novos() -> None:
        rng = np.random.default_rng(0)
        X, y = espiral(100, 3, 0.2, rng)
        Xt, Xv, yt, yv = dividir(X, y, 0.2, rng)
        rede = RedeDensa([2, 64, 64, 3], rng, l2=1e-4)
        treinar(rede, Xt, yt, epocas=1000)
        assert acuracia(rede, Xt, yt) > 0.97
        assert acuracia(rede, Xv, yv) > 0.95
    
    
    def test_mesma_semente_mesmo_resultado() -> None:
        def rodar() -> float:
            rng = np.random.default_rng(5)
            X, y = espiral(40, 3, 0.2, rng)
            rede = RedeDensa([2, 16, 3], rng)
            return treinar(rede, X, y, epocas=50).perdas[-1]
    
        assert rodar() == rodar()
    
    projetos/rede_neural/tests/test_dados.py
    import numpy as np
    
    from rede_neural.dados import dividir, espiral, ou_exclusivo
    
    
    def test_espiral_formas_e_classes() -> None:
        X, y = espiral(50, 3, 0.2, np.random.default_rng(0))
        assert X.shape == (150, 2) and y.shape == (150,)
        assert np.bincount(y).tolist() == [50, 50, 50]
    
    
    def test_ou_exclusivo_rotulos() -> None:
        X, y = ou_exclusivo(300, np.random.default_rng(1))
        esperado = ((X[:, 0] > 0) != (X[:, 1] > 0)).astype(int)
        assert np.array_equal(y, esperado)
        assert set(np.unique(y)) == {0, 1}
    
    
    def test_dividir_e_disjunto_e_embaralha() -> None:
        X, y = espiral(50, 3, 0.2, np.random.default_rng(0))
        Xt, Xv, yt, yv = dividir(X, y, 0.2, np.random.default_rng(2))
        assert len(Xt) == 120 and len(Xv) == 30
        assert len(np.unique(yv)) > 1  # o teste não é só de uma classe, porque embaralhou antes
    
    projetos/rede_neural/tests/test_cli.py
    import pytest
    
    from rede_neural.cli import main
    
    
    def test_xor_pela_linha_de_comando(capsys: pytest.CaptureFixture[str]) -> None:
        assert main(["xor", "--epocas", "300"]) == 0
        saida = capsys.readouterr().out
        assert "acurácia no teste" in saida
        assert "época" in saida
    

    Rodar

    Terminal
    cd projetos/rede_neural
    uv sync
    uv run pytest
    uv run rede espiral
    
    Saída
    ............                                                             [100%]
    12 passed
    
    Treino na espiral (execução real)
    época   200  perda 0.0558  acurácia de treino 0.996
    época   400  perda 0.0445  acurácia de treino 0.996
    época   600  perda 0.0407  acurácia de treino 0.996
    época   800  perda 0.0387  acurácia de treino 0.996
    época  1000  perda 0.0374  acurácia de treino 0.996
    acurácia no treino: 0.996
    acurácia no teste:  0.983
    

    E no ou exclusivo, que uma reta jamais resolve:

    Terminal
    uv run rede xor
    
    Ou exclusivo (execução real)
    época   100  perda 0.0994  acurácia de treino 0.981
    época   200  perda 0.0707  acurácia de treino 0.981
    época   300  perda 0.0603  acurácia de treino 0.988
    época   400  perda 0.0547  acurácia de treino 0.988
    época   500  perda 0.0511  acurácia de treino 0.994
    acurácia no treino: 0.994
    acurácia no teste:  1.000
    

    A acurácia no teste (98,3%) é a que importa, porque são pontos que a rede nunca viu. Treino e teste próximos indicam que ela generalizou, em vez de decorar. Eu não escolhi a semente 0 por sorte: com estes parâmetros (duas camadas ocultas de 64 neurônios, taxa 0,1, momento 0,9, 1000 épocas), testei seis sementes diferentes e todas passaram de 98% no teste.

    Ver o que a rede aprendeu

    O fronteira.py classifica todos os pontos de uma malha do plano de uma vez (o meshgrid do capítulo 27, mais uma única chamada vetorizada a prever) e pinta cada região com a classe prevista. Precisa do matplotlib: uv run --with matplotlib python fronteira.py:

    projetos/rede_neural/fronteira.py
    """Desenha a fronteira de decisão que a rede aprendeu na espiral.
    
    Precisa do matplotlib, que não é dependência do projeto:
        uv run --with matplotlib python fronteira.py
    """
    
    import matplotlib
    
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    import numpy as np
    
    from rede_neural.dados import dividir, espiral
    from rede_neural.rede import RedeDensa
    from rede_neural.treino import treinar
    
    rng = np.random.default_rng(0)
    X, y = espiral(100, 3, 0.2, rng)
    X_treino, X_teste, y_treino, y_teste = dividir(X, y, 0.2, rng)
    rede = RedeDensa([2, 64, 64, 3], rng, l2=1e-4)
    treinar(rede, X_treino, y_treino, epocas=1000)
    
    # Uma malha de pontos cobrindo o plano: a rede classifica todos de uma vez (vetorizado).
    xs = np.linspace(-1.2, 1.2, 300)
    malha_x, malha_y = np.meshgrid(xs, xs)
    pontos = np.column_stack([malha_x.ravel(), malha_y.ravel()])
    classes = rede.prever(pontos).reshape(malha_x.shape)
    
    fig, ax = plt.subplots(figsize=(5, 5), dpi=100)
    ax.contourf(malha_x, malha_y, classes, alpha=0.35, levels=[-0.5, 0.5, 1.5, 2.5])
    ax.scatter(X_treino[:, 0], X_treino[:, 1], c=y_treino, s=10, edgecolors="none")
    ax.scatter(X_teste[:, 0], X_teste[:, 1], c=y_teste, s=28, edgecolors="black", linewidths=0.6)
    ax.set_title("Fronteira aprendida (pontos com borda: teste)")
    ax.set_aspect("equal")
    fig.savefig("fronteira.png")
    
    As regiões que a rede aprendeu para as três classes. Os pontos com borda preta são de teste, que a rede nunca viu durante o treino.
    As regiões que a rede aprendeu para as três classes. Os pontos com borda preta são de teste, que a rede nunca viu durante o treino.

    A fronteira acompanha o enrolar dos braços, e os pontos de teste caem, em quase todos os casos, na região da própria cor.

    O que este projeto não é

    É um exercício para entender, e não um substituto de um framework. Ele só tem camadas densas, e a retropropagação foi escrita à mão para esta arquitetura. Para qualquer outra, o PyTorch e o JAX derivam o gradiente automaticamente (e rodam em GPU), e é por isso que ninguém escreve isso à mão em produção. A vantagem de ter escrito uma vez é que, quando um treino não converge, você sabe onde olhar: o gradiente, a escala da inicialização, a taxa de aprendizado.

    Desafios

    1. Adam. Troque o otimizador pelo Adam (médias móveis do gradiente e do seu quadrado) e compare a perda por época com o momento.
    2. Mini-lotes. O treinar já aceita tamanho_lote. Compare a convergência com lotes de 32 e com o lote completo, em tempo e em perda.
    3. Dropout. Acrescente a regularização por dropout (zerar aleatoriamente uma fração dos neurônios no treino) e verifique o gradiente de novo: o teste ainda passa?
    4. Parada antecipada. Pare o treino quando a perda no conjunto de validação deixar de cair.
    5. Comparar com o scikit-learn. Treine um MLPClassifier (capítulo 40) nos mesmos dados e compare as acurácias. A sua rede chega perto?

    O que eu aprendi com este tipo de projeto

    Escrever a retropropagação uma vez desmistifica o aprendizado profundo, e a verificação do gradiente é o hábito que mais evita horas de depuração. Em qualquer código numérico escrito à mão, eu comparo o resultado com uma versão lenta e obviamente correta antes de confiar nele. É o mesmo princípio dos testes dos outros dois projetos.