Skip to content

Exercise 2 — Perceptron

Neste exercício implementei o perceptron do zero e testei em dois casos: um em que as classes são linearmente separáveis (Exercise 1) e outro em que elas se sobrepõem (Exercise 2). A ideia é ver na prática quando o algoritmo converge e o que acontece quando ele não tem como convergir, que é onde entra o pocket.

Como fiz.

  • A implementação está em code/perceptron.py: função degrau, previsão, regra de atualização e o laço de treino. Usei só NumPy para o modelo, pandas para montar tabelas e matplotlib para os gráficos. Os dois exercícios importam a mesma função train_perceptron, então o Exercise 2 usa exatamente o mesmo código do Exercise 1, sem mudar nada.
  • Cada script começa com rng = np.random.default_rng(42) e todos os sorteios (dados, embaralhamento e pesos iniciais) saem desse gerador. Rodando os scripts de novo, as figuras e os números saem iguais.
  • Depois de gerar os dados eu embaralho a ordem das amostras uma vez só, e essa ordem fica fixa durante o treino. Se não embaralhar, o perceptron vê 1000 pontos da classe 0 e depois 1000 da classe 1. Manter a ordem fixa também ajudou nas comparações do item D, porque quando eu mudo o η e mantenho o mesmo \(\mathbf{w}_0\), a única coisa diferente entre as execuções é o η.
  • Usei os valores do enunciado: \(\mathbf{w}_0 \sim \mathcal{N}(0,\ 0.01)\) (desvio 0.01), \(b_0 = 0\) e \(\eta = 0.01\). O treino para quando passa uma época inteira sem nenhuma atualização ou quando chega em 100 épocas. A acurácia é calculada no dataset inteiro no fim de cada época.
  • O pocket é checado depois de cada atualização, como o enunciado pede: se a acurácia no dataset inteiro for maior que a melhor que eu já tinha visto, guardo uma cópia de \((\mathbf{w}, b)\). Ele não interfere no treino, só guarda os melhores pesos.

Dificuldades. A primeira foi o tempo de execução. Checar o pocket depois de cada atualização significa calcular a acurácia nos 2000 pontos a cada erro, e no Exercise 2 são uns 700 erros por época (umas 70 mil avaliações em 100 épocas). Resolvi calculando a acurácia de forma vetorizada (X @ w + b para todos os pontos de uma vez), e com isso roda em poucos segundos. O laço de atualização continuou sendo amostra por amostra, que é como o perceptron funciona. A segunda foi entender o resultado do Exercise 2: a acurácia final depende de em que momento o treino para, então só o número não explica muita coisa. Por isso, na análise, eu também olhei onde a fronteira ficou (ângulo, deslocamento e quantos pontos ela manda para cada classe).

Também deixei tudo em um notebook, com as saídas: code/perceptron_notebook.ipynb. Os números dele são os mesmos deste relatório.


Exercise 1

Separable data. Este é o script do exercício. A implementação do perceptron que ele usa está no item B.

"""Exercise 1 — dados linearmente separáveis.

Gera figures/fig1.png, fig2.png, fig3.png e imprime os números do relatório.
Roda de qualquer diretório: python ex1_separable.py
"""
from pathlib import Path

import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

from perceptron import (ETA, train_perceptron, predict, make_dataset, plot_data,
                        plot_boundary_panel, unit)

rng = np.random.default_rng(42)
FIG = Path(__file__).resolve().parent.parent / "figures"
FIG.mkdir(exist_ok=True)

# ---------------------------------------------------------------- A — dados
mean0, mean1 = np.array([1.5, 1.5]), np.array([5.0, 5.0])
cov = np.array([[0.5, 0.0], [0.0, 0.5]])
X, y = make_dataset(rng, mean0, mean1, cov)

fig = plot_data(X, y, [mean0, mean1], "Figure 1 — Exercise 1: dados separáveis (1000 por classe)")
fig.savefig(FIG / "fig1.png", bbox_inches="tight"); plt.close(fig)
print("A) médias amostrais:", X[y == 0].mean(0).round(3), X[y == 1].mean(0).round(3))

# ---------------------------------------------------------------- C — treino (η = 0.01)
w0 = rng.normal(0.0, 0.01, size=2)          # inicialização do enunciado; b = 0
res = train_perceptron(X, y, eta=ETA, w0=w0)

print(f"C) w0 = {w0}")
print(f"   w = [{res['w'][0]:.5f}, {res['w'][1]:.5f}], b = {res['b']:.5f}")
print(f"   épocas = {res['epochs']} (convergiu: {res['converged']}), acurácia = {res['acc']:.4f}")
print(f"   atualizações por época: {res['updates_hist'].tolist()}")

fig, ax = plt.subplots(figsize=(6, 5.5))
plot_boundary_panel(ax, X, y, res["w"], res["b"],
                    "Figure 2 — Exercise 1: fronteira de decisão final (η = 0.01)",
                    "fronteira $w\\cdot x + b = 0$")
ax.set_aspect("equal")
fig.savefig(FIG / "fig2.png", bbox_inches="tight"); plt.close(fig)

epochs = np.arange(1, res["epochs"] + 1)
fig, ax = plt.subplots(figsize=(6.5, 4))
ax.plot(epochs, res["acc_hist"], "o-", label="acurácia no fim da época")
ax.set_xticks(epochs); ax.set_ylim(0.45, 1.02)
ax.set_xlabel("época"); ax.set_ylabel("acurácia (dataset inteiro)")
ax.set_title("Figure 3 — Exercise 1: acurácia por época (η = 0.01)")
ax.legend()
fig.savefig(FIG / "fig3.png", bbox_inches="tight"); plt.close(fig)

# ---------------------------------------------------------------- D — η = 1.0, mesmo w0 e mesma ordem
res1 = train_perceptron(X, y, eta=1.0, w0=w0)
cos_dir = float(unit(res["w"]) @ unit(res1["w"]))
print(f"D) η = 1.0: épocas = {res1['epochs']}, acurácia = {res1['acc']:.4f}, "
      f"w = {res1['w'].round(4)}, b = {res1['b']:.4f}, atualizações {res1['updates_hist'].tolist()}")
print(f"   w/||w||: η=0.01 {unit(res['w']).round(4)} | η=1.0 {unit(res1['w']).round(4)}")
print(f"   cosseno = {cos_dir:.4f}, ângulo = {np.degrees(np.arccos(np.clip(cos_dir, -1, 1))):.2f}°")
print(f"   ||w0|| = {np.linalg.norm(w0):.4f}; ||w|| final: "
      f"{np.linalg.norm(res['w']):.4f} (η=0.01) vs {np.linalg.norm(res1['w']):.4f} (η=1.0)")

# ---------------------------------------------------------------- D — partindo de w = 0, b = 0
z1 = train_perceptron(X, y, eta=0.01, w0=np.zeros(2))
z2 = train_perceptron(X, y, eta=1.0, w0=np.zeros(2))
print(f"D) w0 = 0: η=0.01 -> w = {z1['w']}, b = {z1['b']:.4f}, épocas = {z1['epochs']}")
print(f"           η=1.00 -> w = {z2['w']}, b = {z2['b']:.4f}, épocas = {z2['epochs']}")
print(f"   razão w = {z2['w'] / z1['w']}, razão b = {z2['b'] / z1['b']:.6f}")
print("   previsões idênticas:",
      bool(np.all(predict(X, z1["w"], z1["b"]) == predict(X, z2["w"], z2["b"]))),
      "| atualizações:", z1["updates_hist"].tolist(), z2["updates_hist"].tolist())

A — Generate the data

Gerei 1000 amostras por classe com \(\mathcal{N}(\mu, \Sigma)\) e \(\Sigma = 0.5\,I\): a classe 0 com \(\mu_0 = (1.5,\ 1.5)\) e a classe 1 com \(\mu_1 = (5,\ 5)\). As médias das amostras ficaram (1.450, 1.473) e (5.010, 5.013), bem perto das médias teóricas. A distância entre os centros é \(3.5\sqrt 2 \approx 4.95\), e o desvio em cada eixo é \(\sqrt{0.5} \approx 0.71\). Ou seja, cada centro fica a uns 3.5 desvios da reta que divide os dois, e dá para ver na figura que as nuvens não se encostam.

Figure 1

B — Implement the perceptron

O modelo e a regra de atualização que implementei:

\[\hat y = \operatorname{step}(\mathbf{w}\cdot\mathbf{x} + b), \qquad \operatorname{step}(z) = \begin{cases}1 & z \ge 0\\ 0 & z < 0\end{cases}\]
\[\mathbf{w} \leftarrow \mathbf{w} + \eta\,(y - \hat y)\,\mathbf{x}, \qquad b \leftarrow b + \eta\,(y - \hat y)\]

Como \(y - \hat y\) só pode ser \(-1\), \(0\) ou \(+1\), os pesos só mudam quando o modelo erra. Se o ponto é da classe 1 e o modelo previu 0, soma \(\eta\mathbf{x}\) em \(\mathbf{w}\); se é da classe 0 e previu 1, subtrai.

"""Perceptron escrito do zero (NumPy) + geração de dados e figuras.

Usado por ex1_separable.py e ex2_overlapping.py. O modelo (degrau, previsão,
regra de atualização e laço de treino com pocket) está todo aqui; nada vem de
bibliotecas de ML.
"""
import numpy as np
import matplotlib.pyplot as plt

N_PER_CLASS = 1000
MAX_EPOCHS = 100
ETA = 0.01

plt.rcParams.update({"figure.dpi": 110, "axes.grid": True, "grid.alpha": 0.3})
COLORS = {0: "tab:blue", 1: "tab:orange"}


# ---------------------------------------------------------------- modelo

def step(z):
    """Função degrau: 1 se z >= 0, senão 0."""
    return np.where(z >= 0, 1, 0)


def predict(X, w, b):
    """Previsão do perceptron: degrau aplicado a w·x + b (X pode ser uma amostra ou uma matriz)."""
    return step(X @ w + b)


def accuracy(X, y, w, b):
    """Fração de acertos no dataset inteiro."""
    return float(np.mean(predict(X, w, b) == y))


def train_perceptron(X, y, eta, w0, b0=0.0, max_epochs=MAX_EPOCHS):
    """Regra de aprendizado do perceptron, amostra a amostra.

    Para cada amostra (x_i, y_i), na ordem fixa do dataset:
        y_hat = step(w·x_i + b)
        w <- w + eta * (y_i - y_hat) * x_i
        b <- b + eta * (y_i - y_hat)
    Só há atualização quando y_hat != y_i. O treino para quando uma época inteira
    passa sem nenhuma atualização (convergiu) ou ao atingir max_epochs.

    Além dos pesos correntes, guarda o "pocket": toda vez que uma atualização
    produz acurácia no dataset inteiro maior do que qualquer outra já vista,
    copia (w, b) para o bolso. O pocket não interfere no treino.
    """
    w = np.asarray(w0, dtype=float).copy()
    b = float(b0)

    pocket_w, pocket_b = w.copy(), b
    pocket_acc = accuracy(X, y, w, b)
    pocket_epoch = 0                       # 0 = pesos iniciais, antes de qualquer época

    acc_hist, pocket_hist, updates_hist = [], [], []
    converged = False

    for epoch in range(1, max_epochs + 1):
        n_updates = 0
        for x_i, y_i in zip(X, y):
            y_hat = 1 if x_i @ w + b >= 0 else 0
            error = y_i - y_hat            # +1, 0 ou -1
            if error != 0:
                w = w + eta * error * x_i
                b = b + eta * error
                n_updates += 1

                # Pocket: avalia no dataset inteiro depois de cada atualização
                acc_now = accuracy(X, y, w, b)
                if acc_now > pocket_acc:
                    pocket_w, pocket_b = w.copy(), b
                    pocket_acc, pocket_epoch = acc_now, epoch

        acc_hist.append(accuracy(X, y, w, b))   # acurácia no fim da época
        pocket_hist.append(pocket_acc)
        updates_hist.append(n_updates)

        if n_updates == 0:
            converged = True
            break

    return {
        "w": w, "b": b, "epochs": epoch, "converged": converged,
        "acc": acc_hist[-1], "acc_hist": np.array(acc_hist),
        "updates_hist": np.array(updates_hist),
        "pocket_w": pocket_w, "pocket_b": pocket_b,
        "pocket_acc": pocket_acc, "pocket_epoch": pocket_epoch,
        "pocket_hist": np.array(pocket_hist),
    }


# ---------------------------------------------------------------- dados

def make_dataset(rng, mean0, mean1, cov, n=N_PER_CLASS):
    """Sorteia as duas classes e embaralha a ordem uma única vez.

    O embaralhamento importa: sem ele o perceptron veria 1000 amostras da
    classe 0 seguidas de 1000 da classe 1. A ordem fica fixa durante o treino.
    """
    X0 = rng.multivariate_normal(mean0, cov, size=n)
    X1 = rng.multivariate_normal(mean1, cov, size=n)
    X = np.vstack([X0, X1])
    y = np.concatenate([np.zeros(n, dtype=int), np.ones(n, dtype=int)])
    order = rng.permutation(len(y))
    return X[order], y[order]


# ---------------------------------------------------------------- figuras

def scatter_classes(ax, X, y, alpha=0.5):
    for c in (0, 1):
        ax.scatter(*X[y == c].T, s=10, alpha=alpha, color=COLORS[c], label=f"classe {c}")


def draw_boundary(ax, w, b, X, **kw):
    """Desenha a reta w1*x1 + w2*x2 + b = 0 dentro dos limites dos dados."""
    x1 = np.linspace(X[:, 0].min() - 0.5, X[:, 0].max() + 0.5, 200)
    if abs(w[1]) > 1e-12:
        ax.plot(x1, -(w[0] * x1 + b) / w[1], **kw)
    else:                                   # reta vertical
        ax.axvline(-b / w[0], **kw)


def plot_boundary_panel(ax, X, y, w, b, title, line_label, line_color="k"):
    """Dados + fronteira + pontos mal classificados (círculo vermelho)."""
    scatter_classes(ax, X, y, alpha=0.35)
    wrong = predict(X, w, b) != y
    ax.scatter(*X[wrong].T, s=28, facecolors="none", edgecolors="red", linewidths=0.9,
               label=f"mal classificados ({wrong.sum()})")
    draw_boundary(ax, w, b, X, color=line_color, lw=2, label=line_label)
    ax.set_xlim(X[:, 0].min() - 0.5, X[:, 0].max() + 0.5)
    ax.set_ylim(X[:, 1].min() - 0.5, X[:, 1].max() + 0.5)
    ax.set_xlabel("$x_1$"); ax.set_ylabel("$x_2$"); ax.set_title(title)
    ax.legend(loc="upper left", fontsize=8)


def plot_data(X, y, means, title):
    fig, ax = plt.subplots(figsize=(6, 5.5))
    scatter_classes(ax, X, y, alpha=0.45)
    ax.scatter(*np.vstack(means).T, marker="X", s=150, color="k", label="médias")
    ax.set_xlabel("$x_1$"); ax.set_ylabel("$x_2$"); ax.set_title(title)
    ax.legend(); ax.set_aspect("equal")
    return fig


def unit(v):
    return v / np.linalg.norm(v)

C — Train and measure

Treinei com \(\eta = 0.01\), e o \(\mathbf{w}_0\) sorteado foi \([0.00991,\ -0.00827]\). O treino terminou em 2 épocas: na primeira teve 48 atualizações e no fim dela todos os pontos já estavam certos; na segunda não teve nenhum erro, e por isso o treino parou.

valor
\(\mathbf{w}\) final [0.03189, 0.02874]
\(b\) final −0.20
épocas 2 (48 atualizações, depois 0)
acurácia 100%

A reta \(0.03189\,x_1 + 0.02874\,x_2 - 0.20 = 0\) cruza a diagonal em \(x_1 = x_2 \approx 3.30\), perto do ponto médio entre os centros (3.25, 3.25), e o vetor \(\mathbf{w}\) aponta quase na direção \((1, 1)\). Nenhum ponto ficou mal classificado.

Figure 2

Figure 3

D — Analysis

1. Por que converge tão rápido? A regra só atualiza quando erra, e toda atualização melhora a saída naquele ponto: se era um falso negativo, \(\mathbf{w}\cdot\mathbf{x} + b\) aumenta \(\eta(\lVert\mathbf{x}\rVert^2 + 1)\); se era um falso positivo, diminui o mesmo tanto. O teorema de convergência do perceptron (Novikoff) diz que, se existe uma reta que separa os dados com margem \(\gamma\), o número total de erros é no máximo \((R/\gamma)^2\), onde \(R\) é o maior \(\lVert\mathbf{x}\rVert\). Aqui a margem é grande em relação a \(R\), porque as nuvens estão bem longe da reta que as separa, então esse limite é pequeno, e de fato foram só 48 erros. Além disso, como o \(\mathbf{w}_0\) é muito pequeno, logo nas primeiras atualizações o \(\mathbf{w}\) vira basicamente uma soma de pontos da classe 1 menos pontos da classe 0, e isso aponta mais ou menos para \(\mu_1 - \mu_0 \propto (1, 1)\), que já é uma boa direção.

2. Rodando de novo com \(\eta = 1.0\) (mesmo \(\mathbf{w}_0\) e mesma ordem): também deu 2 épocas e 100% de acurácia, mas com 25 atualizações na primeira época em vez de 48. Os pesos finais foram \(\mathbf{w} = [1.717,\ 1.665]\) e \(b = -11\).

\(\eta = 0.01\) \(\eta = 1.0\)
\(\mathbf{w}/\lVert\mathbf{w}\rVert\) [0.7429, 0.6694] [0.7181, 0.6960]
\(\lVert\mathbf{w}\rVert\) 0.043 2.392
atualizações 48, 0 25, 0

Os pesos ficaram uns 56 vezes maiores, mas a direção é quase a mesma: o cosseno entre os dois vetores normalizados deu 0.9993, um ângulo de 2.1°. Então o η não muda muito a solução, ele muda o tamanho do passo comparado com o \(\mathbf{w}_0\). A fronteira \(\mathbf{w}\cdot\mathbf{x} + b = 0\) continua a mesma se eu multiplicar \((\mathbf{w}, b)\) por um número positivo, e a regra só olha o sinal de \(\mathbf{w}\cdot\mathbf{x} + b\). Com \(\eta = 0.01\), o \(\mathbf{w}_0\) (\(\lVert\mathbf{w}_0\rVert = 0.013\)) tem mais ou menos o tamanho de um passo, então ele ainda influencia quais pontos saem errados no começo. Com \(\eta = 1\) o primeiro passo já é muito maior que ele. Acho que é daí que vêm a diferença de 48 para 25 erros e os 2° de diferença na direção.

3. Começando com \(\mathbf{w} = 0\) e \(b = 0\), o η só muda a escala dos pesos. Mostrei por indução. Chamo de \((\mathbf{w}^{(t)}_1, b^{(t)}_1)\) e \((\mathbf{w}^{(t)}_2, b^{(t)}_2)\) os pesos depois de \(t\) amostras nas execuções com \(\eta_1\) e \(\eta_2\), e a hipótese é que \(\mathbf{w}^{(t)}_2 = \tfrac{\eta_2}{\eta_1}\mathbf{w}^{(t)}_1\) e \(b^{(t)}_2 = \tfrac{\eta_2}{\eta_1} b^{(t)}_1\).

  • Base (\(t = 0\)): os dois são zero, então vale para qualquer fator.
  • Passo: para a próxima amostra \((\mathbf{x}, y)\), \(\mathbf{w}^{(t)}_2\cdot\mathbf{x} + b^{(t)}_2 = \tfrac{\eta_2}{\eta_1}\left(\mathbf{w}^{(t)}_1\cdot\mathbf{x} + b^{(t)}_1\right)\). Como \(\eta_2/\eta_1 > 0\), o sinal é o mesmo, então \(\hat y_2 = \hat y_1\) e o erro \(e = y - \hat y\) também é o mesmo. Daí:
\[\mathbf{w}^{(t+1)}_2 = \tfrac{\eta_2}{\eta_1}\mathbf{w}^{(t)}_1 + \eta_2\, e\,\mathbf{x} = \tfrac{\eta_2}{\eta_1}\left(\mathbf{w}^{(t)}_1 + \eta_1\, e\,\mathbf{x}\right) = \tfrac{\eta_2}{\eta_1}\mathbf{w}^{(t+1)}_1,\]

e para o \(b\) é igual. Então as duas execuções erram nos mesmos pontos, na mesma ordem, param na mesma época, e os pesos finais só diferem pelo fator \(\eta_2/\eta_1\). A fronteira é a mesma.

Também conferi isso no script com \(\eta_1 = 0.01\) e \(\eta_2 = 1\): deu \(\mathbf{w}_1 = [0.017074,\ 0.016729]\) e \(b_1 = -0.11\), e \(\mathbf{w}_2 = [1.7074,\ 1.6729]\) e \(b_2 = -11\). A razão foi exatamente 100 nas três coordenadas, as previsões foram iguais nos 2000 pontos e as atualizações por época também (25 e depois 0).

Quando \(\mathbf{w}_0 \ne 0\) a indução não funciona já na base, porque \(\mathbf{w}_0 \ne \tfrac{\eta_2}{\eta_1}\mathbf{w}_0\), e por isso no item 2 as direções ficaram um pouco diferentes. Uma coisa que eu achei interessante: a execução com \(\eta = 1\) do item 2 e a que começa do zero no item 3 deram quase o mesmo resultado (\([1.717,\ 1.665]\) e \([1.707,\ 1.673]\), as duas com 25 erros). Com um passo grande, o \(\mathbf{w}_0\) pequeno praticamente não faz diferença.


Exercise 2

Overlapping data. Script do exercício. Ele importa a mesma train_perceptron do Exercise 1, sem nenhuma mudança.

"""Exercise 2 — dados sobrepostos, perceptron + pocket.

Gera figures/fig4.png, fig5.png, fig6.png e imprime os números do relatório.
Roda de qualquer diretório: python ex2_overlapping.py
"""
from math import erf, sqrt
from pathlib import Path

import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

from perceptron import (ETA, train_perceptron, predict, accuracy, make_dataset, plot_data,
                        plot_boundary_panel, draw_boundary)

rng = np.random.default_rng(42)
FIG = Path(__file__).resolve().parent.parent / "figures"
FIG.mkdir(exist_ok=True)

# ---------------------------------------------------------------- A — dados
mean0, mean1 = np.array([3.0, 3.0]), np.array([4.0, 4.0])
cov = np.array([[1.5, 0.0], [0.0, 1.5]])
X, y = make_dataset(rng, mean0, mean1, cov)

fig = plot_data(X, y, [mean0, mean1], "Figure 4 — Exercise 2: dados sobrepostos (1000 por classe)")
fig.savefig(FIG / "fig4.png", bbox_inches="tight"); plt.close(fig)

# ---------------------------------------------------------------- B — treino com pocket (mesma função do Ex. 1)
w0 = rng.normal(0.0, 0.01, size=2)
res = train_perceptron(X, y, eta=ETA, w0=w0)

print(f"B) épocas = {res['epochs']} (convergiu: {res['converged']})")
print(f"   FINAL : w = [{res['w'][0]:.5f}, {res['w'][1]:.5f}], b = {res['b']:.5f}, acurácia = {res['acc']:.4f}")
print(f"   POCKET: w = [{res['pocket_w'][0]:.5f}, {res['pocket_w'][1]:.5f}], b = {res['pocket_b']:.5f}, "
      f"acurácia = {res['pocket_acc']:.4f}, época {res['pocket_epoch']}")
u = res["updates_hist"]
print(f"   atualizações por época: média {u.mean():.0f}, mín {u.min()}, máx {u.max()}")
h = res["acc_hist"]
print(f"   acurácia corrente nas 100 épocas: mín {h.min():.4f}, máx {h.max():.4f}, média {h.mean():.4f}")
print(f"   pocket ao fim da época 1: {res['pocket_hist'][0]:.4f}")

# ---------------------------------------------------------------- C — figuras
fig, axes = plt.subplots(1, 2, figsize=(12, 5.5), sharex=True, sharey=True)
plot_boundary_panel(axes[0], X, y, res["w"], res["b"],
                    f"Final (época {res['epochs']}): acurácia {res['acc']:.1%}",
                    "fronteira final", line_color="k")
draw_boundary(axes[0], res["pocket_w"], res["pocket_b"], X, color="green", lw=1.5, ls="--",
              label="fronteira pocket (ref.)")
axes[0].legend(loc="upper left", fontsize=8)
plot_boundary_panel(axes[1], X, y, res["pocket_w"], res["pocket_b"],
                    f"Pocket (época {res['pocket_epoch']}): acurácia {res['pocket_acc']:.1%}",
                    "fronteira pocket", line_color="green")
draw_boundary(axes[1], res["w"], res["b"], X, color="k", lw=1.5, ls="--",
              label="fronteira final (ref.)")
axes[1].legend(loc="upper left", fontsize=8)
fig.suptitle("Figure 5 — Exercise 2: fronteiras final e pocket "
             "(vermelho = mal classificado pela fronteira do painel)")
fig.tight_layout()
fig.savefig(FIG / "fig5.png", bbox_inches="tight"); plt.close(fig)

epochs = np.arange(1, res["epochs"] + 1)
fig, ax = plt.subplots(figsize=(9, 4.5))
ax.plot(epochs, res["acc_hist"], lw=1.2, label="acurácia corrente (fim da época)")
ax.plot(epochs, res["pocket_hist"], lw=2, color="green", label="acurácia do pocket")
ax.axhline(0.5, color="gray", ls=":", lw=1, label="chute (50%)")
ax.set_ylim(0.4, 0.8)
ax.set_xlabel("época"); ax.set_ylabel("acurácia (dataset inteiro)")
ax.set_title("Figure 6 — Exercise 2: acurácia corrente vs. pocket (η = 0.01)")
ax.legend(loc="lower right")
fig.savefig(FIG / "fig6.png", bbox_inches="tight"); plt.close(fig)

# ---------------------------------------------------------------- D — onde estão as fronteiras
midpoint = (mean0 + mean1) / 2
for name, w, b in [("final", res["w"], res["b"]), ("pocket", res["pocket_w"], res["pocket_b"])]:
    p1 = predict(X, w, b).mean()
    dist = (midpoint @ w + b) / np.linalg.norm(w)
    ang = np.degrees(np.arctan2(w[1], w[0]))
    print(f"D) {name:>6}: prevê classe 1 em {p1:.1%} | ângulo de w = {ang:.1f}° "
          f"| distância com sinal de (3.5, 3.5) à reta = {dist:+.3f}")

# Teto teórico de uma reta: covariâncias iguais e isotrópicas -> mediatriz, erro Φ(−‖Δμ‖/2σ)
z = np.linalg.norm(mean1 - mean0) / (2 * sqrt(cov[0, 0]))
print(f"D) melhor reta teórica: Φ({z:.4f}) = {0.5 * (1 + erf(z / sqrt(2))):.4f}; "
      f"reta de Bayes (w=[1,1], b=-7) nesta amostra: {accuracy(X, y, np.array([1.0, 1.0]), -7.0):.4f}")

# ---------------------------------------------------------------- D — mais épocas ou η menor?
rows = []
for eta in (1.0, 0.01, 0.001):
    r = train_perceptron(X, y, eta=eta, w0=w0, max_epochs=500)
    tail = r["acc_hist"][-100:]
    rows.append({"eta": eta, "épocas": r["epochs"], "convergiu": r["converged"],
                 "acc final": r["acc"], "acc últ.100 mín": tail.min(), "acc últ.100 máx": tail.max(),
                 "pocket": r["pocket_acc"], "época pocket": r["pocket_epoch"],
                 "atualiz./época": r["updates_hist"].mean()})
print("D) 500 épocas, mesmo w0:")
print(pd.DataFrame(rows).round(4).to_string(index=False))

A — Generate the data

De novo 1000 amostras por classe, agora com \(\Sigma = 1.5\,I\): a classe 0 com \(\mu_0 = (3,\ 3)\) e a classe 1 com \(\mu_1 = (4,\ 4)\). Agora a distância entre os centros é só \(\sqrt 2 \approx 1.41\), e cada centro fica a \(0.71 / 1.22 \approx 0.58\) desvio da reta do meio. Na figura dá para ver que as duas nuvens ficam praticamente uma em cima da outra, então não existe reta que separe tudo.

Figure 4

B — Train, keeping the best weights

Usei a mesma função, com \(\eta = 0.01\) e um \(\mathbf{w}_0 \sim \mathcal{N}(0,\ 0.01)\) novo. Dessa vez o treino foi até as 100 épocas sem convergir: em todas as épocas teve entre 658 e 761 atualizações (701 em média).

\(\mathbf{w}\) \(b\) acurácia
Final (época 100) [0.07538, 0.08603] −0.41 63.90%
Pocket (época 23) [0.05678, 0.06367] −0.42 73.30%

C — Figures

Na Figura 5 os círculos vermelhos são os pontos que a fronteira daquele painel erra. A outra fronteira aparece tracejada só para comparar.

Figure 5

Figure 6

D — Analysis

Para entender onde cada fronteira ficou, calculei no script três coisas: quantos pontos ela classifica como classe 1, o ângulo do \(\mathbf{w}\) (o ideal seria 45°, na direção de \(\mu_1 - \mu_0\)) e a distância com sinal entre o ponto médio (3.5, 3.5) e a reta (o ideal seria 0, porque a melhor reta passa por esse ponto).

prevê classe 1 ângulo de \(\mathbf{w}\) distância de (3.5, 3.5) à reta acurácia
Final 81.2% 48.8° +1.355 63.90%
Pocket 50.1% 48.3° +0.019 73.30%
Reta de Bayes (\(x_1 + x_2 = 7\)) — 45.0° 0 72.60%

1. Por que o final e o pocket ficam tão diferentes? O pocket chegou praticamente no máximo que uma reta consegue nesses dados. Como as duas classes têm a mesma covariância, a melhor reta é a que passa no meio dos dois centros, e a acurácia teórica dela é \(\Phi(\lVert\mu_1 - \mu_0\rVert / 2\sigma) = \Phi(0.577) = 71.8\%\). Nesta amostra essa reta acerta 72.6%. O pocket ficou um pouco acima (73.3%) porque ele pega a melhor reta para esses 2000 pontos específicos. A fronteira dele passa a 0.02 do ponto médio e divide os pontos quase meio a meio.

A fronteira final tem quase o mesmo ângulo (48.8° contra 48.3°), mas está deslocada: ela passa 1.36 unidades abaixo e à esquerda do ponto médio, e por isso manda 81.2% dos pontos para a classe 1. Ela acerta quase toda a classe 1 e erra muita coisa da classe 0 (painel da esquerda da Figura 5). Então o problema dela não é a inclinação, é a posição.

O motivo é que, com as classes sobrepostas, sempre tem ponto errado, então o perceptron nunca para de atualizar. Cada erro muda o \(b\) em \(\pm\eta\) e o \(\mathbf{w}\) em \(\pm\eta\mathbf{x}\): um ponto da classe 1 errado puxa a reta para baixo e para a esquerda, e um ponto da classe 0 errado puxa para cima e para a direita. Na região onde as nuvens se misturam isso não acaba nunca, e a reta fica indo e voltando em volta da posição boa. A fronteira da época 100 é simplesmente onde a reta estava quando o treino acabou, e nada garante que seja uma posição boa. É para isso que serve o pocket: ele não muda o treino, só guarda o melhor momento.

2. Comparando as Figuras 3 e 6. Na Figura 3 a acurácia chega a 100% e o treino para sozinho porque uma época inteira passa sem erro. Na Figura 6 a acurácia corrente fica oscilando entre 59.8% e 71.4% do começo ao fim, sem subir, enquanto a do pocket já estava em 73.1% no fim da primeira época e chegou a 73.3% na época 23, e não mudou mais.

O teorema de convergência diz que, se existir um \((\mathbf{w}^*, b^*)\) com \(y_i'(\mathbf{w}^*\cdot\mathbf{x}_i + b^*) \ge \gamma > 0\) para todo \(i\) (usando \(y' = \pm 1\)), o perceptron faz no máximo \((R/\gamma)^2\) atualizações, ou seja, ele para depois de um número finito de passos e com erro zero. A hipótese que falha aqui é a de que os dados são linearmente separáveis (margem \(\gamma > 0\)). Como nenhuma reta acerta todos os pontos, o teorema não garante nada, e o que acontece na prática é que os pesos ficam mudando para sempre. E mesmo quando os dados não são separáveis, o teorema não fala nada sobre achar a reta que erra menos.

3. Mais épocas ou um η menor resolveriam? Não. Para testar, rodei de novo com 500 épocas, o mesmo \(\mathbf{w}_0\) e três valores de η:

\(\eta\) acurácia final corrente nas últimas 100 épocas (mín–máx) atualizações/época pocket
1.0 63.35% 59.45% – 71.40% 700 73.40%
0.01 66.50% 59.45% – 71.35% 700 73.35%
0.001 63.45% 59.75% – 66.80% 701 73.35%
  • Mais épocas: o treino só para quando passa uma época sem erro, e isso nunca vai acontecer, porque sempre tem ponto que nenhuma reta acerta. Rodar mais épocas é só repetir o mesmo vai e volta. Com 500 épocas a acurácia corrente ficou oscilando na mesma faixa que com 100.
  • η menor: pelo item 3 do Exercise 1, quando o \(\mathbf{w}_0\) é bem pequeno o η basicamente só muda a escala de \((\mathbf{w}, b)\), e a fronteira só depende da direção de \(\mathbf{w}\) e de \(b/\lVert\mathbf{w}\rVert\). Então diminuir o η não deixa os passos menores em relação aos pesos, porque os pesos também ficam menores. Os erros acontecem quase na mesma sequência, e é por isso que as três linhas da tabela ficaram tão parecidas. O que poderia fazer os pesos pararem de oscilar seria um η que diminui ao longo do treino (tipo \(\eta_t \propto 1/t\)), ou usar uma função de perda contínua (como a logística ou a hinge) em vez de olhar só o sinal. Mas, com o perceptron do jeito que ele é, a solução é o pocket.

Results summary

# Quantity Value
1 Exercise 1 — final \(\mathbf{w}\) and \(b\) \(\mathbf{w}\) = [0.03189, 0.02874], \(b\) = −0.20000
2 Exercise 1 — epochs to convergence 2 (48 atualizações na 1ª época, 0 na 2ª)
3 Exercise 1 — final accuracy 1.0000 (100%)
4 Exercise 1 — epochs and final accuracy with \(\eta = 1.0\) 2 épocas, 1.0000 (100%)
5 Exercise 2 — final \(\mathbf{w}\) and \(b\) \(\mathbf{w}\) = [0.07538, 0.08603], \(b\) = −0.41000
6 Exercise 2 — accuracy of the final weights 0.6390
7 Exercise 2 — accuracy of the pocket weights 0.7330
8 Exercise 2 — epoch at which the pocket best occurred 23