Pular para conteúdo

2. Perceptron

Como reproduzir

Todo o código está em code/. Um único script roda os dois exercícios em ordem, com um único gerador rng = np.random.default_rng(42), grava as figuras em figures/ e os números em code/results.json. Todos os números deste relatório vêm desse arquivo. Rodei o script duas vezes e o results.json saiu idêntico byte a byte.

python3 -m venv env && source ./env/bin/activate
python3 -m pip install -r requirements.txt
python docs/exercises/perceptron/code/run_all.py
Código — code/run_all.py e code/common.py (geração dos dados e figuras)
docs/exercises/perceptron/code/run_all.py
"""Roda os dois exercícios em ordem, com um único gerador aleatório, e salva os números.

Uso, a partir da raiz do repositório:

    python docs/exercises/perceptron/code/run_all.py

As figuras vão para docs/exercises/perceptron/figures/ e os números para code/results.json.
"""

import json

import numpy as np

import ex1_separable
import ex2_overlapping
from common import CODE_DIR, SEED


def main():
    rng = np.random.default_rng(SEED)  # o mesmo gerador em todo o relatório
    results = {
        "exercise1": ex1_separable.run(rng),
        "exercise2": ex2_overlapping.run(rng),
    }
    (CODE_DIR / "results.json").write_text(json.dumps(results, indent=2, ensure_ascii=False) + "\n")
    print(f"Números salvos em {CODE_DIR / 'results.json'}")


if __name__ == "__main__":
    main()
docs/exercises/perceptron/code/common.py
"""Configuração compartilhada pelos dois exercícios: semente, pastas, dados e figuras."""

from pathlib import Path

import matplotlib

matplotlib.use("Agg")  # gera os PNG sem abrir janela
import matplotlib.pyplot as plt  # noqa: E402
import numpy as np  # noqa: E402

from perceptron import predict  # noqa: E402

SEED = 42
CODE_DIR = Path(__file__).resolve().parent
FIGURES = CODE_DIR.parent / "figures"

N_PER_CLASS = 1000
CLASS_COLORS = ["tab:blue", "tab:orange"]


def save(fig, name):
    """Salva a figura em figures/ e libera a memória."""
    FIGURES.mkdir(parents=True, exist_ok=True)
    fig.savefig(FIGURES / name, dpi=150, bbox_inches="tight")
    plt.close(fig)


def generate(rng, means, cov):
    """Amostra 1000 pontos por classe de N(means[k], cov).

    A ordem fica a da geração: 1000 pontos da classe 0 e depois 1000 da classe 1. O perceptron
    visita as amostras nessa ordem em toda época (o enunciado não pede embaralhamento).
    """
    # method="cholesky": a decomposição de Cholesky é única, então o resultado não muda de máquina.
    X = np.vstack([rng.multivariate_normal(m, cov, size=N_PER_CLASS, method="cholesky") for m in means])
    y = np.repeat([0, 1], N_PER_CLASS)
    return X, y


def plot_points(ax, X, y):
    """Desenha os pontos das duas classes, uma cor por classe."""
    for c in (0, 1):
        ax.scatter(*X[y == c].T, s=8, alpha=0.5, color=CLASS_COLORS[c], label=f"Classe {c}")
    ax.set_xlabel("$x_1$")
    ax.set_ylabel("$x_2$")


def scatter_figure(X, y, title, name):
    """Figuras 1 e 4: dispersão dos 2000 pontos, uma cor por classe."""
    fig, ax = plt.subplots(figsize=(7, 6), layout="constrained")
    plot_points(ax, X, y)
    ax.set_aspect("equal", adjustable="datalim")
    ax.set_title(title)
    ax.legend(loc="upper left")
    save(fig, name)


def draw_boundary(ax, w, b, **style):
    """Desenha a reta w·x + b = 0 inteira, sem mudar os limites dos eixos.

    p = -b w / ||w||² é o ponto da reta mais perto da origem; (-w2, w1) é a direção da reta.
    """
    p = -b * w / (w @ w)
    return ax.axline(p, p + np.array([-w[1], w[0]]), **style)


def shade_regions(ax, w, b, xlim, ylim):
    """Pinta de leve a região que o modelo prevê como classe 0 e a que prevê como classe 1."""
    xx, yy = np.meshgrid(np.linspace(*xlim, 400), np.linspace(*ylim, 400))
    regions = predict(np.column_stack([xx.ravel(), yy.ravel()]), w, b).reshape(xx.shape)
    ax.contourf(xx, yy, regions, levels=[-0.5, 0.5, 1.5], colors=CLASS_COLORS, alpha=0.08)


def plot_predictions(ax, X, y, w, b):
    """Desenha os pontos com a cor da classe verdadeira: bolinha se (w, b) acerta, x se erra."""
    wrong = predict(X, w, b) != y
    for c in (0, 1):
        ok, bad = (y == c) & ~wrong, (y == c) & wrong
        ax.scatter(*X[ok].T, s=8, alpha=0.5, color=CLASS_COLORS[c], label=f"Classe {c}, acerto")
        ax.scatter(*X[bad].T, marker="x", s=18, lw=0.9, color=CLASS_COLORS[c],
                   label=f"Classe {c}, erro ({bad.sum()})")
    ax.set_xlabel("$x_1$")
    ax.set_ylabel("$x_2$")


def limits(X, pad=0.8):
    """Limites dos eixos que cobrem todos os pontos, com uma margem."""
    return ((X[:, 0].min() - pad, X[:, 0].max() + pad),
            (X[:, 1].min() - pad, X[:, 1].max() + pad))

Abordagem e desafios

Abordagem. O modelo fica num único arquivo, code/perceptron.py, escrito só com NumPy. Ele tem a ativação step, a predição predict, a acurácia accuracy, a inicialização init_weights e o laço de treino train. Os dois exercícios chamam a mesma função train. O Exercise 2 só passa pocket=True, que liga a cópia do melhor \((\mathbf{w}, b)\). Nenhuma biblioteca fornece o modelo: não há scikit-learn neste exercício.

Os sorteios acontecem sempre na mesma ordem: dados do Exercise 1, \(\mathbf{w}\) inicial do Exercise 1, dados do Exercise 2, \(\mathbf{w}\) inicial do Exercise 2 e, por último, a permutação do experimento extra do item 2D.

Desafios.

  • A ordem das amostras. O enunciado diz "para cada amostra" e não pede embaralhamento. Uso a ordem da geração: 1000 pontos da classe 0 e depois 1000 da classe 1, em toda época. Essa escolha muda muito o resultado final do Exercise 2 (50.15% nesta ordem, 63.95% com a ordem embaralhada). Por isso, o item 2D mostra as duas ordens.
  • "Sem mudar mais nada" no item 1D. A execução com \(\eta = 1.0\) precisa do mesmo \(\mathbf{w}\) inicial. Sorteio \(\mathbf{w}_0\) uma única vez e passo o mesmo vetor às duas execuções. Um segundo sorteio mudaria a inicialização e a comparação perderia o sentido.
  • Custo do pocket. O pocket mede a acurácia no dataset completo depois de cada atualização. Isso custa 2000 predições por atualização. O custo é pequeno aqui, porque o Exercise 2 faz só 289 atualizações em 100 épocas.
  • Figura 5 legível. Os pesos finais erram 997 pontos. Um X preto em cada erro cobria as cores das classes. A solução: o ponto certo aparece como bolinha e o ponto errado como "x", os dois na cor da classe verdadeira.

Exercise 1

Dados separáveis: o caso para o qual o perceptron foi projetado.

Código — code/ex1_separable.py
docs/exercises/perceptron/code/ex1_separable.py
"""Exercise 1 — Dados separáveis: o caso para o qual o perceptron foi projetado.

Gera as duas classes, treina o perceptron com eta = 0.01 e com eta = 1.0 (mesmo w inicial)
e confere numericamente o argumento do item D sobre a partida de w = 0.
Produz as Figuras 1, 2 e 3 e uma figura auxiliar do item D.
"""

import matplotlib.pyplot as plt
import numpy as np

from common import (draw_boundary, generate, limits, plot_points, plot_predictions, save,
                    scatter_figure, shade_regions)
from perceptron import init_weights, train

# Parâmetros do enunciado.
MEANS = [[1.5, 1.5], [5.0, 5.0]]
COV = [[0.5, 0.0], [0.0, 0.5]]
ETA = 0.01
ETA_BIG = 1.0


def direction(w):
    """Vetor unitário w / ||w||: a direção normal à fronteira, sem a escala."""
    return w / np.linalg.norm(w)


def angle_deg(u, v):
    """Ângulo, em graus, entre as direções de u e v."""
    cos = np.clip(direction(u) @ direction(v), -1.0, 1.0)
    return float(np.degrees(np.arccos(cos)))


def figure2(X, y, run):
    """Figura 2: a fronteira w·x + b = 0 sobre os pontos, com os mal classificados marcados."""
    fig, ax = plt.subplots(figsize=(7, 6), layout="constrained")
    xlim, ylim = limits(X)
    shade_regions(ax, run["w"], run["b"], xlim, ylim)
    plot_predictions(ax, X, y, run["w"], run["b"])
    draw_boundary(ax, run["w"], run["b"], color="black", lw=1.8, label="Fronteira $w \\cdot x + b = 0$")
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    ax.set_aspect("equal")
    ax.set_title(f"Figura 2 — Fronteira de decisão ($\\eta$ = {ETA}), acurácia = {100 * run['accuracy']:.2f}%")
    ax.legend(loc="upper left")
    save(fig, "fig2_boundary.png")


def figure3(run):
    """Figura 3: acurácia × época. As barras cinzas mostram as atualizações feitas em cada época."""
    hist = run["history"]
    epochs = np.arange(1, run["epochs"] + 1)
    fig, ax = plt.subplots(figsize=(8.5, 4.8), layout="constrained")

    ax2 = ax.twinx()  # eixo da direita: número de atualizações
    ax2.bar(epochs, hist["updates"], color="gray", alpha=0.35, label="Atualizações na época")
    ax2.set_ylabel("Atualizações na época")
    ax2.set_ylim(0, max(hist["updates"]) * 2.5)  # barras baixas, para não cobrir a curva

    ax.plot(epochs, [100 * a for a in hist["accuracy"]], "o-", color="tab:purple", lw=2,
            label="Acurácia no dataset completo")
    ax.set_zorder(ax2.get_zorder() + 1)  # curva na frente das barras
    ax.patch.set_visible(False)
    ax.set_xlabel("Época")
    ax.set_ylabel("Acurácia (%)")
    ax.set_xticks(epochs[::2] if len(epochs) > 15 else epochs)
    h1, l1 = ax.get_legend_handles_labels()
    h2, l2 = ax2.get_legend_handles_labels()
    ax.legend(h1 + h2, l1 + l2, loc="lower right")
    ax.set_title(f"Figura 3 — Acurácia × época ($\\eta$ = {ETA})")
    save(fig, "fig3_accuracy.png")


def figure_eta(X, y, small, big):
    """Figura auxiliar do item D: as fronteiras de eta = 0.01 e eta = 1.0, com zoom no vão entre as nuvens."""
    fig, ax = plt.subplots(figsize=(7, 6), layout="constrained")
    plot_points(ax, X, y)
    draw_boundary(ax, small["w"], small["b"], color="black", lw=1.8, label=f"$\\eta$ = {ETA}")
    draw_boundary(ax, big["w"], big["b"], color="crimson", lw=1.8, ls="--", label=f"$\\eta$ = {ETA_BIG}")
    ax.set_xlim(1.0, 5.5)
    ax.set_ylim(1.0, 5.5)
    ax.set_aspect("equal")
    ax.set_title("Figura auxiliar (item D) — Fronteiras com $\\eta$ = 0.01 e $\\eta$ = 1.0 (zoom)")
    ax.legend(loc="upper left")
    save(fig, "fig_d_eta.png")


def summary(run):
    """Números de uma execução, prontos para o results.json."""
    return {
        "w": run["w"].tolist(),
        "b": run["b"],
        "direction": direction(run["w"]).tolist(),
        "offset": float(-run["b"] / np.linalg.norm(run["w"])),  # distância da reta até a origem
        "epochs": run["epochs"],
        "converged": run["converged"],
        "total_updates": run["total_updates"],
        "accuracy": run["accuracy"],
        "updates_per_epoch": run["history"]["updates"],
        "accuracy_per_epoch": run["history"]["accuracy"],
    }


def run(rng):
    # A — gera os dados e desenha a Figura 1.
    X, y = generate(rng, MEANS, COV)
    scatter_figure(X, y, "Figura 1 — Dados separáveis: 1000 pontos por classe", "fig1_data.png")

    # B/C — um único sorteio de w inicial, reutilizado pelas duas taxas de aprendizado.
    w0, b0 = init_weights(rng)
    small = train(X, y, w0, b0, ETA)
    figure2(X, y, small)
    figure3(small)

    # D — mesma execução, só com eta = 1.0: mesmos dados, mesma ordem, mesmo w0.
    big = train(X, y, w0, b0, ETA_BIG)
    figure_eta(X, y, small, big)

    # D — partida do zero: o argumento algébrico diz que w e b escalam com eta2 / eta1.
    zero_small = train(X, y, np.zeros(2), 0.0, ETA)
    zero_big = train(X, y, np.zeros(2), 0.0, ETA_BIG)
    ratio = ETA_BIG / ETA

    print("Exercise 1")
    for name, r in [("eta = 0.01", small), ("eta = 1.0", big)]:
        print(f"  {name}: w = {r['w']}, b = {r['b']:.4f}, épocas = {r['epochs']}, "
              f"acurácia = {100 * r['accuracy']:.2f}%")

    return {
        "w0": w0.tolist(),
        "b0": b0,
        "eta_small": summary(small),
        "eta_big": summary(big),
        "angle_between_directions_deg": angle_deg(small["w"], big["w"]),
        "zero_start": {
            "eta_small": summary(zero_small),
            "eta_big": summary(zero_big),
            "expected_ratio": ratio,
            # Quanto os pesos de eta = 1.0 diferem de (eta2/eta1) × pesos de eta = 0.01.
            "max_abs_diff_w": float(np.abs(zero_big["w"] - ratio * zero_small["w"]).max()),
            "abs_diff_b": abs(zero_big["b"] - ratio * zero_small["b"]),
            "same_epochs": zero_small["epochs"] == zero_big["epochs"],
            "angle_deg": angle_deg(zero_small["w"], zero_big["w"]),
        },
    }

A — Generate the data

A função generate (em code/common.py) amostra 1000 pontos por classe com rng.multivariate_normal(média, covariância, method="cholesky"):

  • Classe 0: média \([1.5,\ 1.5]\), covariância \(\begin{bmatrix} 0.5 & 0 \\ 0 & 0.5 \end{bmatrix}\);
  • Classe 1: média \([5,\ 5]\), covariância \(\begin{bmatrix} 0.5 & 0 \\ 0 & 0.5 \end{bmatrix}\).

A decomposição de Cholesky é única, então o resultado não depende da biblioteca de álgebra linear da máquina. São 2000 pontos no total.

Figura 1

Figura 1 — Os 2000 pontos do Exercise 1, uma cor por classe.

B — Implement the perceptron

docs/exercises/perceptron/code/perceptron.py
"""Perceptron de camada única, escrito do zero com NumPy.

Esta é a única implementação do modelo no relatório. Os dois exercícios chamam a mesma
função train(); o Exercise 2 só liga o rastreio do pocket (pocket=True).
"""

import numpy as np


def step(z):
    """Ativação degrau: 1 se z >= 0 e 0 caso contrário. Aceita um número ou um vetor."""
    return np.where(z >= 0, 1, 0)


def predict(X, w, b):
    """Predição y_hat = step(w·x + b) para cada linha de X."""
    return step(X @ w + b)


def accuracy(X, y, w, b):
    """Fração de amostras de X com predição igual ao rótulo."""
    return float(np.mean(predict(X, w, b) == y))


def init_weights(rng):
    """w ~ N(0, 0.01²) em cada coordenada e b = 0, como pede o enunciado. Nunca w = 0."""
    return rng.normal(0, 0.01, size=2), 0.0


def train(X, y, w0, b0, eta, max_epochs=100, pocket=False):
    """Treina o perceptron com a regra dirigida pelo erro, para rótulos em {0, 1}.

    Para cada amostra (x, y), na ordem de X:
        erro = y - y_hat            (0 se acertou; +1 ou -1 se errou)
        w <- w + eta * erro * x
        b <- b + eta * erro

    Para quando uma época inteira não faz nenhuma atualização, ou depois de max_epochs épocas.
    Com pocket=True, guarda uma cópia de (w, b) toda vez que uma atualização leva a acurácia no
    dataset completo acima da melhor já vista. É a única diferença entre os dois modos.
    """
    w, b = np.array(w0, dtype=float), float(b0)  # cópias: não altera o w0 de quem chamou

    history = {"accuracy": [], "updates": [], "pocket_accuracy": []}
    best = {"w": w.copy(), "b": b, "accuracy": accuracy(X, y, w, b), "epoch": 0, "update": 0}
    total_updates = 0

    for epoch in range(1, max_epochs + 1):
        updates = 0
        for xi, yi in zip(X, y):
            error = yi - step(xi @ w + b)
            if error == 0:  # acertou: a regra não muda nada
                continue
            w += eta * error * xi
            b += eta * error
            updates += 1
            total_updates += 1

            if pocket:  # algoritmo pocket: guarda o melhor (w, b) visto até agora
                acc = accuracy(X, y, w, b)
                if acc > best["accuracy"]:
                    best = {"w": w.copy(), "b": b, "accuracy": acc,
                            "epoch": epoch, "update": total_updates}

        # Acurácia no dataset completo depois de cada época.
        history["accuracy"].append(accuracy(X, y, w, b))
        history["updates"].append(updates)
        if pocket:
            history["pocket_accuracy"].append(best["accuracy"])
        if updates == 0:  # uma passagem sem erro: convergiu
            break

    result = {
        "w": w, "b": b,
        "accuracy": accuracy(X, y, w, b),
        "epochs": len(history["updates"]),
        "converged": history["updates"][-1] == 0,
        "total_updates": total_updates,
        "history": history,
    }
    if pocket:
        result["pocket"] = best
    return result

As decisões pedidas pelo enunciado, e onde cada uma está no código:

  • Predição: \(\hat{y} = \text{step}(\mathbf{w} \cdot \mathbf{x} + b)\), com \(\text{step}(z) = 1\) se \(z \geq 0\) e \(0\) caso contrário (step e predict).
  • Regra dirigida pelo erro, para rótulos 0/1: o erro \(e = y - \hat{y}\) vale \(0\) num acerto, \(+1\) num falso negativo e \(-1\) num falso positivo. A atualização é \(\mathbf{w} \leftarrow \mathbf{w} + \eta\, e\, \mathbf{x}\) e \(b \leftarrow b + \eta\, e\). Um acerto não muda nada (if error == 0: continue). A forma \(\mathbf{w} + \eta\, y\, \mathbf{x}\) não serve aqui: com \(y = 0\) ela nunca corrigiria um falso positivo.
  • Inicialização: \(\mathbf{w}\) = rng.normal(0, 0.01, size=2) e \(b = 0\) (init_weights). O sorteio deu \(\mathbf{w}_0 = [0.00253,\ 0.00895]\), com \(\lVert \mathbf{w}_0 \rVert = 0.0093\).
  • Taxa de aprendizado: \(\eta = 0.01\).
  • Parada: o laço para depois de uma época sem nenhuma atualização, ou depois de 100 épocas. A acurácia no dataset completo é registrada ao fim de cada época (history["accuracy"]).

C — Train and measure

Resultado com \(\eta = 0.01\):

  • \(\mathbf{w}\) final \(= [0.05050,\ 0.02887]\);
  • \(b\) final \(= -0.25\);
  • 26 épocas: 25 épocas com atualização e a 26ª sem nenhuma, que dispara a parada;
  • acurácia final = 100.00% (2000 / 2000);
  • 73 atualizações no total, em 52 000 visitas a amostras (0.14%).

Figura 2

Figura 2 — Fronteira \(\mathbf{w} \cdot \mathbf{x} + b = 0\) sobre os pontos. O fundo mostra a classe prevista. Pontos mal classificados apareceriam como "x"; não há nenhum.

Figura 3

Figura 3 — Acurácia no dataset completo ao fim de cada época (curva) e número de atualizações na época (barras cinzas).

A acurácia chega a 100% na época 25 e fica lá. A época 26 confirma: nenhuma amostra gera erro.

D — Analysis

Por que dados separáveis convergem rápido? A regra só age num erro. Num acerto, \(y - \hat{y} = 0\), e \(\mathbf{w}\) e \(b\) não mudam. Num erro, a atualização empurra a reta para o lado certo daquele ponto: o escore \(\mathbf{w} \cdot \mathbf{x} + b\) do ponto muda \(\eta\,(\lVert \mathbf{x} \rVert^2 + 1)\) na direção do rótulo verdadeiro. Com dados separáveis, existe uma reta que acerta todos os pontos. Quando os pesos chegam a qualquer reta assim, o erro é zero em todas as amostras e nenhuma atualização acontece mais. Esse estado é um ponto fixo: o laço para.

O que acontece com o número de atualizações por época. Ele é pequeno desde o início e cai a zero:

Épocas Atualizações por época
1 a 12 3, 3, 4, 4, 3, 4, 3, 4, 2, 4, 2, 4
13 a 24 2, 3, 3, 3, 2, 3, 3, 2, 3, 3, 2, 3
25 1
26 0 — parada

Das 2000 amostras de cada época, no máximo 4 geram atualização. Separei cada época em duas metades: primeiro os 1000 pontos da classe 0, depois os 1000 da classe 1. Nas épocas 1 a 24, a metade da classe 0 faz de 1 a 3 atualizações, e a metade da classe 1 faz sempre exatamente 1. Essa última atualização empurra a reta em direção à classe 0. Por isso, no fim de cada uma dessas épocas, todos os erros são falsos positivos (pontos da classe 0 previstos como 1), e não há nenhum falso negativo. O zigue-zague da Figura 3 é o tamanho desse empurrão, que varia de época para época: no fim da época 8 sobram 289 falsos positivos (85.55%), e no fim da época 9 sobram 799 (60.05%). Na época 25, a metade da classe 1 não erra mais, e a reta fica no vão entre as nuvens.

Por que 26 épocas, e não 1 ou 2? O limite é o \(b\). A reta final fica a \(-b / \lVert \mathbf{w} \rVert = 4.30\) da origem. Com \(\lVert \mathbf{w} \rVert = 0.058\), isso pede \(b = -0.25\). Cada erro move \(b\) só \(\eta = 0.01\), enquanto move \(\mathbf{w}\) \(\eta\,\lVert \mathbf{x} \rVert\), cerca de 2 a 7 vezes mais. E os erros das duas classes empurram \(b\) em sentidos opostos: em cada época, a metade da classe 0 tira de 0.01 a 0.03 de \(b\), e a metade da classe 1 devolve 0.01. O saldo foi de 49 erros na classe 0 e 24 na classe 1: 25 passos líquidos de \(-0.01\), de 0 a 2 por época. Esse mesmo efeito, \(b\) lento e \(\mathbf{w}\) rápido, é o que quebra o Exercise 2.

Re-execução com \(\eta = 1.0\) (mesmos dados, mesma ordem, mesmo \(\mathbf{w}_0\)):

\(\eta = 0.01\) \(\eta = 1.0\)
\(\mathbf{w}\) final \([0.05050,\ 0.02887]\) \([5.87062,\ 3.35924]\)
\(b\) final \(-0.25\) \(-31.0\)
Épocas 26 37
Atualizações 73 101
Acurácia final 100.00% 100.00%
Direção \(\mathbf{w} / \lVert \mathbf{w} \rVert\) \([0.86812,\ 0.49635]\) \([0.86795,\ 0.49665]\)
Distância da reta até a origem, \(-b / \lVert \mathbf{w} \rVert\) 4.298 4.583

Figura auxiliar do item D

Figura auxiliar (item D) — As duas fronteiras, com zoom no vão entre as nuvens.

As duas execuções chegam a 100%, por fronteiras diferentes. Neste sorteio, as direções ficaram quase iguais: o ângulo entre elas é de só 0.02°. A diferença está na posição: as retas são quase paralelas, mas a de \(\eta = 1.0\) fica 0.29 mais longe da origem (4.583 contra 4.298), mais perto da classe 1. O caminho também muda: 37 épocas contra 26.

O que \(\eta\) controla. O degrau só olha o sinal de \(\mathbf{w} \cdot \mathbf{x} + b\). Multiplicar \((\mathbf{w}, b)\) por uma constante positiva não muda nenhuma predição. Então o tamanho de \(\eta\) sozinho não importa. O que importa é o tamanho de \(\eta\,\mathbf{x}\) em relação a \(\mathbf{w}_0\):

  • com \(\eta = 1.0\), cada erro soma um vetor de norma típica 2 (classe 0) a 7 (classe 1) a um \(\mathbf{w}_0\) de norma 0.0093. A inicialização some. Prova disso: esta execução faz as mesmas 101 atualizações e as mesmas 37 épocas da execução que parte de \(\mathbf{w} = \mathbf{0}\) com \(\eta = 1.0\). Os pesos finais diferem exatamente por \(\mathbf{w}_0\): \([5.87062,\ 3.35924] - [5.86808,\ 3.35029] = [0.00253,\ 0.00895] = \mathbf{w}_0\);
  • com \(\eta = 0.01\), cada erro soma um vetor de norma típica 0.02 a 0.07, do mesmo tamanho que \(\mathbf{w}_0\). A inicialização muda quais pontos erram nas primeiras épocas, e isso muda todo o caminho: 73 atualizações e uma reta diferente.

Resumo: \(\eta\) controla o peso relativo da inicialização. Formalmente, se \(\mathbf{w}_t = \mathbf{w}_0 + \eta\,\mathbf{u}_t\) e \(b_t = \eta\,c_t\), então \(\text{step}(\mathbf{w}_t \cdot \mathbf{x} + b_t) = \text{step}\big((\mathbf{w}_0/\eta + \mathbf{u}_t) \cdot \mathbf{x} + c_t\big)\). Treinar com \(\eta\) a partir de \(\mathbf{w}_0\) é o mesmo que treinar com passo 1 a partir de \(\mathbf{w}_0 / \eta\). Com \(\eta = 0.01\), a partida efetiva é \(100\,\mathbf{w}_0\), de norma 0.93.

Partida de \(\mathbf{w} = \mathbf{0}\), \(b = 0\). Mostro que duas execuções com \(\eta_1\) e \(\eta_2\) produzem pesos proporcionais em todos os passos. Sejam \((\mathbf{w}_t^{(1)}, b_t^{(1)})\) e \((\mathbf{w}_t^{(2)}, b_t^{(2)})\) os pesos depois de \(t\) amostras visitadas, e \(k = \eta_2 / \eta_1 > 0\). A hipótese de indução é

\[ \mathbf{w}_t^{(2)} = k\,\mathbf{w}_t^{(1)}, \qquad b_t^{(2)} = k\,b_t^{(1)}. \]

Base (\(t = 0\)): \(\mathbf{w}_0^{(1)} = \mathbf{w}_0^{(2)} = \mathbf{0}\) e \(b_0^{(1)} = b_0^{(2)} = 0\). Como \(\mathbf{0} = k \cdot \mathbf{0}\), a hipótese vale.

Passo: na amostra \(t + 1\), com entrada \(\mathbf{x}\) e rótulo \(y\),

\[ \begin{aligned} \hat{y}^{(2)} &= \text{step}\big(\mathbf{w}_t^{(2)} \cdot \mathbf{x} + b_t^{(2)}\big) = \text{step}\big(k\,(\mathbf{w}_t^{(1)} \cdot \mathbf{x} + b_t^{(1)})\big) \\ &= \text{step}\big(\mathbf{w}_t^{(1)} \cdot \mathbf{x} + b_t^{(1)}\big) = \hat{y}^{(1)}, \end{aligned} \]

porque \(k > 0\) não muda o sinal (\(kz \geq 0 \iff z \geq 0\), inclusive em \(z = 0\)). Então o erro \(e = y - \hat{y}\) é o mesmo nas duas execuções, e

\[ \begin{aligned} \mathbf{w}_{t+1}^{(2)} &= k\,\mathbf{w}_t^{(1)} + \eta_2\, e\, \mathbf{x} = k\,\mathbf{w}_t^{(1)} + k\,\eta_1\, e\, \mathbf{x} = k\,\mathbf{w}_{t+1}^{(1)}, \\ b_{t+1}^{(2)} &= k\,b_t^{(1)} + \eta_2\, e = k\,b_t^{(1)} + k\,\eta_1\, e = k\,b_{t+1}^{(1)}. \end{aligned} \]

Consequências:

  • as duas execuções erram exatamente nas mesmas amostras. Então fazem as mesmas atualizações e param na mesma época;
  • os pesos finais diferem só pelo fator \(\eta_2 / \eta_1\);
  • a fronteira \(\{\mathbf{x} : \mathbf{w} \cdot \mathbf{x} + b = 0\}\) não muda quando \((\mathbf{w}, b)\) é multiplicado por \(k > 0\). A reta é a mesma.

Logo, a partir do zero, \(\eta\) não tem efeito algum. É por isso que o item B proíbe essa partida. Na fórmula acima, \(\mathbf{w}_0 / \eta = \mathbf{0}\) para todo \(\eta\): não sobra nada para \(\eta\) controlar.

Conferência numérica. Rodei as duas partidas do zero:

Partida de \(\mathbf{w} = \mathbf{0}\) \(\eta = 0.01\) \(\eta = 1.0\)
\(\mathbf{w}\) final \([0.058681,\ 0.033503]\) \([5.868084,\ 3.350287]\)
\(b\) final \(-0.31\) \(-31.0\)
Épocas 37 37
Atualizações 101 101
Acurácia final 100.00% 100.00%

A razão é exatamente \(\eta_2 / \eta_1 = 100\). A maior diferença entre \(\mathbf{w}^{(2)}\) e \(100\,\mathbf{w}^{(1)}\) é \(2.7 \times 10^{-15}\), só arredondamento de ponto flutuante. O ângulo entre as duas direções é \(0°\).

Exercise 2

Dados sobrepostos: o caso que o perceptron não resolve.

Código — code/ex2_overlapping.py
docs/exercises/perceptron/code/ex2_overlapping.py
"""Exercise 2 — Dados sobrepostos: o caso que o perceptron não resolve.

Treina o mesmo perceptron do Exercise 1 (mesma função train, eta = 0.01, 100 épocas), agora com
o rastreio do pocket ligado. Produz as Figuras 4, 5 e 6. Depois roda as verificações do item D:
onde a fronteira final fica, mais épocas, eta menor e a ordem das amostras.
"""

import matplotlib.pyplot as plt
import numpy as np

from common import (draw_boundary, generate, limits, plot_predictions, save, scatter_figure,
                    shade_regions)
from perceptron import accuracy, init_weights, predict, train

# Parâmetros do enunciado.
MEANS = [[3.0, 3.0], [4.0, 4.0]]
COV = [[1.5, 0.0], [0.0, 1.5]]
ETA = 0.01
MAX_EPOCHS = 100

# Reta de referência: a mediatriz entre as médias, x1 + x2 = 7. Com covariâncias iguais e
# isotrópicas, é a melhor reta para as distribuições do enunciado.
W_REF, B_REF = np.array([1.0, 1.0]), -7.0
CENTER = np.mean(MEANS, axis=0)  # centro da nuvem inteira: (3.5, 3.5)


def position(w, b, X):
    """Onde a reta w·x + b = 0 fica em relação à nuvem de pontos."""
    norm = np.linalg.norm(w)
    return {
        "offset": float(-b / norm),                    # distância da reta até a origem (com sinal)
        "center_distance": float((w @ CENTER + b) / norm),  # > 0: centro da nuvem no lado "classe 1"
        "predicted_1": float(predict(X, w, b).mean()),  # fração de pontos que o modelo chama de 1
        "b_over_norm_w": float(b / norm),
    }


def figure5(X, y, final, pocket):
    """Figura 5: as fronteiras final e pocket, um painel para os erros de cada uma."""
    xlim, ylim = limits(X)
    fig, axes = plt.subplots(1, 2, figsize=(13, 6.2), sharex=True, sharey=True, layout="constrained")
    panels = [(axes[0], final, "Pesos finais"), (axes[1], pocket, "Pesos do pocket")]
    for ax, (w, b, acc), title in panels:
        shade_regions(ax, w, b, xlim, ylim)
        plot_predictions(ax, X, y, w, b)
        draw_boundary(ax, final[0], final[1], color="crimson", lw=2.0, label="Fronteira final")
        draw_boundary(ax, pocket[0], pocket[1], color="black", lw=2.0, ls="--", label="Fronteira do pocket")
        ax.set_xlim(xlim)
        ax.set_ylim(ylim)
        ax.set_aspect("equal")
        ax.set_title(f"{title}: acurácia = {100 * acc:.2f}%")
        ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.1), ncol=3, fontsize=9)
    fig.suptitle("Figura 5 — Fronteiras de decisão final e do pocket")
    save(fig, "fig5_boundaries.png")


def figure6(run):
    """Figura 6: acurácia dos pesos atuais e melhor acurácia até agora (pocket), por época."""
    hist = run["history"]
    epochs = np.arange(1, run["epochs"] + 1)
    fig, ax = plt.subplots(figsize=(9, 4.8), layout="constrained")
    ax.plot(epochs, [100 * a for a in hist["accuracy"]], color="crimson", lw=1.4,
            label="Pesos atuais (fim de cada época)")
    ax.plot(epochs, [100 * a for a in hist["pocket_accuracy"]], color="black", lw=2, ls="--",
            label="Melhor até agora (pocket)")
    ax.axhline(50, color="gray", lw=0.8, ls=":", label="50% (chute)")
    ax.set_ylim(40, 80)
    ax.set_xlabel("Época")
    ax.set_ylabel("Acurácia (%)")
    ax.legend(loc="center right")
    ax.set_title(f"Figura 6 — Acurácia × época ($\\eta$ = {ETA}, dados sobrepostos)")
    save(fig, "fig6_accuracy.png")


def run(rng):
    # A — gera os dados e desenha a Figura 4.
    X, y = generate(rng, MEANS, COV)
    scatter_figure(X, y, "Figura 4 — Dados sobrepostos: 1000 pontos por classe", "fig4_data.png")

    # B — a mesma função do Exercise 1; pocket=True liga a cópia do melhor (w, b).
    w0, b0 = init_weights(rng)
    main = train(X, y, w0, b0, ETA, max_epochs=MAX_EPOCHS, pocket=True)
    best = main["pocket"]

    # C — figuras.
    figure5(X, y, (main["w"], main["b"], main["accuracy"]), (best["w"], best["b"], best["accuracy"]))
    figure6(main)

    # D — o que acontece dentro da última época: treina 99 épocas e depois só a metade da
    # classe 0 da época 100. A ordem das amostras é a mesma, então as atualizações são as mesmas.
    first_99 = train(X, y, w0, b0, ETA, max_epochs=MAX_EPOCHS - 1)
    half = train(X[y == 0], y[y == 0], first_99["w"], first_99["b"], ETA, max_epochs=1)

    # D — mais épocas e eta menor, sem pocket (só interessa o que o laço deixa na mão).
    long = train(X, y, w0, b0, ETA, max_epochs=5 * MAX_EPOCHS)
    small_eta = train(X, y, w0, b0, ETA / 10, max_epochs=MAX_EPOCHS)

    # D — a mesma execução com a ordem das amostras embaralhada uma vez. É o último sorteio do
    # relatório, para não mudar nenhum número acima.
    order = rng.permutation(len(y))
    shuffled = train(X[order], y[order], w0, b0, ETA, max_epochs=MAX_EPOCHS, pocket=True)

    print("Exercise 2")
    print(f"  final: w = {main['w']}, b = {main['b']:.4f}, acurácia = {100 * main['accuracy']:.2f}%")
    print(f"  pocket: w = {best['w']}, b = {best['b']:.4f}, acurácia = {100 * best['accuracy']:.2f}% "
          f"(época {best['epoch']})")

    tail = np.array(long["history"]["accuracy"][-MAX_EPOCHS:])
    return {
        "w0": w0.tolist(),
        "b0": b0,
        "final": {"w": main["w"].tolist(), "b": main["b"], "accuracy": main["accuracy"],
                  **position(main["w"], main["b"], X)},
        "pocket": {"w": best["w"].tolist(), "b": best["b"], "accuracy": best["accuracy"],
                   "epoch": best["epoch"], "update": best["update"],
                   **position(best["w"], best["b"], X)},
        "reference_line": {"w": W_REF.tolist(), "b": B_REF, "accuracy": accuracy(X, y, W_REF, B_REF),
                           **position(W_REF, B_REF, X)},
        "epochs": main["epochs"],
        "converged": main["converged"],
        "total_updates": main["total_updates"],
        "updates_per_epoch": main["history"]["updates"],
        "min_updates_per_epoch": min(main["history"]["updates"]),
        "accuracy_per_epoch": main["history"]["accuracy"],
        "accuracy_range": [min(main["history"]["accuracy"]), max(main["history"]["accuracy"])],
        "mean_norm_x": float(np.linalg.norm(X, axis=1).mean()),
        "mid_last_epoch": {"accuracy": accuracy(X, y, half["w"], half["b"]),
                           **position(half["w"], half["b"], X)},
        "more_epochs": {"epochs": long["epochs"], "accuracy": long["accuracy"],
                        "last_100_range": [float(tail.min()), float(tail.max())],
                        "updates_last_epoch": long["history"]["updates"][-1],
                        **position(long["w"], long["b"], X)},
        "smaller_eta": {"eta": ETA / 10, "accuracy": small_eta["accuracy"],
                        "accuracy_range": [min(small_eta["history"]["accuracy"]),
                                           max(small_eta["history"]["accuracy"])],
                        **position(small_eta["w"], small_eta["b"], X)},
        "shuffled_order": {"accuracy": shuffled["accuracy"],
                           "pocket_accuracy": shuffled["pocket"]["accuracy"],
                           "accuracy_range": [min(shuffled["history"]["accuracy"]),
                                              max(shuffled["history"]["accuracy"])],
                           **position(shuffled["w"], shuffled["b"], X)},
    }

A — Generate the data

A mesma função generate do Exercise 1, com os parâmetros novos. São 1000 pontos por classe:

  • Classe 0: média \([3,\ 3]\), covariância \(\begin{bmatrix} 1.5 & 0 \\ 0 & 1.5 \end{bmatrix}\);
  • Classe 1: média \([4,\ 4]\), covariância \(\begin{bmatrix} 1.5 & 0 \\ 0 & 1.5 \end{bmatrix}\).

Figura 4

Figura 4 — Os 2000 pontos do Exercise 2, uma cor por classe. As nuvens se sobrepõem bastante.

B — Train, keeping the best weights

A chamada é train(X, y, w0, b0, eta=0.01, max_epochs=100, pocket=True): a mesma função do Exercise 1, com o mesmo código. pocket=True liga a única adição ao laço, o bloco if pocket: em code/perceptron.py. O resto do laço é idêntico ao do Exercise 1. Depois de cada atualização, ele mede a acurácia no dataset completo. Se ela passa da melhor já vista, copia \((\mathbf{w}, b)\) para o "bolso". O ponto de partida do bolso é o próprio \((\mathbf{w}_0, b_0)\), com a acurácia inicial. O sorteio deu \(\mathbf{w}_0 = [0.01216,\ -0.00451]\).

O laço nunca para por conta própria: todas as 100 épocas têm entre 2 e 5 atualizações (289 no total).

Conjunto de pesos \(\mathbf{w}\) \(b\) Acurácia
Finais (depois da época 100) \([0.05448,\ 0.04804]\) \(-0.07\) 50.15%
Pocket (melhor visto) \([0.01066,\ 0.00873]\) \(-0.07\) 71.10%

O melhor do pocket apareceu na época 86, na atualização 247 das 289.

C — Figures

Figura 5

Figura 5 — Fronteiras final (vermelha) e do pocket (tracejada) nos dois painéis. O fundo mostra a classe prevista pelos pesos do painel. Bolinha: ponto classificado certo. "x": ponto mal classificado por esses pesos.

Figura 6

Figura 6 — Acurácia dos pesos atuais ao fim de cada época e melhor acurácia vista até ali (pocket).

D — Analysis

A diferença entre o pocket e os pesos finais. A melhor reta para as distribuições do enunciado é a mediatriz entre as médias, \(x_1 + x_2 = 7\). Nesta amostra, ela acerta 71.25% (o valor teórico é \(\Phi(0.577) = 71.8\%\)). O pocket chega a 71.10%, a 0.15 ponto dessa reta. Os pesos finais ficam em 50.15%, o valor de um chute.

Onde a fronteira final fica. A Figura 5 mostra: a reta final passa fora da nuvem, perto da origem: a distância da reta até a origem é \(-b / \lVert \mathbf{w} \rVert = 0.96\). O centro da nuvem, \((3.5,\ 3.5)\), fica a 3.98 da reta, do lado "classe 1". O modelo diz "classe 1" para 99.85% dos pontos. Ele acerta os 1000 pontos da classe 1 e erra 997 dos 1000 pontos da classe 0. Já a reta do pocket fica a 5.08 da origem. Ela corta a nuvem ao meio, quase sobre a reta ideal, que fica a 4.95.

Por que o laço deixa a reta ali. Compare o quanto cada parâmetro anda por erro:

  • \(b\) anda \(\eta = 0.01\);
  • \(\mathbf{w}\) anda \(\eta\,\lVert \mathbf{x} \rVert \approx 0.01 \times 5.11 = 0.051\) (5.11 é a norma média de \(\mathbf{x}\) nestes dados).

Para a reta cortar a nuvem no lugar certo, é preciso \(-b / \lVert \mathbf{w} \rVert \approx 4.95\), ou seja, \(\lvert b \rvert \approx 5\,\lVert \mathbf{w} \rVert\). Mas cada erro faz \(\mathbf{w}\) andar 5 vezes mais do que \(b\). O pocket mostra o problema: a reta boa tem \(b = -0.07\) e \(\lVert \mathbf{w} \rVert = 0.0138\). Um único erro soma 0.051 a \(\mathbf{w}\), 3.7 vezes o tamanho do próprio \(\mathbf{w}\) do pocket, e só 0.01 a \(b\). A reta não se ajusta um pouco: ela salta. Na maioria dos passos, a distância \(-b / \lVert \mathbf{w} \rVert\) vai parar longe de 4.95, e a reta sai da nuvem.

A ordem das amostras decide para que lado ela sai no fim. Cada época termina com os 1000 pontos da classe 1. Num falso negativo, a regra soma \(0.01\,\mathbf{x}\) a \(\mathbf{w}\) e \(0.01\) a \(b\). O escore desse ponto sobe \(0.01\,(\lVert \mathbf{x} \rVert^2 + 1) \approx 0.27\), e o de pontos parecidos sobe quase o mesmo. Poucas atualizações bastam (a época inteira tem de 2 a 5) para a reta sair da nuvem pelo lado da origem, e aí todos os pontos viram "classe 1". A partir desse momento, nenhum ponto da classe 1 erra mais, e a época acaba sem mexer na reta. Medi o outro lado do ciclo: no meio da época 100, depois dos 1000 pontos da classe 0, o modelo diz "classe 0" para 100% dos pontos (acurácia 50.00%, reta a 20.3 da origem, além da nuvem do outro lado). A reta pula de um lado da nuvem para o outro duas vezes por época. Por isso só há de 2 a 5 atualizações por época, e por isso a acurácia no fim de cada época fica entre 50.05% e 51.15%.

Figura 3 × Figura 6: o que o teorema garante. O teorema de convergência do perceptron (Rosenblatt; prova de Novikoff) supõe que existe uma reta \((\mathbf{w}^*, b^*)\) que separa as classes com margem \(\gamma > 0\): todo ponto fica a pelo menos \(\gamma\) dela, do lado certo. Com \(\lVert (\mathbf{x}, 1) \rVert \le R\) e partida de \(\mathbf{w} = \mathbf{0}\), o perceptron faz no máximo \((R / \gamma)^2\) erros, em qualquer ordem de visita. Com um \(\mathbf{w}_0\) pequeno, como aqui, o limite muda um pouco, mas continua finito. Depois disso, nenhuma amostra erra mais, uma época passa sem atualização e o laço para. É o que a Figura 3 mostra: 73 erros no total, 100% na época 25 e parada na 26.

A hipótese que o Exercise 2 quebra é a separabilidade linear. Nenhuma reta acerta todos os pontos: as nuvens se misturam (Figura 4), e mesmo a reta de referência \(x_1 + x_2 = 7\) erra 28.75% dos pontos. Sem reta separadora, não existe \(\gamma > 0\) e o limite \((R / \gamma)^2\) não existe. O teorema não garante nada. Na prática, o laço erra em todas as épocas (nunca menos de 2 atualizações), nunca para, e a curva da Figura 6 não se estabiliza. Só o pocket, que guarda o melhor em vez do último, sobe e fica.

Mais épocas resolvem? Não. A parada exige uma época sem erro, e isso exige uma reta que acerte todos os pontos. Essa reta não existe, então o laço nunca chega a um ponto fixo. Mais épocas só repetem o mesmo ciclo: a época sempre termina com o bloco da classe 1, que empurra a reta para fora da nuvem. Conferi com 500 épocas: acurácia final de 50.20%, entre 50.1% e 51.2% nas últimas 100 épocas, e ainda 2 atualizações na época 500.

Um \(\eta\) menor resolve? Também não. Pela regra, depois de \(t\) atualizações, \(\mathbf{w}_t = \mathbf{w}_0 + \eta\,\mathbf{u}_t\) e \(b_t = \eta\,c_t\), onde \(\mathbf{u}_t\) e \(c_t\) são somas de \(\pm\mathbf{x}\) e \(\pm 1\). É o argumento do item 1D:

  • \(\eta\) multiplica o passo de \(\mathbf{w}\) e o passo de \(b\). A razão entre eles continua \(\lVert \mathbf{x} \rVert \approx 5\), e é essa razão que faz a reta saltar;
  • a predição depende só do sinal, então \(\eta\) apenas reescala os pesos. O único efeito de um \(\eta\) menor é aumentar a partida efetiva \(\mathbf{w}_0 / \eta\). Isso muda as primeiras épocas, não o ciclo.

Conferi com \(\eta = 0.001\): acurácia final de 50.45%, entre 50.05% e 51.2% nas 100 épocas. O que resolve é mudar o algoritmo, não o \(\eta\) ou o número de épocas: guardar o melhor (o pocket), ou usar uma perda que mede o quanto o ponto está errado, e não só o sinal, com um passo que diminui ao longo do treino.

Experimento extra: a ordem das amostras. Com a mesma inicialização e a ordem embaralhada uma vez (rng.permutation, o último sorteio do relatório), a acurácia final vai a 63.95% e o pocket a 71.85%. Os blocos de uma classe só deixam de existir, e a reta final fica mais perto da nuvem: a 3.68 da origem, contra 0.96 na ordem da geração. Mesmo assim, a acurácia ao fim de cada época oscila entre 61.35% e 70.10% e nunca para de mudar. A ordem muda o quanto os pesos finais ficam ruins, mas não o fato de que eles não convergem.

Results summary

# Quantity Value
1 Exercise 1 — final \(\mathbf{w}\) and \(b\) \(\mathbf{w} = [0.05050,\ 0.02887]\), \(b = -0.25\)
2 Exercise 1 — epochs to convergence 26 (a 26ª época não fez nenhuma atualização)
3 Exercise 1 — final accuracy 100.00%
4 Exercise 1 — epochs and final accuracy with \(\eta = 1.0\) 37 épocas; 100.00%
5 Exercise 2 — final \(\mathbf{w}\) and \(b\) \(\mathbf{w} = [0.05448,\ 0.04804]\), \(b = -0.07\)
6 Exercise 2 — accuracy of the final weights 50.15%
7 Exercise 2 — accuracy of the pocket weights 71.10%
8 Exercise 2 — epoch at which the pocket best occurred Época 86 (atualização 247 de 289)