Code

Aula 6: MNIST: treino, validação e teste

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

animacoes.py

Os vídeos da aula 6, feitos com o Manim Community.

450 linesDownload
"""Os vídeos da aula 6, feitos com o Manim Community.

Rode, a partir da raiz do repositório, depois de mlp.py, que grava as probabilidades e os pesos, com o nome
dos vídeos que quer gravar, ou sem nome nenhum para gravar todos:
    python aulas/06-mnist-numpy/codigo/animacoes.py
    python aulas/06-mnist-numpy/codigo/animacoes.py softmax

Os três vídeos, na ordem da aula:
    digito-probabilidades   o dígito vira uma lista de 784 números, que a rede leva a dez probabilidades
    softmax                 a softmax em três quadros, as somas, a exponencial e a divisão pela soma, com
                            o exemplo do slide, z⃗ = (2; 1; 0); as somas menos 2 e as somas vezes 3
    neuronio                um neurônio oculto: o dígito, os 784 pesos dele na forma da imagem, o produto
                            pixel a pixel e a soma, com um 7 e com um 5 do teste

Todo render do curso é em 1080p, 1920 por 1080, a 30 quadros por segundo. Uma
opção grava só o último quadro, em segundos, para conferir a composição:
    --quadros

Precisa do Manim Community (pip install manim), do MiKTeX, o mesmo das equações
dos slides, e do ffmpeg. Cada vídeo sai em dois arquivos:
    aulas/06-mnist-numpy/videos/<nome>.mp4           o vídeo
    aulas/06-mnist-numpy/figuras/<nome>-quadro.png   o último quadro, que o slide mostra parado

As regras são as dos vídeos das aulas anteriores: sem título próprio, texto com
pelo menos 30 pontos do Manim, equação com pelo menos 32, tudo escrito, nada
sobreposto, cada texto num lugar fixo. E nada é sorteado: os dois dígitos são
os do teste, índices 0 e 61, e as probabilidades são as que a rede de
referência deu a eles, lidas de resultados/mlp.json.
"""
import json
import shutil
import subprocess
import sys
import time
from pathlib import Path

import numpy as np
from manim import (DOWN, LEFT, RIGHT, UP, Arrow, Create, FadeIn, FadeOut, GrowArrow, LaggedStart, Line, MathTex, Rectangle,
                   RoundedRectangle, Scene, TexTemplate, Text, Transform, TransformFromCopy, VGroup, Write, config,
                   interpolate_color, tempconfig, ManimColor)
from manimpango import register_font

RAIZ = Path(__file__).resolve().parents[3]
AULA = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mnist import carrega  # noqa: E402

for arquivo in ("IBMPlexSans-Regular.ttf", "IBMPlexSans-SemiBold.ttf", "SourceSerif4-Semibold.ttf"):
    register_font(str(RAIZ / "infra" / "fontes" / arquivo))
SANS = "IBM Plex Sans"

FUNDO, ESCURO, TEXTO, SUAVE = "#F6F5F1", "#14213D", "#3B4658", "#5E6A7D"
AZUL, LARANJA, CINZA, BORDA = "#1F5FAD", "#A8500F", "#8A94A6", "#DCDFE4"

MODELO = TexTemplate(preamble="\\usepackage[T1]{fontenc}\n\\usepackage{amsmath}\n\\usepackage{stix2}\n")
config.tex_template = MODELO

EXEMPLOS = (0, 61)          # o 7 em que a rede tem certeza e o 8 em que ela hesita entre o 8 e o 2


def texto(s, tamanho=30, cor=TEXTO):
    """Texto corrido, na fonte dos slides."""
    return Text(s, font=SANS, font_size=tamanho, color=cor)


def eq(tex, tamanho=34, cor=ESCURO):
    """Equação em LaTeX, na STIX Two."""
    return MathTex(tex, font_size=tamanho, color=cor, tex_template=MODELO)


def decimal(v):
    """Probabilidade em LaTeX, com vírgula: três casas, ou quatro perto de 1, para 0,9998 não virar 1,000."""
    return (f"{v:.3f}" if v < 0.9995 else f"{v:.4f}").replace(".", "{,}")


def cor_do_pixel(v):
    """O tom de um pixel: 0 é branco, 1 é o azul-escuro dos títulos, como tinta no papel."""
    return interpolate_color(ManimColor("#FFFFFF"), ManimColor(ESCURO), float(v))


class DigitoProbabilidades(Scene):
    """Um dígito do teste vira uma lista de 784 números, e a rede devolve dez probabilidades.

    À esquerda, o dígito, 28 por 28 quadradinhos com o tom de cada pixel. As
    linhas da imagem descem, uma depois da outra, e se enfileiram numa faixa
    embaixo: 784 números, a primeira linha da imagem primeiro. A faixa entra na
    rede, no meio, e a rede devolve, à direita, dez barras, a probabilidade de
    cada dígito. Depois, o mesmo com um 8 em que a rede hesita entre o 8 e o 2.
    """

    def construct(self):
        self.camera.background_color = FUNDO
        X, t = carrega()["teste"]
        resultado = json.loads((AULA / "resultados" / "mlp.json").read_text(encoding="utf-8"))
        P = np.array(resultado["escolha"]["probabilidades_teste_200"])
        a, b = EXEMPLOS

        # ------------------------------------------------ os lugares fixos de cada coisa
        Y_LEGENDA = 3.45
        lado, centro_digito = 0.12, np.array([-4.9, 0.9, 0])
        x_faixa, y_faixa, alt_faixa = (-6.6, 6.6), -2.75, 0.32
        largura_celula = (x_faixa[1] - x_faixa[0]) / 784
        centro_rede = np.array([0.25, 0.9, 0])
        x_barras, base, alt_max = np.linspace(2.85, 6.45, 10), -0.75, 3.2

        # ------------------------------------------------ o dígito: 784 quadradinhos
        def quadradinhos(x):
            g = VGroup()
            for k, v in enumerate(x):
                lin, col = divmod(k, 28)
                # o contorno fino da mesma cor fecha as frestas entre os quadradinhos vizinhos
                q = Rectangle(width=lado, height=lado, stroke_width=1, stroke_color=cor_do_pixel(v),
                              fill_color=cor_do_pixel(v), fill_opacity=1)
                q.move_to(centro_digito + np.array([(col - 13.5) * lado, (13.5 - lin) * lado, 0]))
                g.add(q)
            return g

        digito = quadradinhos(X[a])
        moldura = Rectangle(width=28 * lado, height=28 * lado, color=CINZA, stroke_width=2).move_to(centro_digito)
        linhas = [VGroup(*digito[28 * r:28 * r + 28]) for r in range(28)]

        # a faixa: as mesmas 784 cores, uma célula fina por pixel
        def faixa_de(x):
            g = VGroup()
            for k, v in enumerate(x):
                c = Rectangle(width=largura_celula, height=alt_faixa, stroke_width=1, stroke_color=cor_do_pixel(v),
                              fill_color=cor_do_pixel(v), fill_opacity=1)
                c.move_to(np.array([x_faixa[0] + (k + 0.5) * largura_celula, y_faixa, 0]))
                g.add(c)
            return g

        faixa = faixa_de(X[a])
        moldura_faixa = Rectangle(width=x_faixa[1] - x_faixa[0], height=alt_faixa, color=CINZA, stroke_width=2)
        moldura_faixa.move_to(np.array([0, y_faixa, 0]))
        nome_faixa = texto("784 números, um por pixel, a primeira linha da imagem primeiro", 30, SUAVE)
        nome_faixa.next_to(moldura_faixa, DOWN, buff=0.2)

        # ------------------------------------------------ a rede e as barras
        caixa = RoundedRectangle(width=2.6, height=1.5, corner_radius=0.18, color=ESCURO, stroke_width=2.5)
        caixa.move_to(centro_rede)
        dentro = VGroup(texto("a rede", 32, ESCURO), eq(r"784 \to 256 \to 10", 32)).arrange(DOWN, buff=0.16)
        dentro.move_to(centro_rede)
        seta_sobe = Arrow(np.array([centro_rede[0], y_faixa + alt_faixa / 2 + 0.08, 0]),
                          caixa.get_bottom() + DOWN * 0.06, buff=0, color=SUAVE, stroke_width=4)
        seta_lado = Arrow(caixa.get_right() + np.array([0.06, 0, 0]), np.array([x_barras[0] - 0.4, centro_rede[1], 0]),
                          buff=0, color=SUAVE, stroke_width=4)
        eixo = Line(np.array([x_barras[0] - 0.3, base, 0]), np.array([x_barras[-1] + 0.3, base, 0]),
                    color=ESCURO, stroke_width=2.5)
        nomes = VGroup(*[eq(str(d), 32, TEXTO).move_to(np.array([x, base - 0.3, 0])) for d, x in enumerate(x_barras)])

        def barras_de(p):
            """As dez barras: a maior em azul, a segunda em laranja se passar de 0,1, as outras em cinza."""
            ordem = np.argsort(p)[::-1]
            g = VGroup()
            for d, (x, v) in enumerate(zip(x_barras, p)):
                cor = AZUL if d == ordem[0] else (LARANJA if d == ordem[1] and v > 0.1 else CINZA)
                altura = max(v * alt_max, 0.004)
                r = Rectangle(width=0.26, height=altura, stroke_width=0, fill_color=cor, fill_opacity=1)
                r.move_to(np.array([x, base + altura / 2, 0]))
                g.add(r)
            valores = VGroup()
            for d in ordem[:2]:
                if p[d] > 0.1:
                    cor = AZUL if d == ordem[0] else LARANJA
                    valores.add(eq(decimal(p[d]), 30, cor).move_to(np.array([x_barras[d], base + p[d] * alt_max + 0.24, 0])))
            return g, valores

        barras, valores = barras_de(P[a])
        barras_zero = VGroup(*[r.copy().stretch_to_fit_height(0.004).move_to(np.array([r.get_x(), base + 0.002, 0]))
                               for r in barras])

        def legenda(s):
            return texto(s, 30, TEXTO).move_to(np.array([0, Y_LEGENDA, 0]))

        def troca_legenda(velha, nova):
            """Apaga a legenda e escreve a nova no mesmo lugar, uma depois da outra: as letras nunca se misturam."""
            self.play(FadeOut(velha), run_time=0.4)
            self.play(FadeIn(nova), run_time=0.5)
            return nova

        # ------------------------------------------------ 1. o dígito
        leg = legenda(f"um dígito do teste, o {t[a]}: 28 por 28 pixels, cada um de 0 a 1")
        self.play(Write(leg), Create(moldura), LaggedStart(*[FadeIn(l) for l in linhas], lag_ratio=0.06),
                  run_time=2.2)
        self.wait(1.2)

        # ------------------------------------------------ 2. as linhas descem e viram a faixa
        leg = troca_legenda(leg, legenda("linha após linha, a imagem vira uma lista de 784 números"))
        self.play(Create(moldura_faixa), run_time=0.8)
        self.play(LaggedStart(*[TransformFromCopy(linhas[r], VGroup(*faixa[28 * r:28 * r + 28]))
                                for r in range(28)], lag_ratio=0.12), run_time=4.5)
        self.play(Write(nome_faixa), run_time=1.0)
        self.wait(0.8)

        # ------------------------------------------------ 3. a rede e as dez probabilidades
        leg = troca_legenda(leg, legenda("a rede devolve dez probabilidades, que somam 1"))
        self.play(GrowArrow(seta_sobe), run_time=0.8)
        self.play(Create(caixa), Write(dentro), run_time=1.2)
        self.play(GrowArrow(seta_lado), Create(eixo), Write(nomes), FadeIn(barras_zero), run_time=1.2)
        self.play(Transform(barras_zero, barras), run_time=1.4)
        self.play(Write(valores), run_time=0.8)
        self.wait(1.6)

        # ------------------------------------------------ 4. um 8 em que a rede hesita
        novo_digito, nova_faixa = quadradinhos(X[b]), faixa_de(X[b])
        self.play(FadeOut(valores), run_time=0.5)
        leg = troca_legenda(leg, legenda(f"outro dígito, um {t[b]}: a rede hesita entre o 8 e o 2"))
        self.play(*[q.animate.set_fill(n.get_fill_color(), 1).set_stroke(n.get_fill_color())
                    for q, n in zip(digito, novo_digito)],
                  *[c.animate.set_fill(n.get_fill_color(), 1).set_stroke(n.get_fill_color())
                    for c, n in zip(faixa, nova_faixa)], run_time=1.8)
        novas_barras, novos_valores = barras_de(P[b])
        self.play(Transform(barras_zero, novas_barras), run_time=1.4)
        self.play(Write(novos_valores), run_time=0.8)
        self.wait(2.5)


# ================================================================ as frases do alto, nos vídeos novos

def no_alto(s):
    """A frase do alto do vídeo, que diz o que está acontecendo; cabe na largura com folga."""
    f = texto(s, 30).move_to(np.array([0, 3.45, 0]))
    assert f.width < 13, s
    return f


def troca(cena, atual, s):
    """Apaga a frase do alto e escreve a seguinte."""
    nova = no_alto(s)
    cena.play(FadeOut(atual), run_time=0.35)
    cena.play(Write(nova), run_time=0.9)
    return nova


def num(v, casas=3):
    """Número em LaTeX, com vírgula decimal e sem o espaço depois dela."""
    return f"{v:.{casas}f}".replace(".", "{,}")


# ================================================================ a softmax por dentro

ETAPAS_SOFTMAX = [((2.0, 1.0, 0.0), "As somas do exemplo do slide: 2, 1 e 0"),
                  ((0.0, -1.0, -2.0), "Menos 2 em todas as somas: as probabilidades não mudam"),
                  ((6.0, 3.0, 0.0), "Somas três vezes mais afastadas: a aposta fica firme")]


class Softmax(Scene):
    """A softmax em três quadros: as somas z, a exponencial e^z e a divisão pela soma, que dá as probabilidades.

    Os números são os do exemplo do slide, z⃗ = (2; 1; 0). Depois, as mesmas somas
    menos 2, que dão as mesmas probabilidades, e as somas vezes 3, que deixam a
    maior probabilidade perto de 1.
    """

    def construct(self):
        self.camera.background_color = FUNDO
        xs = (-4.7, -0.1, 4.5)                     # o centro de cada quadro: z, e^z, p
        base_z, base, alt = -0.75, -1.9, 2.6        # a linha de base de cada quadro e a altura máxima das barras
        largura_barra, passo = 0.6, 0.95

        def barras(valores, x0, cor, escala, base_):
            """Três barras a partir da base, com o número em cima (ou embaixo, se negativo)."""
            g, nums = VGroup(), VGroup()
            for i, v in enumerate(valores):
                h = max(abs(v) * escala, 0.02)
                cx = x0 + (i - 1) * passo
                r = Rectangle(width=largura_barra, height=h, stroke_width=0, fill_color=cor, fill_opacity=0.85)
                r.move_to(np.array([cx, base_ + (h / 2 if v >= 0 else -h / 2), 0]))
                g.add(r)
                casas = 3 if abs(v) < 10 else 1
                n = eq(num(v, casas) if not float(v).is_integer() or abs(v) >= 10 else f"{v:g}".replace("-", "-"),
                       32, cor)
                n.next_to(r, UP if v >= 0 else DOWN, buff=0.12)
                nums.add(n)
            return g, nums

        def quadro(z):
            ez = np.exp(z)
            p = ez / ez.sum()
            gz, nz = barras(z, xs[0], AZUL, 0.38, base_z)
            ge, ne = barras(ez, xs[1], LARANJA, alt / ez.max(), base)
            gp, np_ = barras(p, xs[2], ESCURO, alt, base)
            soma = eq(rf"\text{{soma}} = {num(ez.sum(), 3 if ez.sum() < 100 else 1)}", 32, SUAVE).move_to(
                np.array([xs[1], base - 0.75, 0]))
            soma_p = eq(r"\text{soma} = 1", 32, SUAVE).move_to(np.array([xs[2], base - 0.75, 0]))
            return VGroup(gz, nz, ge, ne, gp, np_, soma, soma_p)

        linhas = VGroup(*[Line(np.array([x - 1.4, b_, 0]), np.array([x + 1.4, b_, 0]), color=ESCURO, stroke_width=2.5)
                          for x, b_ in zip(xs, (base_z, base, base))])
        nomes = VGroup(eq(r"z_k", 40).move_to(np.array([xs[0], 2.55, 0])),
                       eq(r"e^{z_k}", 40).move_to(np.array([xs[1], 2.55, 0])),
                       eq(r"p_k = e^{z_k} / \text{soma}", 40).move_to(np.array([xs[2], 2.55, 0])))
        setas = VGroup(Arrow(np.array([xs[0] + 1.55, 0.3, 0]), np.array([xs[1] - 1.55, 0.3, 0]), buff=0, color=SUAVE,
                             stroke_width=5, max_tip_length_to_length_ratio=0.25),
                       Arrow(np.array([xs[1] + 1.55, 0.3, 0]), np.array([xs[2] - 1.55, 0.3, 0]), buff=0, color=SUAVE,
                             stroke_width=5, max_tip_length_to_length_ratio=0.25))
        z0, frase0 = ETAPAS_SOFTMAX[0]
        leg = no_alto(frase0)
        q = quadro(np.array(z0))
        self.play(Write(leg), Create(linhas), Write(nomes), run_time=1.6)
        self.play(Create(q[0]), Write(q[1]), run_time=1.2)
        self.wait(0.8)
        leg = troca(self, leg, "A exponencial deixa tudo positivo, e as grandes muito maiores")
        self.play(GrowArrow(setas[0]), run_time=0.6)
        self.play(TransformFromCopy(q[0], q[2]), Write(q[3]), Write(q[6]), run_time=1.4)
        self.wait(0.8)
        leg = troca(self, leg, "Dividindo pela soma, saem probabilidades: 0,665, 0,245 e 0,090")
        self.play(GrowArrow(setas[1]), run_time=0.6)
        self.play(TransformFromCopy(q[2], q[4]), Write(q[5]), Write(q[7]), run_time=1.4)
        self.wait(1.5)
        for z, frase_ in ETAPAS_SOFTMAX[1:]:
            leg = troca(self, leg, frase_)
            self.play(Transform(q, quadro(np.array(z))), run_time=1.8)
            self.wait(1.8)
        self.wait(1.5)


# ================================================================ o que um neurônio oculto faz com a imagem

NEURONIO = 4              # o neurônio 5 da figura dos pesos, contado do 1: a barra azul com laranja em cima
DIGITOS_NEURONIO = (0, 8)  # o 7 do índice 0 do teste e o 5 do índice 8: um com soma negativa, outro positiva


def cor_do_peso(v, limite):
    """Azul para peso negativo, branco no zero, laranja para positivo, como na figura dos pesos."""
    f = float(np.clip(v / limite, -1, 1))
    if f < 0:
        return interpolate_color(ManimColor("#FFFFFF"), ManimColor(AZUL), -f)
    return interpolate_color(ManimColor("#FFFFFF"), ManimColor(LARANJA), f)


class NeuronioPixel(Scene):
    """Um neurônio oculto: o dígito, os 784 pesos dele na forma da imagem, o produto pixel a pixel e a soma.

    Os pesos são os do neurônio 5 da rede da aula, lidos de resultados/mlp.json. A
    soma mostrada é a dos 784 produtos, antes do viés do neurônio, que é um número
    só e não muda o desenho. Com o 7, a tinta cai sobre os pesos azuis, e a soma é
    negativa; com o 5, sobre o laranja, e a soma é positiva.
    """

    def construct(self):
        self.camera.background_color = FUNDO
        X, t = carrega()["teste"]
        resultado = json.loads((AULA / "resultados" / "mlp.json").read_text(encoding="utf-8"))
        w = np.array(resultado["escolha"]["pesos_16"])[NEURONIO]
        limite_w = np.abs(np.array(resultado["escolha"]["pesos_16"])).max()
        a, b = DIGITOS_NEURONIO
        limite_p = max(np.abs(X[a] * w).max(), np.abs(X[b] * w).max())
        lado, y0 = 0.1, 0.45
        centros = (np.array([-4.7, y0, 0]), np.array([-0.1, y0, 0]), np.array([4.5, y0, 0]))

        def grade(cores, centro):
            """28 por 28 quadradinhos, um por pixel, com a cor de cada um."""
            g = VGroup()
            for k, c in enumerate(cores):
                i, j = divmod(k, 28)
                g.add(Rectangle(width=lado, height=lado, stroke_width=0, fill_color=c, fill_opacity=1).move_to(
                    centro + np.array([(j - 13.5) * lado, (13.5 - i) * lado, 0])))
            borda = Rectangle(width=28 * lado, height=28 * lado, color=CINZA, stroke_width=2).move_to(centro)
            return VGroup(g, borda)

        def digito(i):
            return grade([cor_do_pixel(v) for v in X[i]], centros[0])

        def produto(i):
            return grade([cor_do_peso(v, limite_p) for v in X[i] * w], centros[2])

        def soma(i):
            return eq(rf"\sum_{{j=1}}^{{784}} w_j\, x_j = {num(X[i] @ w, 2)}", 44).move_to(np.array([0, -2.65, 0]))

        pesos = grade([cor_do_peso(v, limite_w) for v in w], centros[1])
        rotulos = VGroup(texto("o dígito", 30, SUAVE).next_to(pesos, DOWN, buff=0.3).set_x(centros[0][0]),
                         texto(f"os pesos do neurônio {NEURONIO + 1}", 30, SUAVE).next_to(pesos, DOWN, buff=0.3),
                         texto("pixel vezes peso", 30, SUAVE).next_to(pesos, DOWN, buff=0.3).set_x(centros[2][0]))
        sinais = VGroup(eq(r"\times", 56).move_to(np.array([-2.4, y0, 0])), eq("=", 56).move_to(np.array([2.2, y0, 0])))

        leg = no_alto(f"Um neurônio oculto tem um peso por pixel: 784, na forma da imagem")
        self.play(Write(leg), FadeIn(pesos, lag_ratio=0.002), run_time=2.0)
        self.play(Write(rotulos[1]), run_time=0.9)
        self.wait(1.0)
        leg = troca(self, leg, f"A imagem de um {t[a]} entra: cada pixel vezes o seu peso")
        d, pr = digito(a), produto(a)
        self.play(FadeIn(d, lag_ratio=0.002), Write(rotulos[0]), Write(sinais[0]), run_time=1.6)
        self.play(Write(sinais[1]), FadeIn(pr, lag_ratio=0.002), Write(rotulos[2]), run_time=1.8)
        self.wait(0.8)
        leg = troca(self, leg, "A soma dos produtos: tinta no laranja soma, tinta no azul subtrai")
        s = soma(a)
        self.play(Write(s), run_time=1.2)
        self.wait(1.8)
        leg = troca(self, leg, f"Outro dígito, um {t[b]}: a tinta cai no laranja, e a soma sobe")
        self.play(Transform(d, digito(b)), Transform(pr, produto(b)), Transform(s, soma(b)), run_time=2.0)
        self.wait(1.5)
        leg = troca(self, leg, "Cada neurônio responde a um padrão de tinta na imagem")
        self.wait(3)


CENAS = {"digito-probabilidades": DigitoProbabilidades, "softmax": Softmax, "neuronio": NeuronioPixel}


def grava(nome, largura=1920, altura=1080, quadros=30, crf=24, so_ultimo_quadro=False):
    """Desenha a cena e grava o mp4 e o png do último quadro, em videos/ e figuras/ da aula.

    O manim trabalha numa pasta temporária, apagada no fim. O mp4 sai de novo
    pelo ffmpeg, em H.264 com qualidade constante (crf 24), para ficar abaixo dos
    20 MB que o deck aceita por vídeo. Com so_ultimo_quadro, o manim pula as
    animações e grava só o último quadro. Devolve o tempo gasto, em segundos.
    """
    inicio = time.perf_counter()
    pasta_videos, pasta_quadros = AULA / "videos", AULA / "figuras"
    pasta_videos.mkdir(parents=True, exist_ok=True)
    pasta_quadros.mkdir(parents=True, exist_ok=True)
    trabalho = pasta_videos / f"_manim-{nome}"
    opcoes = {"pixel_width": largura, "pixel_height": altura, "frame_rate": quadros, "media_dir": str(trabalho),
              "output_file": nome, "background_color": FUNDO, "disable_caching": True, "progress_bar": "none",
              "verbosity": "WARNING", "tex_template": MODELO}
    if so_ultimo_quadro:
        opcoes.update(save_last_frame=True, write_to_movie=False)
    with tempconfig(opcoes):
        cena = CENAS[nome]()
        cena.render()
        if so_ultimo_quadro:
            png_manim = Path(cena.renderer.file_writer.image_file_path)
        else:
            mp4 = Path(cena.renderer.file_writer.movie_file_path)
    png = pasta_quadros / f"{nome}-quadro.png"   # -quadro: não pisar nas figuras estáticas
    if so_ultimo_quadro:
        shutil.copyfile(png_manim, png)
        arquivos = [png]
    else:
        destino = pasta_videos / f"{nome}.mp4"
        subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(mp4), "-c:v", "libx264", "-crf", str(crf),
                        "-preset", "slow", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-an", str(destino)],
                       check=True)
        subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-sseof", "-0.5", "-i", str(destino),
                        "-frames:v", "1", "-update", "1", str(png)], check=True)
        arquivos = [destino, png]
    shutil.rmtree(trabalho, ignore_errors=True)
    segundos = time.perf_counter() - inicio
    for arquivo in arquivos:
        print(f"gravado: {arquivo} ({arquivo.stat().st_size / 1e6:.1f} MB)")
    print(f"{nome}: {segundos:.0f} s")
    return segundos


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")
    args = sys.argv[1:]
    so_quadro = "--quadros" in args
    for nome in [a for a in args if a != "--quadros"] or list(CENAS):
        grava(nome, so_ultimo_quadro=so_quadro)

esqueleto.py

Aula 6, o esqueleto da entrega: um MLP no MNIST, em NumPy.

170 linesDownload
"""Aula 6, o esqueleto da entrega: um MLP no MNIST, em NumPy.

Este arquivo vai para a turma, junto com mnist.py, que carrega os dados. Antes,
rode uma vez, a partir da raiz do repositório:
    python infra/baixar_dados.py

Faltam três funções, marcadas com A FAZER: softmax, para_a_frente e
retropropagacao. O resto já funciona: os pesos iniciais, a perda, a acurácia, a
conferência do gradiente pela derivada numérica, o laço do treino e o gráfico.
Com as três escritas, rode:
    python aulas/06-mnist-numpy/codigo/esqueleto.py

O programa confere a retropropagação antes de treinar; se a diferença passar de
10⁻⁵, a conta está errada, e o treino nem começa. Depois treina, mostra a
acurácia na validação a cada época, mede o teste uma vez no fim e grava as
curvas em curvas-da-entrega.png, ao lado deste arquivo.

A entrega: a acurácia no teste, com a semente e a versão dos dados que o
programa imprime, e o gráfico das curvas de treino e de validação.

As matrizes seguem a convenção das aulas 3 e 5: X tem um exemplo por linha, N
por 784; W1 tem uma linha por neurônio oculto, largura por 784; W2, 10 por
largura. Uma camada inteira, para o lote todo, é X @ W1.T + b1.
"""
import sys
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parent))
from mnist import carrega, versao  # noqa: E402

SEMENTE = 1         # troque, e anote: o resultado vai junto com a semente
TAXA = 0.1          # η, a taxa de aprendizado
LOTE = 64           # exemplos por passo do gradiente
EPOCAS = 10
LARGURA = 64        # neurônios na camada oculta


def sorteia_pesos(largura, semente):
    """Pesos iniciais de uma rede 784, largura, 10, com desvio 1/√n, n as entradas do neurônio."""
    rng = np.random.default_rng(semente)
    return {"W1": rng.normal(0, 1 / np.sqrt(784), (largura, 784)), "b1": np.zeros(largura),
            "W2": rng.normal(0, 1 / np.sqrt(largura), (10, largura)), "b2": np.zeros(10)}


def softmax(Z):
    """A FAZER. Recebe Z, N por 10, e devolve P, N por 10: a softmax de cada linha.

    p_k = e^{z_k} / Σ_j e^{z_j}. Subtraia o maior valor de cada linha antes da
    exponencial: o resultado não muda, e e^{z} não estoura. Ajuda: Z.max(axis=1,
    keepdims=True) e E.sum(axis=1, keepdims=True).
    """
    raise NotImplementedError("escreva a softmax")


def para_a_frente(p, X):
    """A FAZER. Recebe os pesos p e um lote X, N por 784. Devolve (H, P).

    H = tanh(X W1ᵀ + b1), N por largura; P = softmax(H W2ᵀ + b2), N por 10.
    """
    raise NotImplementedError("escreva a passada para a frente")


def perda(P, t):
    """A entropia cruzada média: −ln da probabilidade que a rede deu ao dígito certo."""
    return float(-np.mean(np.log(P[np.arange(len(t)), t] + 1e-12)))


def retropropagacao(p, X, t):
    """A FAZER. Devolve um dicionário com o gradiente da perda média em relação a W1, b1, W2 e b2.

    O δ da saída é (P − Y)/N, com Y o rótulo certo escrito como 1 na posição do
    dígito e 0 nas outras, e N o tamanho do lote. O δ da camada oculta é
    (δ da saída @ W2) vezes 1 − H², elemento a elemento. Cada gradiente de peso é
    δᵀ vezes a entrada da camada; o de cada viés, a soma do δ sobre o lote.
    Cada gradiente tem o mesmo formato do peso a que se refere.
    """
    raise NotImplementedError("escreva a retropropagação")


def acuracia(P, t):
    """A fração de acertos: a maior probabilidade tem de ser a do dígito certo."""
    return float(np.mean(P.argmax(axis=1) == t))


def confere_gradiente(X, t, quantos=30, h=1e-5):
    """Compara a sua retropropagação com a derivada numérica, em pesos sorteados.

    Devolve a maior diferença relativa entre as duas contas, que tem de ficar
    perto de 10⁻⁷ ou menor.
    """
    p = sorteia_pesos(16, 3)
    g = retropropagacao(p, X, t)
    rng = np.random.default_rng(3)
    pior = 0.0
    for _ in range(quantos):
        nome = rng.choice(list(p))
        idx = tuple(rng.integers(0, s) for s in p[nome].shape)
        guardado = p[nome][idx]
        p[nome][idx] = guardado + h
        mais = perda(para_a_frente(p, X)[1], t)
        p[nome][idx] = guardado - h
        menos = perda(para_a_frente(p, X)[1], t)
        p[nome][idx] = guardado
        numerica = (mais - menos) / (2 * h)
        pior = max(pior, abs(numerica - g[nome][idx]) / max(1e-8, abs(numerica) + abs(g[nome][idx])))
    return pior


def treina(treino, validacao):
    """O laço do treino: lotes embaralhados a cada época, um passo do gradiente por lote.

    Devolve (historico, melhores_pesos, melhor_epoca), com a melhor época escolhida
    pela acurácia na validação.
    """
    X, t = treino
    p = sorteia_pesos(LARGURA, SEMENTE)
    rng = np.random.default_rng(SEMENTE + 1000)
    historico = {"perda_treino": [], "perda_validacao": [], "acuracia_validacao": []}
    melhor, melhor_epoca = None, 0
    for epoca in range(1, EPOCAS + 1):
        ordem = rng.permutation(len(X))
        for inicio in range(0, len(X), LOTE):
            i = ordem[inicio:inicio + LOTE]
            g = retropropagacao(p, X[i], t[i])
            for nome in p:
                p[nome] -= TAXA * g[nome]
        historico["perda_treino"].append(perda(para_a_frente(p, X)[1], t))
        _, P = para_a_frente(p, validacao[0])
        historico["perda_validacao"].append(perda(P, validacao[1]))
        historico["acuracia_validacao"].append(acuracia(P, validacao[1]))
        print(f"época {epoca:>2}: perda no treino {historico['perda_treino'][-1]:.4f}, "
              f"na validação {historico['perda_validacao'][-1]:.4f}; "
              f"acurácia na validação {100 * historico['acuracia_validacao'][-1]:.2f}%")
        if melhor is None or historico["acuracia_validacao"][-1] > max(historico["acuracia_validacao"][:-1]):
            melhor, melhor_epoca = {k: v.copy() for k, v in p.items()}, epoca
    return historico, melhor, melhor_epoca


def desenha(historico, arquivo):
    """As curvas da perda no treino e na validação, época a época, num png."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    epocas = range(1, len(historico["perda_treino"]) + 1)
    fig, ax = plt.subplots(figsize=(7, 4.5))
    ax.plot(epocas, historico["perda_treino"], "o-", label="treino")
    ax.plot(epocas, historico["perda_validacao"], "s--", label="validação")
    ax.set_xlabel("época")
    ax.set_ylabel("perda, entropia cruzada")
    ax.legend()
    fig.tight_layout()
    fig.savefig(arquivo, dpi=150)
    print(f"gráfico gravado em {arquivo}")


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")
    conjuntos = carrega()
    print(f"semente {SEMENTE}, versão dos dados {versao()}, largura {LARGURA}, taxa {TAXA}, lote {LOTE}")
    dif = confere_gradiente(conjuntos["treino"][0][:20], conjuntos["treino"][1][:20])
    print(f"retropropagação contra a derivada numérica: maior diferença relativa {dif:.1e}")
    if dif > 1e-5:
        sys.exit("a retropropagação não confere com a derivada numérica: corrija antes de treinar")
    historico, pesos, epoca = treina(conjuntos["treino"], conjuntos["validacao"])
    _, P = para_a_frente(pesos, conjuntos["teste"][0])
    print(f"melhor época na validação: {epoca}")
    print(f"teste, medido uma vez: acurácia {100 * acuracia(P, conjuntos['teste'][1]):.2f}%")
    desenha(historico, Path(__file__).resolve().parent / "curvas-da-entrega.png")

figuras_slides.py

Figuras dos slides da aula 6: o MNIST em NumPy.

335 linesDownload
"""Figuras dos slides da aula 6: o MNIST em NumPy.

Rode, a partir da raiz do repositório, depois de mlp.py, que grava os números:
    python aulas/06-mnist-numpy/codigo/figuras_slides.py

Gera, a 200 dpi, em aulas/06-mnist-numpy/figuras/:
    digito-medio.png o dígito médio de cada classe, a imagem da capa
    digitos.png      quatro exemplos de cada dígito, do conjunto de treino
    rede.png         a rede 784, 256, 10, em bolinhas
    saida.png        dois dígitos do teste e as dez probabilidades que a rede dá a cada um
    larguras.png     a acurácia na validação, época a época, para as três larguras
    curvas.png       a perda e a acurácia no treino e na validação da rede escolhida
    sobreajuste.png  a mesma rede treinada com só mil exemplos: o treino decora
    erros.png        os primeiros dígitos do teste que a rede erra
    confusao.png     a matriz de confusão do teste: o dígito certo contra o que a rede respondeu
    pesos.png        os pesos de 16 neurônios ocultos, cada um como uma imagem 28 por 28

Os números vêm de resultados/mlp.json, gravado por mlp.py; as imagens, de mnist.py.
Nada aqui treina rede.
"""
import json
import sys
from pathlib import Path

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

sys.path.insert(0, str(Path(__file__).resolve().parent))
from mnist import carrega  # noqa: E402

plt.rcParams["mathtext.fontset"] = "stix"
plt.rcParams["hatch.color"] = "white"     # a hachura em branco, sobre a barra laranja
plt.rcParams["hatch.linewidth"] = 2.0

AULA = Path(__file__).resolve().parents[1]
AZUL, LARANJA, ESCURO, CINZA, TEXTO = "#1f5fad", "#a8500f", "#14213d", "#8a94a6", "#3b4658"
VIRGULA = FuncFormatter(lambda v, _: f"{v:g}".replace(".", ",").replace("-", "−"))
PORCENTO = FuncFormatter(lambda v, _: f"{100 * v:g}%".replace(".", ","))
# os dois exemplos do teste que a figura da saída e o vídeo mostram: o 7 do
# índice 0, em que a rede tem certeza, e o 8 do índice 61, em que ela hesita
EXEMPLOS = (0, 61)


def salva(fig, nome, fundo="white"):
    """Salva a 200 dpi, recortada em volta do que foi desenhado."""
    saida = AULA / "figuras" / nome
    saida.parent.mkdir(exist_ok=True)
    fig.savefig(saida, dpi=200, bbox_inches="tight", pad_inches=0.2, facecolor=fundo)
    plt.close(fig)
    print(f"salva: {saida}")


def digito_medio(conjuntos):
    """A imagem da capa: o dígito médio de cada classe, de 0 a 9, em duas linhas de cinco.

    Cada quadro é a média, pixel a pixel, de todos os exemplos de treino daquele
    dígito, uns 5 mil por classe. Onde quase todos passam o traço, como na haste
    do 1, a média fica escura; onde cada um escreve de um jeito, ela se espalha
    clara. As cores vão do fundo dos slides, #F6F5F1, ao azul-escuro dos títulos,
    e a figura sai com o fundo dos slides, para não fazer um retângulo branco na
    capa. Os pixels ficam quadrados, sem suavizar, como a rede os vê.
    """
    X, t = conjuntos["treino"]
    papel = "#F6F5F1"
    tinta = LinearSegmentedColormap.from_list("papel-tinta", [papel, ESCURO])
    fig, eixos = plt.subplots(2, 5, figsize=(10, 4.2), facecolor=papel)
    for d, ax in enumerate(eixos.flat):
        media = X[t == d].mean(axis=0).reshape(28, 28)
        ax.imshow(media, cmap=tinta, vmin=0, vmax=media.max(), interpolation="nearest")
        ax.axis("off")
    fig.subplots_adjust(wspace=0.04, hspace=0.04)
    salva(fig, "digito-medio.png", fundo=papel)


def decimal(v):
    """Probabilidade com três casas e vírgula; perto de 1, com quatro, para 0,9998 não virar 1,000."""
    return (f"{v:.3f}" if v < 0.9995 else f"{v:.4f}").replace(".", ",")


def imagem(ax, x):
    """Um dígito de 784 números desenhado como a imagem de 28 por 28: 0 branco, 1 preto."""
    ax.imshow(x.reshape(28, 28), cmap="gray_r", vmin=0, vmax=1, interpolation="nearest")
    ax.set_xticks([])
    ax.set_yticks([])
    for borda in ax.spines.values():
        borda.set_color(CINZA)


def digitos(conjuntos):
    """Quatro exemplos de cada dígito, de 0 a 9, os primeiros do conjunto de treino."""
    X, t = conjuntos["treino"]
    fig, eixos = plt.subplots(4, 10, figsize=(10, 4.4))
    for d in range(10):
        indices = np.flatnonzero(t == d)[:4]
        for linha, i in enumerate(indices):
            imagem(eixos[linha, d], X[i])
        eixos[0, d].set_title(str(d), fontsize=20, color=ESCURO, pad=8)
    fig.subplots_adjust(wspace=0.08, hspace=0.08)
    salva(fig, "digitos.png")


def rede(largura):
    """A rede em bolinhas: 784 entradas, `largura` neurônios ocultos e 10 saídas.

    Só alguns neurônios de cada camada aparecem desenhados, com reticências no
    meio; as saídas são as dez, com o dígito de cada uma. As ligações, todas com
    todas entre camadas vizinhas, vão em cinza claro, atrás das bolinhas.
    """
    fig, ax = plt.subplots(figsize=(10, 6.2))
    colunas = {"entrada": (0.0, [3.6, 2.8, 2.0, 1.2, -1.2, -2.0, -2.8, -3.6]),
               "oculta": (4.4, [3.0, 2.2, 1.4, -1.4, -2.2, -3.0]),
               "saida": (8.8, list(np.linspace(3.6, -3.6, 10)))}
    raio = 0.3
    for (xa, ya), (xb, yb) in ((colunas["entrada"], colunas["oculta"]), (colunas["oculta"], colunas["saida"])):
        for y1 in ya:
            for y2 in yb:
                ax.plot([xa, xb], [y1, y2], color="#d5d9e0", lw=0.8, zorder=0)
    for nome, (x, ys) in colunas.items():
        for k, y in enumerate(ys):
            ax.add_patch(plt.Circle((x, y), raio, facecolor="white", edgecolor=ESCURO, lw=2, zorder=2))
            if nome == "saida":
                ax.text(x, y, str(k), ha="center", va="center", fontsize=15, color=ESCURO, zorder=3)
        if nome != "saida":
            ax.text(x, 0, "⋮", ha="center", va="center", fontsize=30, color=ESCURO)
    rotulos = (("784 pixels", "entrada"), (f"{largura} neurônios\ncom a tanh", "oculta"),
               ("10 saídas\ncom a softmax", "saida"))
    for texto, nome in rotulos:
        ax.text(colunas[nome][0], -4.35, texto, ha="center", va="top", fontsize=17, color=TEXTO, linespacing=1.3)
    ax.set_xlim(-1.4, 10.4)
    ax.set_ylim(-5.9, 4.1)
    ax.set_aspect("equal")
    ax.axis("off")
    salva(fig, "rede.png")


def saida(conjuntos, resultado):
    """Dois dígitos do teste, à esquerda, e as dez probabilidades da rede, à direita.

    A barra mais alta, a resposta da rede, vai em azul, com o valor escrito em
    cima; as outras, em cinza. No 8 em que a rede hesita, a segunda barra também
    leva o valor, em laranja, com hachura, para a figura valer em preto e branco.
    """
    X, t = conjuntos["teste"]
    P = np.array(resultado["escolha"]["probabilidades_teste_200"])
    fig, eixos = plt.subplots(2, 2, figsize=(10, 6.6), gridspec_kw={"width_ratios": [1, 2.6]})
    for linha, i in enumerate(EXEMPLOS):
        imagem(eixos[linha, 0], X[i])
        eixos[linha, 0].set_title(f"o dígito certo: {t[i]}", fontsize=16, color=ESCURO, pad=8)
        ax = eixos[linha, 1]
        ordem = np.argsort(P[i])[::-1]
        cores = [CINZA] * 10
        cores[ordem[0]] = AZUL
        barras = ax.bar(range(10), P[i], color=cores, width=0.7)
        ax.text(ordem[0], P[i][ordem[0]] + 0.04, decimal(P[i][ordem[0]]), ha="center",
                va="bottom", fontsize=15, color=AZUL)
        if P[i][ordem[1]] > 0.1:
            barras[ordem[1]].set_facecolor(LARANJA)
            barras[ordem[1]].set_hatch("//")
            ax.text(ordem[1], P[i][ordem[1]] + 0.04, decimal(P[i][ordem[1]]), ha="center",
                    va="bottom", fontsize=15, color=LARANJA)
        ax.set_ylim(0, 1.22)
        ax.set_yticks([0, 0.5, 1])
        ax.yaxis.set_major_formatter(VIRGULA)
        ax.set_xticks(range(10))
        ax.tick_params(labelsize=15)
        ax.set_ylabel("probabilidade", fontsize=15)
        for lado in ("top", "right"):
            ax.spines[lado].set_visible(False)
    eixos[1, 1].set_xlabel("dígito", fontsize=15)
    fig.tight_layout(h_pad=2.2, w_pad=2.5)
    salva(fig, "saida.png")


def larguras(resultado):
    """A acurácia na validação, época a época, para as três larguras: cor, traço e marcador diferentes."""
    fig, ax = plt.subplots(figsize=(8.4, 5.2))
    estilos = {"16": (CINZA, "o", (0, (2, 2))), "64": (LARANJA, "s", (0, (6, 3))), "256": (AZUL, "D", "-")}
    for largura, (cor, marcador, traco) in estilos.items():
        h = resultado["redes"][largura]["historico"]["acuracia_validacao"]
        ax.plot(range(1, len(h) + 1), h, color=cor, marker=marcador, ms=7, ls=traco, lw=2.4,
                label=f"{largura} neurônios")
    ax.set_xlim(0.5, 20.5)
    ax.set_xticks([1, 5, 10, 15, 20])
    ax.set_ylim(0.888, 0.98)
    ax.set_yticks([0.90, 0.92, 0.94, 0.96, 0.98])
    ax.yaxis.set_major_formatter(PORCENTO)
    ax.tick_params(labelsize=17)
    ax.set_xlabel("época", fontsize=18)
    ax.set_ylabel("acurácia na validação", fontsize=18)
    ax.grid(axis="y", color="#e3e6eb", lw=1)
    fig.legend(loc="lower center", ncol=3, frameon=False, fontsize=17, columnspacing=1.6)
    fig.tight_layout(rect=(0, 0.1, 1, 1))
    salva(fig, "larguras.png")


def par_de_paineis(historico, nome, marca=None, teto=None):
    """Perda à esquerda e acurácia à direita, treino em azul cheio e validação em laranja tracejado.

    marca: uma época para marcar com uma linha vertical cinza, a da menor perda
    na validação; teto: o limite de cima do eixo da perda, que começa em zero.
    """
    epocas = np.arange(1, len(historico["perda_treino"]) + 1)
    muitas = len(epocas) > 40
    fig, eixos = plt.subplots(1, 2, figsize=(11, 4.6))
    for ax, chave, rotulo in ((eixos[0], "perda", "perda, entropia cruzada"), (eixos[1], "acuracia", "acurácia")):
        ax.set_box_aspect(0.75)
        ax.plot(epocas, historico[f"{chave}_treino"], color=AZUL, lw=2.6,
                marker=None if muitas else "o", ms=6)
        ax.plot(epocas, historico[f"{chave}_validacao"], color=LARANJA, lw=2.6, ls=(0, (6, 3)),
                marker=None if muitas else "s", ms=6)
        if marca:
            ax.axvline(marca, color=CINZA, lw=1.6, ls=(0, (2, 2)), zorder=0)
        ax.set_xlabel("época", fontsize=18)
        ax.set_ylabel(rotulo, fontsize=18)
        ax.tick_params(labelsize=16)
    if teto:
        eixos[0].set_ylim(0, teto)
    eixos[0].yaxis.set_major_formatter(VIRGULA)
    eixos[1].yaxis.set_major_formatter(PORCENTO)
    for ax in eixos:
        ax.xaxis.set_major_formatter(VIRGULA)
    alcas = [plt.Line2D([], [], color=AZUL, lw=2.6, marker=None if muitas else "o", label="treino"),
             plt.Line2D([], [], color=LARANJA, lw=2.6, ls=(0, (6, 3)), marker=None if muitas else "s",
                        label="validação")]
    if marca:
        alcas.append(plt.Line2D([], [], color=CINZA, lw=1.6, ls=(0, (2, 2)),
                                label=f"menor perda na validação, época {marca}"))
    fig.legend(handles=alcas, loc="lower center", ncol=len(alcas), frameon=False, fontsize=17, columnspacing=1.8)
    fig.tight_layout(rect=(0, 0.11, 1, 1), w_pad=3)
    salva(fig, nome)


def erros(conjuntos, resultado, quantos=16):
    """Os primeiros `quantos` dígitos do teste que a rede erra, com a resposta certa e a da rede."""
    X, t = conjuntos["teste"]
    previsto = np.array(resultado["escolha"]["previsto_teste"])
    errados = np.flatnonzero(previsto != t)[:quantos]
    fig, eixos = plt.subplots(2, quantos // 2, figsize=(12, 4.2))
    for ax, i in zip(eixos.flat, errados):
        imagem(ax, X[i])
        ax.set_xlabel(f"certo {t[i]}\nrede {previsto[i]}", fontsize=15, color=ESCURO, linespacing=1.25)
    fig.subplots_adjust(wspace=0.15, hspace=0.55)
    salva(fig, "erros.png")


def confusao(conjuntos, resultado):
    """A matriz de confusão do teste: em cada casa, quantos dígitos de uma classe a rede deu como outra.

    Linha: o dígito certo; coluna: o que a rede respondeu. A diagonal são os
    acertos, cerca de mil por dígito, e fica em cinza-escuro com o número em
    branco; fora dela, os erros, com a cor mais forte onde há mais. Os números
    estão todos escritos, para a figura se ler sem a cor. Devolve a matriz e os
    três pares mais confundidos.
    """
    _, t = conjuntos["teste"]
    previsto = np.array(resultado["escolha"]["previsto_teste"])
    C = np.zeros((10, 10), dtype=int)
    np.add.at(C, (t, previsto), 1)
    fora = C.copy()
    np.fill_diagonal(fora, 0)
    mapa = LinearSegmentedColormap.from_list("erros", ["#ffffff", LARANJA])
    fig, ax = plt.subplots(figsize=(8.2, 7.4))
    ax.imshow(fora, cmap=mapa, vmin=0, vmax=max(fora.max(), 1))
    for i in range(10):
        for j in range(10):
            if i == j:
                ax.add_patch(plt.Rectangle((j - 0.5, i - 0.5), 1, 1, color="#4a5568"))
                ax.text(j, i, f"{C[i, j]:,}".replace(",", "."), ha="center", va="center", fontsize=12 if C[i, j] >= 1000 else 14, color="white",
                        fontweight="bold")
            elif C[i, j]:
                ax.text(j, i, str(C[i, j]), ha="center", va="center", fontsize=16,
                        color="white" if C[i, j] > 0.6 * fora.max() else ESCURO)
    ax.set_xticks(range(10))
    ax.set_yticks(range(10))
    ax.tick_params(labelsize=18, length=0)
    ax.set_xlabel("o que a rede respondeu", fontsize=19)
    ax.set_ylabel("o dígito certo", fontsize=19)
    ax.xaxis.set_label_position("top")
    ax.xaxis.tick_top()
    for lado in ax.spines.values():
        lado.set_visible(False)
    salva(fig, "confusao.png")
    pares = sorted(((fora[i, j], i, j) for i in range(10) for j in range(10)), reverse=True)[:3]
    return C, pares


def pesos(resultado, quantos=16):
    """Os pesos de 16 neurônios ocultos, cada um desenhado como uma imagem 28 por 28.

    Cada neurônio oculto tem um peso por pixel; arrumados na forma da imagem,
    mostram o que o neurônio procura. Laranja é peso positivo: tinta ali aumenta
    a soma; azul é negativo: tinta ali diminui. A escala é a mesma nos 16, de
    menos a mais o maior valor absoluto, com o branco no zero, e a barra embaixo diz
    o valor de cada tom.
    """
    W = np.array(resultado["escolha"]["pesos_16"])[:quantos]
    limite = np.abs(W).max()
    mapa = LinearSegmentedColormap.from_list("pesos", [AZUL, "#ffffff", LARANJA])
    fig, eixos = plt.subplots(2, 8, figsize=(13, 4.4))
    for k, ax in enumerate(eixos.flat):
        im = ax.imshow(W[k].reshape(28, 28), cmap=mapa, vmin=-limite, vmax=limite)
        ax.set_xticks([])
        ax.set_yticks([])
        ax.set_title(f"neurônio {k + 1}", fontsize=13, color=ESCURO)
        for lado in ax.spines.values():
            lado.set_color(CINZA)
    barra = fig.colorbar(im, ax=list(eixos.flat), orientation="horizontal", fraction=0.05, pad=0.06, aspect=60)
    barra.ax.xaxis.set_major_formatter(VIRGULA)
    barra.ax.tick_params(labelsize=13)
    barra.set_label("o peso de cada pixel: azul, negativo; laranja, positivo", fontsize=15)
    salva(fig, "pesos.png")
    return limite


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")
    conjuntos = carrega()
    resultado = json.loads((AULA / "resultados" / "mlp.json").read_text(encoding="utf-8"))
    escolhida = resultado["escolha"]["largura"]
    digito_medio(conjuntos)
    digitos(conjuntos)
    rede(escolhida)
    saida(conjuntos, resultado)
    larguras(resultado)
    par_de_paineis(resultado["redes"][str(escolhida)]["historico"], "curvas.png")
    poucos = resultado["poucos"]
    par_de_paineis(poucos["historico"], "sobreajuste.png", marca=poucos["epoca_menor_perda_validacao"], teto=1.3)
    erros(conjuntos, resultado)
    C, pares = confusao(conjuntos, resultado)
    print("os pares mais confundidos (quantos, certo, rede):", [(int(n), int(i), int(j)) for n, i, j in pares])
    print(f"o maior peso, em valor absoluto, dos 16 neurônios: {pesos(resultado):.3f}")

mnist.py

Os dados do MNIST, prontos para a rede: leitura, versão e divisão em treino e validação.

100 linesDownload
"""Os dados do MNIST, prontos para a rede: leitura, versão e divisão em treino e validação.

Este arquivo vai para a turma junto com o esqueleto: ele só carrega os dados.
Antes, rode uma vez, a partir da raiz do repositório:
    python infra/baixar_dados.py
que baixa os quatro arquivos do MNIST para dados/ e confere cada um.

Rodado sozinho, mostra o que foi carregado:
    python aulas/06-mnist-numpy/codigo/mnist.py

O MNIST tem 70 mil dígitos escritos à mão, cada um numa imagem de 28 por 28
pixels em tons de cinza: 60 mil vêm marcados para treino e 10 mil para teste. A
validação sai do treino: 10 mil dos 60 mil, sorteados com uma semente fixa, ficam
de lado para escolher o tamanho da rede e a hora de parar. Sobram 50 mil para
ajustar os pesos. O teste só se usa uma vez, no fim.
"""
import gzip
import hashlib
import sys
from pathlib import Path

import numpy as np

DADOS = Path(__file__).resolve().parents[3] / "dados"
ARQUIVOS = {"treino": ("train-images-idx3-ubyte.gz", "train-labels-idx1-ubyte.gz"),
            "teste": ("t10k-images-idx3-ubyte.gz", "t10k-labels-idx1-ubyte.gz")}
SEMENTE_DIVISAO = 0        # a semente que sorteia quais 10 mil do treino viram validação
N_VALIDACAO = 10_000


def le_imagens(caminho):
    """Lê um arquivo de imagens do MNIST e devolve uma matriz N por 784, com valores de 0 a 1.

    O formato, chamado idx, começa com 16 bytes de cabeçalho: um número mágico,
    a quantidade de imagens, as linhas e as colunas, cada um em 4 bytes. Depois
    vêm os pixels, um byte cada, de 0 a 255, linha por linha. Cada imagem de 28
    por 28 vira uma linha de 784 números, a primeira linha da imagem primeiro;
    a divisão por 255 leva os valores para entre 0 e 1, a escala em que a tanh
    trabalha bem.
    """
    with gzip.open(caminho, "rb") as f:
        dados = f.read()
    magico, n, linhas, colunas = np.frombuffer(dados[:16], dtype=">u4")   # >u4: inteiro de 4 bytes, o mais significativo primeiro
    assert magico == 2051, f"{caminho.name} não é um arquivo de imagens do MNIST"
    pixels = np.frombuffer(dados[16:], dtype=np.uint8)
    return pixels.reshape(n, linhas * colunas).astype(np.float64) / 255.0


def le_rotulos(caminho):
    """Lê um arquivo de rótulos do MNIST e devolve um vetor de N inteiros, de 0 a 9.

    O cabeçalho tem 8 bytes, o número mágico e a quantidade; depois, um byte por
    rótulo, o dígito que a imagem mostra.
    """
    with gzip.open(caminho, "rb") as f:
        dados = f.read()
    magico, n = np.frombuffer(dados[:8], dtype=">u4")
    assert magico == 2049, f"{caminho.name} não é um arquivo de rótulos do MNIST"
    return np.frombuffer(dados[8:], dtype=np.uint8).astype(np.int64)


def versao():
    """A versão dos dados: os 12 primeiros caracteres do SHA-256 dos quatro arquivos juntos.

    Vai junto com todo resultado. Se alguém rodar com outros arquivos, a versão
    muda, e o resultado deixa de ser comparável.
    """
    h = hashlib.sha256()
    for imagens, rotulos in ARQUIVOS.values():
        for nome in (imagens, rotulos):
            h.update((DADOS / nome).read_bytes())
    return h.hexdigest()[:12]


def carrega():
    """Devolve um dicionário com os três conjuntos, cada um um par (X, t).

    X: matriz N por 784, um dígito por linha, valores de 0 a 1;
    t: vetor de N rótulos, de 0 a 9.
    "treino" tem 50 mil, "validacao" 10 mil, "teste" 10 mil. A validação são 10
    mil exemplos do treino original, sorteados com SEMENTE_DIVISAO; o teste é o
    teste original, intocado.
    """
    falta = [n for par in ARQUIVOS.values() for n in par if not (DADOS / n).exists()]
    if falta:
        sys.exit(f"faltam em dados/: {', '.join(falta)}. Rode antes: python infra/baixar_dados.py")
    X, t = le_imagens(DADOS / ARQUIVOS["treino"][0]), le_rotulos(DADOS / ARQUIVOS["treino"][1])
    X_teste, t_teste = le_imagens(DADOS / ARQUIVOS["teste"][0]), le_rotulos(DADOS / ARQUIVOS["teste"][1])
    ordem = np.random.default_rng(SEMENTE_DIVISAO).permutation(len(X))    # uma ordem sorteada dos 60 mil
    val, tre = ordem[:N_VALIDACAO], ordem[N_VALIDACAO:]
    return {"treino": (X[tre], t[tre]), "validacao": (X[val], t[val]), "teste": (X_teste, t_teste)}


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")
    conjuntos = carrega()
    print(f"versão dos dados: {versao()}")
    for nome, (X, t) in conjuntos.items():
        contagem = np.bincount(t, minlength=10)
        print(f"{nome:>9}: {X.shape[0]:>6} dígitos de {X.shape[1]} pixels; de 0 a 9: {contagem.tolist()}")

Not published here: the reference solution of an assignment that is still open.