Code

Aula 1: O neurônio e o perceptron

The programs in the class folder, highlighted, each with its file to download.

figuras_slides.py

Figuras dos slides da aula 1.

180 linesDownload
"""Figuras dos slides da aula 1.

Rode, a partir da raiz do repositório:
    python aulas/01-neuronio/codigo/figuras_slides.py

Gera duas figuras do mesmo tamanho e com os mesmos eixos, a 200 dpi:
    aulas/01-neuronio/figuras/reta-e.png   o neurônio da tabela do E, a reta dele e o vetor w
    aulas/01-neuronio/figuras/xor.png      os quatro pontos do XOR, que nenhuma reta separa

A figura do perceptron sai do próprio perceptron.py. As fotos e desenhos dos
slides vêm do Wikimedia Commons; autor e licença de cada um estão no README
da aula.
"""
from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Patch
from matplotlib.ticker import FuncFormatter

# O que vem entre cifrões, como $x_1$, sai como fórmula, com o índice embaixo, na
# fonte STIX, que vem com o matplotlib e tem o mesmo desenho da STIX Two das
# equações dos slides (infra/equacoes.py).
plt.rcParams["mathtext.fontset"] = "stix"

AULA = Path(__file__).resolve().parents[1]
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])        # as quatro combinações de duas entradas lógicas
AZUL, VERMELHO, REGIAO, ESCURO, LARANJA = "#1f5fad", "#c0392b", "#cfe3f5", "#14213d", "#a8500f"

# Vírgula decimal e sinal de menos tipográfico nos eixos, como pede o CLAUDE.md
VIRGULA = FuncFormatter(lambda v, _: f"{v:g}".replace(".", ",").replace("-", "−"))


def plano(alvo, com_regiao):
    """Figura e eixos no padrão das duas figuras: mesmo tamanho, mesmos limites, mesma legenda.

    alvo: a resposta certa de cada linha de X, 1 ou 0.
    com_regiao: se a legenda inclui a região onde o neurônio dispara.
    Os pontos de resposta 1 são círculos azuis, e os de resposta 0, quadrados
    vermelhos: forma e cor diferentes, para a figura continuar legível para
    quem não distingue vermelho de azul e em impressão em preto e branco.
    """
    fig, ax = plt.subplots(figsize=(6.4, 7.0))
    ax.scatter(X[alvo == 1, 0], X[alvo == 1, 1], s=420, c=AZUL, zorder=3)
    ax.scatter(X[alvo == 0, 0], X[alvo == 0, 1], s=420, c=VERMELHO, marker="s", zorder=3)
    ax.set_xlim(-0.5, 1.5)
    ax.set_ylim(-0.5, 1.5)
    ax.set_xticks([0, 0.5, 1])
    ax.set_yticks([0, 0.5, 1])
    ax.xaxis.set_major_formatter(VIRGULA)
    ax.yaxis.set_major_formatter(VIRGULA)
    ax.tick_params(labelsize=17)
    ax.set_xlabel("entrada $x_1$", fontsize=20)
    ax.set_ylabel("entrada $x_2$", fontsize=20)
    ax.set_aspect("equal")
    # legenda embaixo, fora do gráfico, centrada, para não cobrir nenhum ponto
    alcas = [plt.Line2D([], [], marker="o", ls="", ms=16, color=AZUL, label="resposta 1"),
             plt.Line2D([], [], marker="s", ls="", ms=16, color=VERMELHO, label="resposta 0")]
    if com_regiao:
        alcas.append(Patch(color=REGIAO, label="dispara"))
    fig.legend(handles=alcas, loc="lower center", ncol=len(alcas), frameon=False, fontsize=17,
               columnspacing=1.2, handletextpad=0.4)
    fig.tight_layout(rect=(0, 0.09, 1, 1))
    return fig, ax


def salva(fig, nome):
    """Salva a 200 dpi. bbox_inches="tight" recorta em volta de tudo o que foi
    desenhado, legenda incluída, para nada encostar na borda."""
    saida = AULA / "figuras" / nome
    fig.savefig(saida, dpi=200, bbox_inches="tight", pad_inches=0.2)
    plt.close(fig)
    print(f"salva: {saida}")


def reta_e():
    """O neurônio com w1 = 1, w2 = 1 e b = -1,5, o da tabela do E no quadro.

    Com esses pesos, a reta x1 + x2 - 1,5 = 0 passa longe dos quatro pontos, o
    que deixa a separação clara na primeira vez que a turma vê a ideia. A região
    azul é onde a soma z é maior ou igual a zero, isto é, onde o neurônio dispara.

    A seta é o vetor de pesos w = (1, 1), desenhado a partir de um ponto da reta.
    Ele é perpendicular à reta e aponta para o lado em que z cresce, que é o lado
    em que o neurônio dispara: é a geometria do produto escalar w·x.
    """
    w1, w2, b = 1.0, 1.0, -1.5
    fig, ax = plano(np.array([0, 0, 0, 1]), com_regiao=True)
    g1, g2 = np.meshgrid(np.linspace(-0.5, 1.5, 401), np.linspace(-0.5, 1.5, 401))
    ax.contourf(g1, g2, w1 * g1 + w2 * g2 + b >= 0, levels=[0.5, 1.5], colors=[REGIAO])
    xs = np.array([-0.5, 1.5])
    ax.plot(xs, -(w1 * xs + b) / w2, color=ESCURO, lw=2.5)        # a reta z = 0
    # o vetor w, com comprimento 0,3 no desenho, saindo do ponto (1,1; 0,4), que está na reta
    base = np.array([1.1, 0.4])
    ponta = base + 0.3 * np.array([w1, w2]) / np.hypot(w1, w2)
    ax.annotate("", xy=ponta, xytext=base, arrowprops=dict(arrowstyle="-|>", lw=3, color=ESCURO, mutation_scale=28))
    # w com a seta em cima, como todo vetor nos slides
    ax.text(ponta[0] + 0.03, ponta[1] + 0.03, r"$\vec{w}$", fontsize=26, color=ESCURO)
    salva(fig, "reta-e.png")


def xor():
    """Os quatro pontos do XOR, sem reta: nenhuma reta deixa os círculos de um
    lado e os quadrados do outro, porque eles estão em diagonal."""
    fig, ax = plano(np.array([0, 1, 1, 0]), com_regiao=False)
    salva(fig, "xor.png")


def neuronio():
    """O neurônio na representação clássica: bolinhas ligadas por linhas.

    Da esquerda para a direita: as entradas x₁ e x₂ e a entrada fixa 1, que
    carrega o viés; uma linha de cada entrada até o neurônio, com o peso que a
    multiplica; a soma Σ; a função degrau, desenhada como um degrau; a saída y.
    Tratar o viés como o peso de uma entrada que vale sempre 1 é o jeito
    clássico de desenhar: b·1 = b, então a conta é a mesma, z = w₁x₁ + w₂x₂ + b.
    Tudo é simétrico em torno da linha do meio, y = 2,8 nas coordenadas do desenho.
    """
    FUNDO, CINZA, SUAVE = "#F6F5F1", "#8a94a6", "#5e6a7d"
    fig, ax = plt.subplots(figsize=(14, 5.6), facecolor=FUNDO)
    ax.set_facecolor(FUNDO)
    ax.set_xlim(0, 14)
    ax.set_ylim(0, 5.6)
    ax.set_aspect("equal")
    ax.axis("off")

    def bolinha(centro, raio, fundo, borda, texto, cor_texto, tamanho):
        ax.add_patch(plt.Circle(centro, raio, facecolor=fundo, edgecolor=borda, lw=3, zorder=3))
        ax.text(*centro, texto, ha="center", va="center", fontsize=tamanho, color=cor_texto, zorder=4)

    def seta(de, para, cor=ESCURO):
        ax.annotate("", xy=para, xytext=de, zorder=2,
                    arrowprops=dict(arrowstyle="-|>", lw=2.5, color=cor, mutation_scale=24, shrinkA=0, shrinkB=0))

    meio = 2.8
    soma, r_soma, r_entrada = np.array([6.2, meio]), 0.95, 0.55
    entradas = [("$x_1$", "$w_1$", 4.4, "white", AZUL), ("$x_2$", "$w_2$", meio, "white", AZUL),
                ("$1$", "$b$", 1.2, "#e9eef5", CINZA)]
    for nome, peso, y, fundo, borda in entradas:
        centro = np.array([1.6, y])
        bolinha(centro, r_entrada, fundo, borda, nome, ESCURO, 30)
        # a linha sai da borda da bolinha e chega à borda do neurônio, na direção que liga os centros
        direcao = (soma - centro) / np.linalg.norm(soma - centro)
        seta(centro + r_entrada * direcao, soma - r_soma * direcao)
        # o peso fica a 42% do caminho, afastado da linha na perpendicular, para
        # não encostar nela: normal é a direção da linha girada de 90 graus
        normal = np.array([-direcao[1], direcao[0]])
        pos = centro + 0.42 * (soma - centro) + 0.42 * normal
        ax.text(*pos, peso, ha="center", va="center", fontsize=30, color=AZUL, zorder=5)

    bolinha(soma, r_soma, ESCURO, ESCURO, r"$\Sigma$", "white", 50)

    # a função degrau, desenhada dentro de uma caixa: 0 à esquerda de z = 0, 1 à direita
    cx, larg, alt = 9.4, 1.9, 1.4
    ax.add_patch(plt.Rectangle((cx - larg / 2, meio - alt / 2), larg, alt, facecolor="white",
                               edgecolor=ESCURO, lw=3, zorder=3))
    xs = [cx - 0.7, cx, cx, cx + 0.7]
    ys = [meio - 0.35, meio - 0.35, meio + 0.35, meio + 0.35]
    ax.plot(xs, ys, color=LARANJA, lw=4, zorder=4, solid_capstyle="round")

    saida = np.array([12.4, meio])
    bolinha(saida, r_entrada, LARANJA, LARANJA, "$y$", "white", 32)

    seta(soma + [r_soma, 0], [cx - larg / 2, meio])
    ax.text((soma[0] + r_soma + cx - larg / 2) / 2, meio + 0.3, "$z$", ha="center", fontsize=30, color=ESCURO)
    seta([cx + larg / 2, meio], saida - [r_entrada, 0])

    # legendas embaixo de cada parte, alinhadas numa mesma altura; a letra é
    # grande porque o slide mostra a figura menor que o tamanho dela
    for x, texto in [(1.6, "entradas"), (soma[0], "soma ponderada"), (cx, "degrau"), (saida[0], "saída")]:
        ax.text(x, 0.25, texto, ha="center", fontsize=23, color=SUAVE)
    salva(fig, "neuronio.png")


if __name__ == "__main__":
    reta_e()
    xor()
    neuronio()

perceptron.py

Aula 1: o perceptron de Rosenblatt aprende E e OU, e não consegue o XOR.

125 linesDownload
"""Aula 1: o perceptron de Rosenblatt aprende E e OU, e não consegue o XOR.

Rode, a partir da raiz do repositório:
    python aulas/01-neuronio/codigo/perceptron.py

Os pesos começam em zero, então não há sorteio: o resultado é sempre o mesmo, e
dá para refazer a tabela à mão, no quadro.
"""
from pathlib import Path

import numpy as np

# As quatro combinações possíveis de duas entradas lógicas, uma por linha
X = np.array([[0, 0],
              [0, 1],
              [1, 0],
              [1, 1]])

# A resposta certa para cada linha de X, calculada com os operadores lógicos do
# Python: & é o E, | é o OU e ^ é o XOR. Eles agem bit a bit sobre inteiros e,
# com 0 e 1, dão exatamente as tabelas-verdade; com arrays do NumPy, a conta
# vale elemento a elemento. Cuidado: em Python, ^ não é potência; potência é **.
x1, x2 = X[:, 0], X[:, 1]            # a primeira e a segunda coluna de X
ALVOS = {
    "E":   x1 & x2,   # [0 0 0 1]: 1 só quando as duas entradas são 1
    "OU":  x1 | x2,   # [0 1 1 1]: 1 quando pelo menos uma entrada é 1
    "XOR": x1 ^ x2,   # [0 1 1 0]: 1 quando só uma das entradas é 1
}


def degrau(z):
    """Função degrau: 1 se z >= 0, senão 0. É o disparo do neurônio."""
    return int(z >= 0)


def neuronio(x, w, b):
    """Soma cada entrada multiplicada pelo seu peso, soma o viés e passa pelo degrau."""
    return degrau(x @ w + b)


def treinar(X, alvo, taxa=1, epocas=20):
    """Regra do perceptron. A cada exemplo errado, corrige pesos e viés:
        w = w + taxa * (alvo - saida) * x
        b = b + taxa * (alvo - saida)
    A taxa é a taxa de aprendizado, a letra grega η (eta) dos slides.
    Se a saída foi 0 e devia ser 1, os pesos crescem na direção de x; se foi 1 e
    devia ser 0, diminuem. Uma época é uma passada por todos os exemplos.
    """
    w = np.zeros(2)
    b = 0.0
    for epoca in range(1, epocas + 1):
        erros = 0
        for x, t in zip(X, alvo):
            y = neuronio(x, w, b)
            if y != t:
                w = w + taxa * (t - y) * x
                b = b + taxa * (t - y)
                erros += 1
        print(f"  época {epoca:2d}: {erros} erro(s); depois dela, w = {w}, b = {b:+.0f}")
        if erros == 0:
            return w, b, epoca
    return w, b, None


def desenhar(resultados, caminho):
    """Os quatro pontos de cada função e a reta que o perceptron aprendeu.

    O neurônio dispara na região azul, onde w1*x1 + w2*x2 + b >= 0, e não dispara
    fora dela. Aprender é mover a reta w1*x1 + w2*x2 + b = 0 até ela separar os
    pontos. Um ponto em cima da reta tem soma 0, e o degrau dá 1: ele dispara.
    """
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from matplotlib.ticker import FuncFormatter
    # o que vem entre cifrões, como $x_1$, sai como fórmula, com o índice embaixo,
    # na fonte STIX, que vem com o matplotlib e é a mesma das equações dos slides
    plt.rcParams["mathtext.fontset"] = "stix"
    virgula = FuncFormatter(lambda v, _: f"{v:g}".replace(".", ",").replace("-", "−"))
    fig, eixos = plt.subplots(1, 3, figsize=(15, 6))
    xs = np.linspace(-0.5, 1.5, 2)
    g1, g2 = np.meshgrid(np.linspace(-0.5, 1.5, 201), np.linspace(-0.5, 1.5, 201))
    for ax, (nome, (w, b, epoca)) in zip(eixos, resultados.items()):
        alvo = ALVOS[nome]
        if epoca is not None and w[1] != 0:
            dispara = (w[0] * g1 + w[1] * g2 + b >= 0)
            ax.contourf(g1, g2, dispara, levels=[0.5, 1.5], colors=["#cfe3f5"])
            ax.plot(xs, -(w[0] * xs + b) / w[1], "k-")
            ax.set_title(f"{nome}: separou na época {epoca}")
        else:
            ax.set_title(f"{nome}: nenhuma reta separa")
        ax.scatter(X[alvo == 1, 0], X[alvo == 1, 1], s=360, c="#1f5fad", label="alvo 1", zorder=3)
        ax.scatter(X[alvo == 0, 0], X[alvo == 0, 1], s=360, c="#c0392b", marker="s", label="alvo 0", zorder=3)
        ax.set_xlim(-0.5, 1.5)
        ax.set_ylim(-0.5, 1.5)
        ax.set_xticks([0, 0.5, 1])
        ax.set_yticks([0, 0.5, 1])
        ax.xaxis.set_major_formatter(virgula)
        ax.yaxis.set_major_formatter(virgula)
        ax.set_xlabel("entrada $x_1$", fontsize=18)
        ax.set_ylabel("entrada $x_2$", fontsize=18)
        ax.tick_params(labelsize=15)
        ax.title.set_fontsize(18)
        ax.set_aspect("equal")
    # a legenda fica embaixo, fora dos gráficos, para não cobrir nenhum ponto
    from matplotlib.patches import Patch
    alcas, _ = eixos[0].get_legend_handles_labels()
    alcas.append(Patch(color="#cfe3f5", label="região onde o neurônio dispara"))
    fig.legend(handles=alcas, loc="lower center", ncol=3, frameon=False, fontsize=17)
    fig.tight_layout(rect=(0, 0.1, 1, 1))
    fig.savefig(caminho, dpi=200)


if __name__ == "__main__":
    resultados = {}
    for nome, alvo in ALVOS.items():
        print(f"\n{nome}")
        w, b, epoca = treinar(X, alvo)
        resultados[nome] = (w, b, epoca)
        if epoca is None:
            print(f"  depois de 20 épocas ainda erra: nenhuma reta separa os pontos de {nome}")
    figura = Path(__file__).resolve().parent.parent / "figuras" / "perceptron.png"
    figura.parent.mkdir(exist_ok=True)
    desenhar(resultados, figura)
    print(f"\nfigura salva em {figura}")