Code

Aula 2: Derivada, gradiente e gradiente descendente

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

animacoes.py

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

1437 linesDownload
"""Os vídeos da aula 2, feitos com o Manim Community.

Rode, a partir da raiz do repositório, com o nome dos vídeos que quer gravar, ou
sem nome nenhum para gravar todos:
    python aulas/02-gradiente/codigo/animacoes.py
    python aulas/02-gradiente/codigo/animacoes.py derivada taxa

Os sete vídeos, na ordem da aula:
    perceptron-e     a revisão: a regra do perceptron aprende o E, e a tabela das épocas se monta
    perceptron-xor   a mesma regra no XOR: 30 épocas, 4 erros em cada uma, e a reta nunca para
    perda-de-w       a reta gira com o peso w, e a perda desenha uma curva com um fundo
    derivada         a secante vira tangente quando h encolhe, e de perto a curva vira reta
    parcial          dois cortes na superfície da perda: as duas derivadas parciais e o gradiente
    descida-3d       a descida na superfície da perda, com a reta se ajustando aos pontos ao lado
    taxa             o gradiente descendente na parábola com três taxas: devagar, oscila, diverge

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/02-gradiente/videos/<nome>.mp4           1920 por 1080, 30 quadros por segundo, sem som
    aulas/02-gradiente/figuras/<nome>-quadro.png   o último quadro, que o slide mostra parado

No deck, o slide mostra o quadro parado e toca o mp4 quando se clica nele. O
vídeo ocupa uns 60% da largura do slide, embaixo do título do slide, e por isso
não tem título próprio, e as letras são grandes: texto corrido com pelo menos 30
pontos do Manim, equação com pelo menos 38.

Três regras valem em todas as cenas. Todo texto e todo número entra escrito, com
o efeito de escrita, como nos vídeos do 3Blue1Brown; as formas entram desenhadas.
Nada se sobrepõe: cada texto tem um lugar fixo, fora dos dados, e os pontos ficam
por cima das retas. E nada é sorteado: os vídeos saem sempre iguais, com os
números de reta.py e do perceptron.py da aula 1.
"""
import shutil
import subprocess
import sys
from pathlib import Path

import numpy as np
from manim import (DEGREES, DOWN, LEFT, RIGHT, UP, Arrow, Circle, Create, DashedLine, DecimalNumber, Dot, Dot3D,
                   FadeIn, FadeOut, GrowArrow, GrowFromCenter, GrowFromEdge, Indicate, LaggedStart, Line, MathTex,
                   MovingCameraScene, ParametricFunction, Polygon, Rectangle, RoundedRectangle, Scene, Square,
                   SurroundingRectangle, Surface, TexTemplate, Text, ThreeDAxes, ThreeDScene, TracedPath,
                   ValueTracker, VGroup, VMobject, Write, always_redraw, config, smooth, tempconfig)
from manim import TransformFromCopy
from manimpango import register_font

sys.path.insert(0, str(Path(__file__).resolve().parent))
from reta import dados, gradiente, minimos_quadrados, perda  # noqa: E402

RAIZ = Path(__file__).resolve().parents[3]
AULA = Path(__file__).resolve().parents[1]

# As fontes do curso, as mesmas dos slides, guardadas em infra/fontes/ com as licenças
for arquivo in ("IBMPlexSans-Regular.ttf", "IBMPlexSans-SemiBold.ttf", "SourceSerif4-Semibold.ttf"):
    register_font(str(RAIZ / "infra" / "fontes" / arquivo))
SANS, SERIFA = "IBM Plex Sans", "Source Serif 4"

# A paleta de infra/slides.py e das figuras: resposta 1 em círculo azul, resposta
# 0 em quadrado vermelho, região que dispara em azul claro
FUNDO, ESCURO, TEXTO, SUAVE, CINZA = "#F6F5F1", "#14213D", "#3B4658", "#5E6A7D", "#8A94A6"
AZUL, LARANJA, VERMELHO, REGIAO, CARTAO, BORDA = "#1F5FAD", "#A8500F", "#C0392B", "#CFE3F5", "#FDFCFA", "#DCDFE4"

# As equações em STIX Two, a fonte das equações dos slides (infra/equacoes.py).
# Vale para todo MathTex e para os algarismos dos números que mudam na tela.
MODELO = TexTemplate(preamble="\\usepackage[T1]{fontenc}\n\\usepackage{amsmath}\n\\usepackage{stix2}\n")
config.tex_template = MODELO


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


def eq(*partes, tamanho=44, cor=ESCURO):
    """Equação em LaTeX. Cada parte vira um pedaço separado, que se pode colorir ou mover sozinho."""
    return MathTex(*partes, font_size=tamanho, color=cor, tex_template=MODELO)


def frase(*pedacos, tamanho=32, cor=TEXTO):
    """Uma linha que mistura texto e equação, como "com o viés parado em b = 0,967".

    Cada pedaço é um texto comum ou, se começa com $, uma equação; a equação sai
    um pouco maior que o texto, para as duas letras terem a mesma altura.
    """
    partes = [eq(p.strip("$"), tamanho=round(tamanho * 1.25), cor=cor) if p.startswith("$") else texto(p, tamanho, cor)
              for p in pedacos]
    return VGroup(*partes).arrange(RIGHT, buff=0.16)


class Numero(DecimalNumber):
    """Número que muda na tela, com vírgula decimal, como pede o CLAUDE.md."""

    def _get_num_string(self, number):
        return super()._get_num_string(number).replace(".", ",")


def colore_fracao(fracao, cor_cima, cor_baixo):
    """Pinta o numerador de uma cor e o denominador de outra.

    As letras de um MathTex não vêm numa ordem garantida, então a divisão é pela
    posição: o traço da fração é a letra mais larga, e o que fica acima dele é o
    numerador; o que fica abaixo, o denominador. O traço fica com a cor do texto.
    """
    letras = fracao[0]
    traco = max(letras, key=lambda m: m.width)
    for m in letras:
        if m is not traco:
            m.set_color(cor_cima if m.get_center()[1] > traco.get_center()[1] else cor_baixo)


def n(v):
    """Número inteiro em LaTeX; o menos sai como sinal de menos, e não como hífen."""
    return f"{int(round(v))}"


def br(v, casas=2):
    """Número decimal em LaTeX, com vírgula decimal: 1{,}28, sem o espaço que o LaTeX põe depois de vírgula."""
    return f"{v:.{casas}f}".replace(".", "{,}")


def vetor(v):
    """Par de números inteiros em LaTeX, como (1,\\ 0)."""
    return rf"({n(v[0])},\ {n(v[1])})"


def distancia_ao_segmento(m, a, c):
    """A menor distância entre o ponto m e o segmento de a até c, no plano."""
    d = c - a
    s = np.clip(np.dot(m - a, d) / max(np.dot(d, d), 1e-12), 0, 1)
    return np.linalg.norm(m - (a + s * d))


class Moldura:
    """Um gráfico 2D com moldura e marcas por fora, como as figuras do matplotlib.

    Os eixos não passam pela origem: com eixo no meio do gráfico, a curva passaria
    por cima dos números do eixo. A moldura é o retângulo dos limites, as marcas
    ficam na borda de baixo e na da esquerda, e os números e os nomes dos eixos,
    fora dela. O nome de um eixo pode ser LaTeX, um texto pronto ou None.
    """

    def __init__(self, x_lim, y_lim, largura, altura, canto, x_marcas, y_marcas, x_nome, y_nome, casas=0,
                 tam_marca=32, tam_nome=40):
        self.x_lim, self.y_lim = x_lim, y_lim
        self.largura, self.altura = largura, altura
        self.canto = np.array([canto[0], canto[1], 0.0])
        self.x_marcas, self.y_marcas, self.casas = x_marcas, y_marcas, casas
        self.x_nome, self.y_nome = x_nome, y_nome
        self.tam_marca, self.tam_nome = tam_marca, tam_nome

    def p(self, x, y):
        """Leva o ponto (x, y) do gráfico para a tela."""
        fx = (x - self.x_lim[0]) / (self.x_lim[1] - self.x_lim[0])
        fy = (y - self.y_lim[0]) / (self.y_lim[1] - self.y_lim[0])
        return self.canto + np.array([fx * self.largura, fy * self.altura, 0.0])

    def dentro(self, x, y):
        return self.x_lim[0] <= x <= self.x_lim[1] and self.y_lim[0] <= y <= self.y_lim[1]

    def caixa(self):
        """O retângulo dos limites."""
        c = Rectangle(width=self.largura, height=self.altura, color=ESCURO, stroke_width=2.5)
        return c.move_to(self.canto + np.array([self.largura / 2, self.altura / 2, 0]))

    def desenho(self):
        """A moldura, as marcas, os números das marcas e os nomes dos eixos, tudo fora da área dos dados.

        Devolve um grupo: o primeiro item é a moldura, para ser desenhada com
        Create, e o resto são textos, para entrarem escritos.
        """
        caixa = self.caixa()
        partes = VGroup(caixa)
        numero = (lambda v: br(v, self.casas)) if self.casas else (lambda v: n(v))
        numeros_x, numeros_y = VGroup(), VGroup()
        for v in self.x_marcas:
            base = self.p(v, self.y_lim[0])
            partes.add(Line(base, base + UP * 0.12, color=ESCURO, stroke_width=2.5))
            numeros_x.add(eq(numero(v), tamanho=self.tam_marca, cor=TEXTO).next_to(base, DOWN, buff=0.14))
        for v in self.y_marcas:
            base = self.p(self.x_lim[0], v)
            partes.add(Line(base, base + RIGHT * 0.12, color=ESCURO, stroke_width=2.5))
            numeros_y.add(eq(numero(v), tamanho=self.tam_marca, cor=TEXTO).next_to(base, LEFT, buff=0.14))
        partes.add(numeros_x, numeros_y)
        for nome, lado, grupo in ((self.x_nome, DOWN, numeros_x), (self.y_nome, LEFT, numeros_y)):
            if nome is None:
                continue
            rotulo = eq(nome, tamanho=self.tam_nome) if isinstance(nome, str) else nome
            partes.add(rotulo.next_to(VGroup(caixa, grupo), lado, buff=0.25))
        return partes

    def curva(self, f, x_de, x_ate, cor=ESCURO, espessura=5, pontos=300):
        """O gráfico de f entre x_de e x_ate, só a parte que fica dentro da moldura."""
        xs = np.linspace(x_de, x_ate, pontos)
        dentro = [self.p(x, f(x)) for x in xs if self.dentro(x, f(x))]
        if len(dentro) < 2:
            return VMobject()
        return VMobject(stroke_color=cor, stroke_width=espessura).set_points_smoothly(dentro)

    def reta(self, x0, y0, inclinacao):
        """A reta por (x0, y0) com essa inclinação, cortada na moldura: as duas pontas, na tela."""
        xs = np.linspace(self.x_lim[0], self.x_lim[1], 600)
        ys = y0 + inclinacao * (xs - x0)
        ok = (ys >= self.y_lim[0]) & (ys <= self.y_lim[1])
        xs, ys = xs[ok], ys[ok]
        return (self.p(xs[0], ys[0]), self.p(xs[-1], ys[-1]))


# ================================================================ 1 e 2. o perceptron no E e no XOR

X_E = [(0, 0), (0, 1), (1, 0), (1, 1)]
T_E = [0, 0, 0, 1]
T_XOR = [0, 1, 1, 0]


def treino(alvos, epocas=20, para_sem_erro=True):
    """A regra do perceptron, com η = 1 e tudo começando em zero, como no perceptron.py da aula 1.

    Devolve um passo por exemplo visto: a época, o índice do exemplo, z, a saída
    y, a resposta certa t, os pesos e o viés antes e depois, e quantos erros a
    época já tem. No E, para na primeira época sem erro, a sexta; no XOR, que
    nunca fica sem erro, anda as épocas pedidas.
    """
    w, b = np.zeros(2), 0.0
    passos = []
    for epoca in range(1, epocas + 1):
        erros = 0
        for k, (x, t) in enumerate(zip(X_E, alvos)):
            x = np.array(x)
            z = w @ x + b
            y = int(z >= 0)                              # o degrau: dispara se z ≥ 0
            w_antes, b_antes = w.copy(), b
            if y != t:
                w = w + (t - y) * x                      # a regra dos pesos, com η = 1
                b = b + (t - y)                          # a regra do viés
                erros += 1
            passos.append(dict(epoca=epoca, k=k, z=z, y=y, t=t, w_antes=w_antes, b_antes=b_antes,
                               w=w.copy(), b=b, erros=erros))
        if erros == 0 and para_sem_erro:
            break
    return passos


class Plano:
    """O quadrado do plano (x₁, x₂), com os limites pedidos nos dois eixos.

    No E, de −0,5 a 1,5, como nas figuras da aula 1. No XOR, de −1,5 a 2,5: lá
    a reta passa por x₁ = −1 e por x₂ = −1, e precisa caber no desenho.
    """

    def __init__(self, canto, lado, lim=(-0.5, 1.5), fator_seta=0.2):
        self.canto, self.lado, self.lim = np.array(canto, dtype=float), lado, lim
        self.vao = lim[1] - lim[0]
        self.fator_seta = fator_seta

    def p(self, x1, x2):
        """Leva o ponto (x₁, x₂) do plano para a tela."""
        e = self.lado / self.vao
        return self.canto + np.array([(x1 - self.lim[0]) * e, (x2 - self.lim[0]) * e, 0.0])

    def cantos(self):
        a, c = self.lim
        return [(a, a), (c, a), (c, c), (a, c)]

    def regiao(self, w1, w2, b):
        """O pedaço do quadrado em que w₁x₁ + w₂x₂ + b ≥ 0, cortado pela reta, pelo método de Sutherland e Hodgman.

        Percorre os cantos, guarda os que estão do lado que dispara e, quando uma
        aresta cruza a reta, guarda o ponto do cruzamento.
        """
        cantos = self.cantos()
        z = [w1 * a + w2 * c + b for a, c in cantos]
        saida = []
        for i in range(4):
            p, q, zp, zq = cantos[i], cantos[(i + 1) % 4], z[i], z[(i + 1) % 4]
            if zp >= 0:
                saida.append(p)
            if (zp >= 0) != (zq >= 0):
                s = zp / (zp - zq)
                saida.append((p[0] + s * (q[0] - p[0]), p[1] + s * (q[1] - p[1])))
        return saida

    def corda(self, w1, w2, b):
        """O pedaço da reta z = 0 que fica dentro do quadrado: os dois pontos da borda, ou None.

        A ordem dos dois pontos segue a direção de w girado de 90 graus, e não a
        ordem em que as bordas são percorridas. Assim, quando os pesos mudam aos
        poucos, cada ponta da corda muda aos poucos também, e a seta ancorada
        nela desliza, sem pular de um lado para o outro.
        """
        if np.hypot(w1, w2) < 1e-9:
            return None                                  # com w = (0, 0) não há reta: z = b em todo ponto
        cantos = self.cantos()
        z = [w1 * a + w2 * c + b for a, c in cantos]
        pontos = []
        for i in range(4):
            p, q = np.array(cantos[i], dtype=float), np.array(cantos[(i + 1) % 4], dtype=float)
            zp, zq = z[i], z[(i + 1) % 4]
            if zp == zq:
                continue                                 # aresta paralela à reta: não cruza, ou está em cima dela
            s = zp / (zp - zq)
            if 0 <= s <= 1:
                ponto = p + s * (q - p)
                if all(np.linalg.norm(ponto - o) > 1e-9 for o in pontos):
                    pontos.append(ponto)                 # um canto em cima da reta entraria duas vezes
        if len(pontos) < 2:
            return None
        a, c = max(((u, v) for u in pontos for v in pontos), key=lambda par: np.linalg.norm(par[0] - par[1]))
        if np.linalg.norm(c - a) < 1e-9:
            return None
        if np.dot(c - a, np.array([-w2, w1])) < 0:
            a, c = c, a
        return a, c

    def seta(self, w1, w2, b, s):
        """O vetor dos pesos, desenhado do ponto da corda na fração s, perpendicular à reta, para o lado que dispara.

        O comprimento é fator_seta vezes o tamanho de w, para se ver os pesos
        crescendo, mas encolhe se a ponta fosse sair do quadrado: encolher aos
        poucos não pisca, e sumir de repente piscaria. Devolve (início, fim) no
        plano, ou None quando não há reta dentro do quadrado.
        """
        corda = self.corda(w1, w2, b)
        if corda is None:
            return None
        a, c = corda
        base = a + s * (c - a)
        w = np.array([w1, w2])
        direcao = w / np.linalg.norm(w)
        margem = 0.03 * self.vao
        cabe = np.inf                                    # quanto a seta anda na direção de w até a borda
        for eixo in range(2):
            if direcao[eixo] > 1e-9:
                cabe = min(cabe, (self.lim[1] - margem - base[eixo]) / direcao[eixo])
            elif direcao[eixo] < -1e-9:
                cabe = min(cabe, (self.lim[0] + margem - base[eixo]) / direcao[eixo])
        comprimento = min(self.fator_seta * np.linalg.norm(w), cabe)
        if comprimento < 0.02 * self.vao:
            return None
        return base, base + comprimento * direcao

    def melhor_s(self, w1, w2, b, marcas):
        """A fração da corda onde ancorar a seta para esses pesos: a que deixa a seta inteira e mais longe dos pontos.

        Só se calcula nos pesos de chegada de cada correção; no caminho, a fração
        anda aos poucos de uma escolha à outra. Devolve None se a seta não cabe
        inteira em lugar nenhum.
        """
        inteira = self.fator_seta * np.hypot(w1, w2)
        melhor, maior = None, -1.0
        for s in np.linspace(0.1, 0.9, 33):
            seta = self.seta(w1, w2, b, s)
            if seta is None or np.linalg.norm(seta[1] - seta[0]) < inteira - 1e-9:
                continue
            d = min(distancia_ao_segmento(np.array(m, dtype=float), *seta) for m in marcas)
            if d > maior:
                melhor, maior = s, d
        return melhor


class Perceptron(Scene):
    """O que os vídeos do E e do XOR têm em comum: o plano, os quatro pontos, a legenda e o que anda com os pesos.

    O plano fica à esquerda, com a legenda em cima dele; a direita fica livre
    para cada vídeo. A reta z = 0, a região que dispara e o vetor dos pesos
    se redesenham a cada quadro a partir de quatro números: w₁, w₂, b e a
    fração da reta onde a seta se apoia.
    """

    ALVOS, LIM, MARCAS, FATOR_SETA = T_E, (-0.5, 1.5), (0, 1), 0.2

    def monta(self):
        self.camera.background_color = FUNDO
        plano = self.plano = Plano((-5.6, -2.6, 0), 5.0, self.LIM, self.FATOR_SETA)

        # ------------------------------------------------ o plano, com marcas e rótulos por fora
        meio = (self.LIM[0] + self.LIM[1]) / 2
        borda = Rectangle(width=plano.lado, height=plano.lado, color=ESCURO, stroke_width=2.5)
        borda.move_to(plano.p(meio, meio))
        tracos, numeros_x, numeros_y = VGroup(), VGroup(), VGroup()
        for v in self.MARCAS:
            base_x, base_y = plano.p(v, self.LIM[0]), plano.p(self.LIM[0], v)
            tracos.add(Line(base_x, base_x + UP * 0.12, color=ESCURO, stroke_width=2.5),
                       Line(base_y, base_y + RIGHT * 0.12, color=ESCURO, stroke_width=2.5))
            numeros_x.add(eq(n(v), tamanho=32, cor=TEXTO).next_to(base_x, DOWN, buff=0.16))
            numeros_y.add(eq(n(v), tamanho=32, cor=TEXTO).next_to(base_y, LEFT, buff=0.16))
        rotulos = VGroup(eq("x_1", tamanho=40).next_to(VGroup(borda, numeros_x), DOWN, buff=0.2),
                         eq("x_2", tamanho=40).next_to(VGroup(borda, numeros_y), LEFT, buff=0.25))
        self.borda, self.tracos, self.numeros = borda, tracos, VGroup(numeros_x, numeros_y, rotulos)

        # ------------------------------------------------ o que anda com os pesos
        self.w1, self.w2, self.b, self.s, self.opac = (ValueTracker(v) for v in (0.0, 0.0, 0.0, 0.5, 0.0))

        def valores():
            return self.w1.get_value(), self.w2.get_value(), self.b.get_value()

        def desenha_regiao():
            vertices = plano.regiao(*valores())
            if len(vertices) < 3:
                return VMobject()
            return Polygon(*[plano.p(*v) for v in vertices], fill_color=REGIAO, fill_opacity=1, stroke_width=0)

        def desenha_reta():
            corda = plano.corda(*valores())
            if corda is None:
                return VMobject()
            return Line(plano.p(*corda[0]), plano.p(*corda[1]), color=ESCURO, stroke_width=5)

        def desenha_seta():
            seta = plano.seta(*valores(), self.s.get_value())
            if seta is None or self.opac.get_value() < 0.01:
                return VMobject()
            return Arrow(plano.p(*seta[0]), plano.p(*seta[1]), buff=0, color=AZUL, stroke_width=7,
                         max_tip_length_to_length_ratio=0.3, max_stroke_width_to_length_ratio=12
                         ).set_opacity(self.opac.get_value())

        self.area, self.linha, self.seta = (always_redraw(desenha_regiao), always_redraw(desenha_reta),
                                            always_redraw(desenha_seta))

        self.marcadores = []
        for (a, c), t in zip(X_E, self.ALVOS):
            if t == 1:
                m = Circle(radius=0.21, color=AZUL, fill_opacity=1, stroke_width=0)
            else:
                m = Square(side_length=0.38, color=VERMELHO, fill_opacity=1, stroke_width=0)
            self.marcadores.append(m.move_to(plano.p(a, c)))

        # ------------------------------------------------ a legenda, em cima do plano e fora dele
        def item(icone, rotulo):
            return VGroup(icone, texto(rotulo, 26, TEXTO)).arrange(RIGHT, buff=0.14)
        self.legenda = VGroup(
            item(Circle(radius=0.12, color=AZUL, fill_opacity=1, stroke_width=0), "resposta 1"),
            item(Square(side_length=0.22, color=VERMELHO, fill_opacity=1, stroke_width=0), "resposta 0"),
            item(Square(side_length=0.26, color=REGIAO, fill_opacity=1, stroke_width=0), "dispara"),
            item(Arrow(LEFT * 0.25, RIGHT * 0.25, buff=0, color=AZUL, stroke_width=6), "o vetor dos pesos"),
        ).arrange_in_grid(rows=2, cols=2, buff=(0.4, 0.18), col_alignments="ll")
        self.legenda.next_to(borda, UP, buff=0.3, aligned_edge=LEFT)

    def abre(self):
        """Desenha o plano, os pontos e a legenda."""
        self.add(self.area, self.linha, self.seta)
        self.play(Create(self.borda), Create(self.tracos), Write(self.numeros), run_time=1.3)
        self.play(LaggedStart(*[GrowFromCenter(m) for m in self.marcadores], lag_ratio=0.2), run_time=1)
        self.play(Write(self.legenda), run_time=1.4)

    def move_pesos(self, w, b, tempo):
        """Leva pesos e viés aos valores novos; a reta, a região e a seta andam junto, aos poucos.

        A seta aparece só depois da primeira correção que a deixa inteira dentro
        do quadrado, e aparece devagar, em vez de surgir no meio do caminho.
        """
        s_novo = self.plano.melhor_s(w[0], w[1], b, X_E)
        anims = [self.w1.animate.set_value(w[0]), self.w2.animate.set_value(w[1]), self.b.animate.set_value(b)]
        if s_novo is not None:
            if self.opac.get_value() < 0.01:
                self.s.set_value(s_novo)                 # a seta ainda não apareceu: começa já no lugar certo
            else:
                anims.append(self.s.animate.set_value(s_novo))
        self.play(*anims, run_time=tempo)
        if self.opac.get_value() < 0.01 and s_novo is not None:
            self.play(self.opac.animate.set_value(1), run_time=0.5)

    def pulsa(self, k, tempo, fator=1.35):
        """Aumenta o marcador do exemplo k, para mostrar qual exemplo o neurônio está vendo."""
        self.play(self.marcadores[k].animate.scale(fator), run_time=tempo)

    def despulsa(self, k, tempo, fator=1.35):
        self.play(self.marcadores[k].animate.scale(1 / fator), run_time=tempo)


class PerceptronE(Perceptron):
    """A revisão: a regra do perceptron aprende o E, e a tabela das épocas se monta ao lado.

    À direita, o cartão das duas regras. Na primeira época, as letras das regras
    viram os números do exemplo, pedaço por pedaço, e a conta fica escrita
    embaixo; na segunda, a conta entra escrita, mais depressa. Da terceira em
    diante, a conta sai e fica a tabela das épocas, a do perceptron.py da aula 1:
    quantos erros e os pesos no fim de cada época. Termina na sexta, sem erro.
    """

    def construct(self):
        self.monta()
        passos = treino(T_E)

        # ------------------------------------------------ as duas regras, em pedaços, para as letras virarem números
        regra_w = eq(r"\vec{w}", r"\leftarrow", r"\vec{w}", "+", r"\eta", r"\,(t - y)\,", r"\vec{x}", tamanho=50)
        regra_b = eq("b", r"\leftarrow", "b", "+", r"\eta", r"\,(t - y)", tamanho=50)
        regras = VGroup(regra_w, regra_b).arrange(DOWN, buff=0.3)
        cartao = RoundedRectangle(corner_radius=0.18, width=6.15, height=regras.height + 0.6,
                                  fill_color=CARTAO, fill_opacity=1, stroke_color=BORDA, stroke_width=2)
        cartao.move_to(np.array([3.775, 3.75 - cartao.height / 2, 0]))
        regras.move_to(cartao)
        X0 = 0.7                                         # a margem esquerda da coluna da direita
        subtitulo = frase("com", "$\\eta = 1$", "e tudo começando em zero", tamanho=26, cor=SUAVE)
        subtitulo.move_to(np.array([X0, cartao.get_bottom()[1] - 0.42, 0]), aligned_edge=LEFT)

        # seis lugares fixos para as linhas da conta, um embaixo do outro, alinhados pela esquerda
        lugares = [np.array([X0, y, 0.0]) for y in (0.75, 0.0, -0.75, -1.5, -2.25, -3.0)]
        atuais = [None] * len(lugares)

        def poe(trocas, tempo):
            """Troca o conteúdo dos lugares pedidos; None deixa o lugar vazio.

            Primeiro sai o que estava, depois o novo entra escrito: saindo e entrando
            juntos, os dois ficariam um instante um por cima do outro.
            """
            saidas = [FadeOut(atuais[i]) for i, _ in trocas if atuais[i] is not None]
            if saidas:
                self.play(*saidas, run_time=min(0.25, tempo * 0.5))
            entradas = []
            for i, novo in trocas:
                if novo is not None:
                    novo.move_to(lugares[i], aligned_edge=LEFT)
                    entradas.append(Write(novo))
                atuais[i] = novo
            if entradas:
                self.play(*entradas, run_time=tempo)

        def substitui(regra, numerica, pares, resto, lugar, tempo):
            """As letras da regra viram os números: cada pedaço da regra voa para o pedaço correspondente da conta."""
            numerica.move_to(lugares[lugar], aligned_edge=LEFT)
            self.play(*[TransformFromCopy(regra[i], numerica[j]) for i, j in pares], run_time=1.4)
            self.play(Write(VGroup(*[numerica[j] for j in resto])), run_time=tempo)
            atuais[lugar] = numerica

        # ------------------------------------------------ a tabela das épocas, que substitui a conta da terceira em diante
        colunas = [1.3, 2.8, 4.6, 6.2]
        y_cabeca, y_linhas = 0.75, [0.05 - 0.62 * i for i in range(6)]
        cabeca = VGroup(texto("época", 30, SUAVE), texto("erros", 30, SUAVE), eq(r"\vec{w}", tamanho=40, cor=SUAVE),
                        eq("b", tamanho=40, cor=SUAVE))
        for c, m in zip(colunas, cabeca):
            m.move_to(np.array([c, y_cabeca, 0]))
        fio = Line(np.array([X0, y_cabeca - 0.36, 0]), np.array([6.85, y_cabeca - 0.36, 0]), color=BORDA,
                   stroke_width=2)
        fins = {}                                        # os pesos e os erros no fim de cada época
        for p in passos:
            fins[p["epoca"]] = p

        def celulas(epoca):
            f = fins[epoca]
            cor = LARANJA if f["erros"] else AZUL
            itens = [eq(str(epoca), tamanho=40), eq(str(f["erros"]), tamanho=40, cor=cor),
                     eq(vetor(f["w"]), tamanho=40), eq(n(f["b"]), tamanho=40)]
            for c, m in zip(colunas, itens):
                m.move_to(np.array([c, y_linhas[epoca - 1], 0]))
            return itens

        # ------------------------------------------------ a abertura
        self.abre()
        self.play(Create(cartao), run_time=0.6)
        self.play(Write(regras), run_time=1.6)
        self.play(Write(subtitulo), run_time=1.1)
        self.wait(0.8)

        # ------------------------------------------------ as duas primeiras épocas, exemplo por exemplo, com a conta
        tabela_aberta = False
        for p in passos:
            x = np.array(X_E[p["k"]])
            erro = p["t"] - p["y"]
            if p["epoca"] <= 2:
                tempo = 0.55 if p["epoca"] == 1 else 0.4
                poe([(0, texto(f"Época {p['epoca']}, exemplo {p['k'] + 1} de 4", 34, ESCURO, "SEMIBOLD")),
                     (1, eq(rf"\vec{{x}} = {vetor(x)} \qquad t = {p['t']}", tamanho=42)),
                     (2, None), (3, None), (4, None), (5, None)], tempo)
                self.pulsa(p["k"], tempo)
                poe([(2, eq(rf"z = {vetor(p['w_antes'])}\cdot{vetor(x)} + ({n(p['b_antes'])}) = {n(p['z'])}",
                            tamanho=42))], tempo)
                if erro == 0:
                    poe([(3, eq(rf"y = {p['y']}:\ \text{{acertou, nada muda}}", tamanho=42, cor=AZUL))], tempo)
                    self.wait(1.0 if p["epoca"] == 1 else 0.5)
                else:
                    poe([(3, eq(rf"y = {p['y']}:\ \text{{errou}},\ t - y = {n(erro)}", tamanho=42, cor=LARANJA))],
                        tempo)
                    self.play(cartao.animate.set_stroke(LARANJA, width=5), regras.animate.set_color(LARANJA),
                              run_time=tempo)
                    conta_w = eq(r"\vec{w}", r"\leftarrow", vetor(p["w_antes"]), "+", "1", rf"\cdot({n(erro)})\cdot",
                                 vetor(x), "=", vetor(p["w"]), tamanho=38)
                    conta_b = eq("b", r"\leftarrow", n(p["b_antes"]), "+", "1", rf"\cdot({n(erro)})", "=", n(p["b"]),
                                 tamanho=38)
                    if p["epoca"] == 1:
                        substitui(regra_w, conta_w, [(i, i) for i in range(7)], [7, 8], 4, tempo)
                        substitui(regra_b, conta_b, [(i, i) for i in range(6)], [6, 7], 5, tempo)
                    else:
                        poe([(4, conta_w), (5, conta_b)], tempo)
                    self.move_pesos(p["w"], p["b"], 1.6 if p["epoca"] == 1 else 1.1)
                    self.play(cartao.animate.set_stroke(BORDA, width=2), regras.animate.set_color(ESCURO),
                              run_time=0.3)
                    self.wait(0.8 if p["epoca"] == 1 else 0.4)
                self.despulsa(p["k"], tempo * 0.6)
                continue

            # ------------------------------------------------ da terceira época em diante: a tabela
            if not tabela_aberta:
                poe([(i, None) for i in range(6)], 0.4)
                self.play(Write(cabeca), Create(fio), run_time=1.0)
                self.play(*[Write(VGroup(*celulas(e))) for e in (1, 2)], run_time=1.2)
                self.wait(0.6)
                tabela_aberta = True
            if p["k"] == 0:
                linha = celulas(p["epoca"])
                self.play(Write(linha[0]), run_time=0.35)
            self.pulsa(p["k"], 0.18)
            if erro != 0:
                self.play(cartao.animate.set_stroke(LARANJA, width=5), regras.animate.set_color(LARANJA),
                          run_time=0.15)
                self.move_pesos(p["w"], p["b"], 0.7)
                self.play(cartao.animate.set_stroke(BORDA, width=2), regras.animate.set_color(ESCURO),
                          run_time=0.15)
            self.despulsa(p["k"], 0.14)
            if p["k"] == 3:
                self.play(Write(VGroup(*linha[1:])), run_time=0.6)
                self.wait(0.3)

        # ------------------------------------------------ a última época passou sem erro
        ultima = passos[-1]["epoca"]
        destaque = SurroundingRectangle(VGroup(*linha), color=AZUL, buff=0.14, corner_radius=0.1, stroke_width=3)
        fim = frase(f"Época {ultima}: nenhum erro.", tamanho=30, cor=AZUL)
        fim.move_to(subtitulo, aligned_edge=LEFT)
        self.play(Create(destaque), FadeOut(subtitulo), run_time=0.8)
        self.play(Write(fim), run_time=1.3)
        self.play(Indicate(self.marcadores[3], color=AZUL, scale_factor=1.3), run_time=1.2)
        self.wait(4)


class PerceptronXOR(Perceptron):
    """A mesma regra no XOR: a reta corrige e corrige, e nunca separa os pontos.

    O plano vai de −1,5 a 2,5, porque as retas do XOR passam por x₁ = −1 e por
    x₂ = −1. À direita, a época, os erros da época e os pesos; embaixo, o gráfico
    dos erros por época, que nunca chega a zero. As duas primeiras épocas vão
    devagar; da sexta em diante, bem depressa: são 30 épocas. Com tudo
    começando em zero, os pesos voltam a (−1, 0) e o viés a 0 no fim de toda
    época a partir da segunda: a regra anda em círculo.
    """

    ALVOS, LIM, MARCAS, FATOR_SETA = T_XOR, (-1.5, 2.5), (-1, 0, 1, 2), 0.3
    EPOCAS = 30

    def construct(self):
        self.monta()
        passos = treino(T_XOR, epocas=self.EPOCAS, para_sem_erro=False)
        X0 = 0.8
        lugar_epoca, lugar_erros, lugar_pesos = (np.array([X0, y, 0.0]) for y in (3.35, 2.6, 1.75))

        # ------------------------------------------------ o gráfico dos erros por época
        grafico = Moldura((0.3, self.EPOCAS + 0.7), (0, 4.4), 5.3, 2.9, (1.4, -2.75), [1, 10, 20, 30], [0, 2, 4],
                          texto("época", 28, SUAVE), None, tam_marca=30)
        desenho = grafico.desenho()
        nome_grafico = texto("erros por época", 28, SUAVE).next_to(grafico.caixa(), UP, buff=0.18,
                                                                    aligned_edge=LEFT)
        largura_barra = 0.62 * grafico.largura / self.EPOCAS

        def barra(epoca, erros):
            altura = erros * grafico.altura / (grafico.y_lim[1] - grafico.y_lim[0])
            r = Rectangle(width=largura_barra, height=altura, fill_color=LARANJA, fill_opacity=1, stroke_width=0)
            return r.move_to(grafico.p(epoca, 0), aligned_edge=DOWN)

        atual = {}

        def troca(chave, novo, lugar, tempo):
            """Troca um dos três textos da direita: o velho sai, o novo entra escrito no mesmo lugar."""
            if chave in atual:
                self.play(FadeOut(atual[chave]), run_time=min(0.2, tempo * 0.5))
            novo.move_to(lugar, aligned_edge=LEFT)
            self.play(Write(novo), run_time=tempo)
            atual[chave] = novo

        def pesos(w, b):
            return eq(rf"\vec{{w}} = {vetor(w)} \qquad b = {n(b)}", tamanho=44, cor=AZUL)

        # ------------------------------------------------ a abertura
        self.abre()
        troca("pesos", pesos((0, 0), 0), lugar_pesos, 1.0)
        self.play(Create(desenho[0]), Create(VGroup(*desenho[1:-3])), Write(VGroup(*desenho[-3:])),
                  Write(nome_grafico), run_time=1.5)
        self.wait(0.6)

        for p in passos:
            e, k = p["epoca"], p["k"]
            devagar, medio, rapido = e <= 2, 3 <= e <= 4, e >= 5
            if e == 5 and k == 0:
                # daqui em diante, bem depressa: os erros e os pesos mudariam rápido demais
                # para ler, e saem; o gráfico conta os erros de cada época
                self.play(FadeOut(atual.pop("erros")), FadeOut(atual.pop("pesos")), run_time=0.4)
            t_escrita = 0.5 if devagar else (0.25 if medio else 0.07)
            if k == 0:
                troca("epoca", texto(f"Época {e}", 40, ESCURO, "SEMIBOLD"), lugar_epoca, t_escrita)
                if not rapido:
                    troca("erros", texto("erros nesta época: 0", 32, TEXTO), lugar_erros, t_escrita)
            if not rapido:
                self.pulsa(k, 0.3 if devagar else 0.15)
            if p["t"] != p["y"]:
                self.move_pesos(p["w"], p["b"], 1.1 if devagar else (0.55 if medio else (0.16 if e <= 10 else 0.11)))
                if not rapido:
                    troca("erros", texto(f"erros nesta época: {p['erros']}", 32, TEXTO), lugar_erros, t_escrita)
                    troca("pesos", pesos(p["w"], p["b"]), lugar_pesos, t_escrita)
            elif devagar:
                self.wait(0.4)
            if not rapido:
                self.despulsa(k, 0.2 if devagar else 0.1)
            if k == 3:
                self.play(GrowFromEdge(barra(e, p["erros"]), DOWN), run_time=0.6 if devagar else (0.25 if medio
                          else 0.1))

        # ------------------------------------------------ 30 épocas, e nenhuma sem erro
        self.play(FadeOut(atual["epoca"]), run_time=0.4)
        fim_1 = texto(f"Época {self.EPOCAS}: ainda 4 erros.", 38, LARANJA, "SEMIBOLD")
        fim_2 = texto("Nenhuma reta separa o XOR.", 32, TEXTO)
        fim_1.move_to(lugar_epoca, aligned_edge=LEFT)
        fim_2.move_to(lugar_erros, aligned_edge=LEFT)
        ultimo = passos[-1]
        fim_3 = pesos(ultimo["w"], ultimo["b"]).move_to(lugar_pesos, aligned_edge=LEFT)
        fim_4 = texto("iguais no fim de toda época", 26, SUAVE)
        fim_4.next_to(fim_3, DOWN, buff=0.22, aligned_edge=LEFT)
        self.play(Write(fim_1), run_time=1.1)
        self.play(Write(fim_2), run_time=1.1)
        self.play(Write(fim_3), run_time=1.0)
        self.play(Write(fim_4), run_time=1.1)
        self.wait(4)


# ================================================================ 3. a perda como função do peso

class PerdaDeW(Scene):
    """A reta gira com o peso w e, ao lado, a perda de cada w desenha uma curva com um fundo.

    À esquerda, os 20 pontos de reta.py, a reta y = w x + b e o erro de cada
    ponto, em segmento laranja. O viés fica parado no melhor valor, 0,967, e só
    w muda: de 0,2 a 4, com a perda escrita e o ponto correspondente andando no
    gráfico da direita, que vai desenhando a curva L(w). Depois o ponto volta ao
    fundo da curva, e a reta do fundo é a que melhor passa pelos pontos.
    """

    def construct(self):
        self.camera.background_color = FUNDO
        x, t = dados()
        w_mq, b_mq = minimos_quadrados(x, t)

        def L(w):
            return perda(w, b_mq, x, t)

        g1 = Moldura((0, 2.1), (0, 6), 5.3, 4.6, (-6.0, -2.45), [0, 1, 2], [0, 2, 4, 6], "x", "t")
        g2 = Moldura((0, 4.2), (0, 5.5), 5.3, 4.6, (1.45, -2.45), [0, 1, 2, 3, 4], [0, 1, 2, 3, 4, 5], "w", "L(w)")
        d1, d2 = g1.desenho(), g2.desenho()

        w, alcance = ValueTracker(0.2), ValueTracker(0.2001)

        def reta():
            a, c = g1.reta(0, b_mq, w.get_value())
            return Line(a, c, color=ESCURO, stroke_width=5)

        def erros():
            """O erro de cada ponto: o segmento vertical do ponto até a reta, cortado na moldura."""
            grupo = VGroup()
            for xi, ti in zip(x, t):
                yi = np.clip(w.get_value() * xi + b_mq, 0, 6)
                if abs(yi - ti) > 1e-3:
                    grupo.add(Line(g1.p(xi, ti), g1.p(xi, yi), color=LARANJA, stroke_width=3.5))
            return grupo

        def curva():
            return g2.curva(L, 0.2, max(alcance.get_value(), 0.21), pontos=200)

        def ponto():
            return Dot(g2.p(w.get_value(), L(w.get_value())), radius=0.11, color=LARANJA)

        linha, segmentos, tracado, bolinha = (always_redraw(reta), always_redraw(erros), always_redraw(curva),
                                              always_redraw(ponto))
        pontos = VGroup(*[Dot(g1.p(xi, ti), radius=0.075, color=AZUL) for xi, ti in zip(x, t)])

        # os números em cima de cada gráfico, fora dos dados
        valor_w = Numero(0.2, num_decimal_places=2, font_size=44, color=ESCURO)
        valor_l = Numero(L(0.2), num_decimal_places=3, font_size=44, color=LARANJA)
        rotulo_w = VGroup(eq("w =", tamanho=44), valor_w).arrange(RIGHT, buff=0.18)
        rotulo_l = VGroup(eq("L =", tamanho=44, cor=LARANJA), valor_l).arrange(RIGHT, buff=0.18)
        rotulo_w.move_to(g1.caixa().get_top() + UP * 0.5)
        rotulo_l.move_to(g2.caixa().get_top() + UP * 0.5)
        valor_w.add_updater(lambda d: d.set_value(w.get_value()))
        valor_l.add_updater(lambda d: d.set_value(L(w.get_value())))
        cima = np.array([0, 3.45, 0])
        aviso = frase("o viés fica parado em", "$b = 0{,}967;$", "só o peso", "$w$", "muda", tamanho=32, cor=SUAVE)
        aviso.move_to(cima)

        self.play(Create(d1[0]), Create(d2[0]), Create(VGroup(*d1[1:-4], *d2[1:-4])),
                  Write(VGroup(*d1[-4:], *d2[-4:])), run_time=1.8)
        self.play(LaggedStart(*[GrowFromCenter(p) for p in pontos], lag_ratio=0.06), run_time=1.3)
        self.play(Write(aviso), run_time=1.4)
        self.play(Create(linha), run_time=0.8)
        self.add(linha)
        self.play(Create(segmentos), run_time=1.0)
        self.add(segmentos, linha)
        self.bring_to_front(pontos)
        self.play(Write(rotulo_w), Write(rotulo_l), run_time=1.2)
        self.play(GrowFromCenter(bolinha), run_time=0.5)
        self.add(tracado, bolinha)
        self.wait(1.0)

        # w anda de 0,2 a 4: a reta gira, os erros mudam, e a curva se desenha
        self.play(w.animate.set_value(4.0), alcance.animate.set_value(4.0), run_time=8, rate_func=lambda u: u)
        self.wait(1.0)
        # e volta ao fundo, pela curva já desenhada
        self.play(w.animate.set_value(w_mq), run_time=3.5)
        rotulo_fundo = texto("o fundo", 30, LARANJA).move_to(g2.p(w_mq, 1.0))
        self.play(Write(rotulo_fundo), run_time=0.8)
        valor_w.clear_updaters()
        valor_l.clear_updaters()
        self.play(FadeOut(aviso), run_time=0.5)
        final = frase("No fundo da curva, a melhor reta:", "$w = 2{,}056$", tamanho=32, cor=ESCURO).move_to(cima)
        self.play(Write(final), run_time=1.5)
        self.wait(4)


# ================================================================ 4. a derivada

class Derivada(MovingCameraScene):
    """A secante de x² a partir de x = 1 gira até a tangente quando h encolhe; depois, a câmera chega perto.

    À esquerda, o gráfico, com o degrau da secante: a perna azul é h, a variação
    da entrada, e a laranja é f(1 + h) − f(1), a variação da saída. À direita, a
    razão entre as duas, com h e a inclinação escritos e mudando junto com o
    desenho: 3; 2,5; 2,1; 2,01. Com h = 0,01, o degrau é pequeno demais para se
    ver, e a câmera se aproxima 200 vezes do ponto (1, 1): de perto, a curva
    vira uma reta, o degrau aparece, e ele anda 0,01 e sobe 0,0201. Na volta,
    a tangente, de inclinação 2, e o limite.
    """

    def construct(self):
        self.camera.background_color = FUNDO
        quadro = self.camera.frame
        largura0, centro0 = quadro.width, quadro.get_center().copy()

        def escala():
            """Quanto a câmera está perto: 1 no começo, 0,005 no fundo do zoom."""
            return quadro.width / largura0

        g = Moldura((-0.6, 2.4), (-1, 5.5), 5.6, 6.2, (-5.0, -3.0), [0, 1, 2], [0, 1, 2, 3, 4, 5], "x", "f(x)")
        eixos = g.desenho()

        def f(v):
            return v ** 2

        curva = g.curva(f, -0.6, 2.4, pontos=400)
        X0 = 1.1
        nome = eq("f(x) = x^2", tamanho=50).move_to(np.array([X0, 3.0, 0]), aligned_edge=LEFT)
        h = ValueTracker(1.0)

        def secante():
            a, c = g.reta(1, 1, 2 + h.get_value())
            return DashedLine(a, c, color=CINZA, stroke_width=4, dash_length=0.12)

        def perna_h():
            return Line(g.p(1, 1), g.p(1 + h.get_value(), 1), color=AZUL, stroke_width=6 * escala())

        def perna_f():
            v = h.get_value()
            return Line(g.p(1 + v, 1), g.p(1 + v, f(1 + v)), color=LARANJA, stroke_width=6 * escala())

        def ponto_q():
            v = h.get_value()
            return Dot(g.p(1 + v, f(1 + v)), radius=0.09 * escala(), color=ESCURO)

        p = Dot(g.p(1, 1), radius=0.1, color=ESCURO)
        p.add_updater(lambda d: d.scale_to_fit_width(0.2 * escala()).move_to(g.p(1, 1)))
        sec, ph, pf, q = (always_redraw(secante), always_redraw(perna_h), always_redraw(perna_f),
                          always_redraw(ponto_q))

        # à direita, a razão, com o numerador em laranja e o denominador em azul, como as pernas
        razao = eq(r"\frac{f(1 + h) - f(1)}{h}", tamanho=62)
        colore_fracao(razao, LARANJA, AZUL)
        razao.move_to(np.array([X0, 1.45, 0]), aligned_edge=LEFT)
        val_h = Numero(1.0, num_decimal_places=2, font_size=50, color=AZUL)
        val_i = Numero(3.0, num_decimal_places=2, font_size=50, color=ESCURO)
        linha_h = VGroup(eq("h =", tamanho=50, cor=AZUL), val_h).arrange(RIGHT, buff=0.2)
        linha_h.move_to(np.array([X0, -0.05, 0]), aligned_edge=LEFT)
        linha_i = VGroup(texto("inclinação =", 36, ESCURO), val_i).arrange(RIGHT, buff=0.2)
        linha_i.move_to(np.array([X0, -1.0, 0]), aligned_edge=LEFT)
        val_h.add_updater(lambda d: d.set_value(h.get_value()))
        val_i.add_updater(lambda d: d.set_value(2 + h.get_value()))

        self.play(Create(eixos[0]), Create(VGroup(*eixos[1:-4])), Write(VGroup(*eixos[-4:])), run_time=1.6)
        self.play(Create(curva), Write(nome), run_time=1.6)
        self.play(GrowFromCenter(p), run_time=0.5)
        self.play(Create(ph), Create(pf), GrowFromCenter(q), run_time=1)
        self.add(ph, pf, q)
        self.play(Create(sec), run_time=1)
        self.add(sec, p, q)
        self.play(Write(razao), run_time=1.5)
        self.play(Write(linha_h), Write(linha_i), run_time=1.4)
        self.wait(1.5)
        for alvo in (0.5, 0.1, 0.01):
            self.play(h.animate.set_value(alvo), run_time=2.4)
            self.wait(1.2)
        val_h.clear_updaters()
        val_i.clear_updaters()

        # ------------------------------------------------ de perto: a câmera se aproxima 200 vezes do ponto (1, 1)
        # O zoom é exponencial, com a mesma rapidez do começo ao fim, e o ponto (1, 1)
        # chega aos poucos ao centro da tela. A linha tracejada sai antes: de perto, os
        # tracinhos ficariam maiores que a tela.
        alvo = g.p(1.005, 1.0101)
        u = ValueTracker(0.0)
        K = 0.005

        # Neste renderizador, a espessura do traço cresce com o zoom: cada linha visível
        # afina na mesma proporção, para continuar com a espessura de antes na tela.
        finas = [(m, m.get_stroke_width()) for m in [*eixos.family_members_with_points(), curva]
                 if m.get_stroke_width() > 0]

        def acompanha(fr):
            k = K ** u.get_value()
            fr.set(width=largura0 * k)
            fr.move_to(alvo + (centro0 - alvo) * k * (1 - smooth(u.get_value())))
            for m, largura in finas:
                m.set_stroke(width=largura * k)

        self.play(FadeOut(sec), run_time=0.6)
        self.add(quadro)                                 # fora da cena, o quadro não roda o updater
        quadro.add_updater(acompanha)
        self.play(u.animate.set_value(1.0), run_time=5, rate_func=smooth)

        def perto(mob, onde):
            """Um texto no tamanho de quem está de perto: encolhido pelo zoom e posto na tela de agora."""
            return mob.scale(escala()).move_to(quadro.get_center() + onde * escala())

        leg_h = perto(eq("0{,}01", tamanho=48, cor=AZUL), np.array([0, 0, 0]))
        leg_h.next_to(g.p(1.005, 1), DOWN, buff=0.25 * escala())
        leg_f = perto(eq("0{,}0201", tamanho=48, cor=LARANJA), np.array([0, 0, 0]))
        leg_f.next_to(g.p(1.01, 1.01005), RIGHT, buff=0.25 * escala())
        conta = perto(eq(r"\frac{0{,}0201}{0{,}01} = 2{,}01", tamanho=56), np.array([-3.6, 2.4, 0]))
        aviso = VGroup(texto("de perto, a curva", 36, TEXTO), texto("é quase uma reta", 36, TEXTO)
                       ).arrange(DOWN, aligned_edge=LEFT, buff=0.18)
        aviso = perto(aviso, np.array([4.5, -3.0, 0]))

        def escreve(*mobs, tempo):
            """Write de perto: o traço provisório da escrita encolhe junto com o zoom."""
            self.play(*[Write(m, stroke_width=2 * escala()) for m in mobs], run_time=tempo)
        escreve(aviso, tempo=1.3)
        escreve(leg_h, leg_f, tempo=1.3)
        escreve(conta, tempo=1.5)
        self.wait(2.5)
        self.play(FadeOut(aviso), FadeOut(leg_h), FadeOut(leg_f), FadeOut(conta), run_time=0.6)
        self.play(u.animate.set_value(0.0), run_time=4, rate_func=smooth)
        quadro.clear_updaters()

        # ------------------------------------------------ a tangente: a secante no limite, quando h tende a zero
        a, c = g.reta(1, 1, 2)
        tangente = Line(a, c, color=VERMELHO, stroke_width=6)
        self.play(FadeOut(ph), FadeOut(pf), FadeOut(q), FadeOut(razao), FadeOut(linha_h), FadeOut(linha_i),
                  run_time=0.8)
        self.play(Create(tangente), run_time=1.3)
        self.add(p)
        limite = eq(r"f'(1) = \lim_{h \to 0} \frac{f(1 + h) - f(1)}{h} = 2", tamanho=40)
        limite.move_to(np.array([X0, 1.0, 0]), aligned_edge=LEFT)
        legenda_t = VGroup(Line(LEFT * 0.35, RIGHT * 0.35, color=VERMELHO, stroke_width=6),
                           texto("a tangente", 34, ESCURO)).arrange(RIGHT, buff=0.2)
        legenda_t.move_to(np.array([X0, -0.6, 0]), aligned_edge=LEFT)
        self.play(Write(limite), run_time=2)
        self.play(Write(legenda_t), run_time=1.3)
        self.wait(4)


# ================================================================ 5. as derivadas parciais, em 3D

def eixos_da_perda():
    """Os eixos 3D da perda da reta: w de −0,6 a 4,2, b de −1,2 a 3,2 e L de 0 a 24."""
    return ThreeDAxes(x_range=[-0.6, 4.2, 1], y_range=[-1.2, 3.2, 1], z_range=[0, 24, 4],
                      x_length=6.0, y_length=5.5, z_length=3.6,
                      axis_config=dict(color=ESCURO, stroke_width=2, include_tip=False))


def chao(eixos, marcas=True):
    """A moldura do chão, o plano (w, b), com as marcas e os números por fora, para a vista de cima.

    O chão é o retângulo de w entre −0,6 e 4,2 e b entre −1,2 e 3,2. Vista de
    cima, a moldura faz o papel dos eixos: os números ficam fora dela e não
    caem em cima das curvas de nível.
    """
    cantos = [eixos.c2p(-0.6, -1.2, 0), eixos.c2p(4.2, -1.2, 0), eixos.c2p(4.2, 3.2, 0), eixos.c2p(-0.6, 3.2, 0)]
    borda = Polygon(*cantos, stroke_color=ESCURO, stroke_width=2.5, fill_opacity=0)
    tracos, numeros = VGroup(), VGroup()
    if marcas:
        for v in range(0, 5):
            base = eixos.c2p(v, -1.2, 0)
            tracos.add(Line(base, eixos.c2p(v, -1.05, 0), color=ESCURO, stroke_width=2.5))
            numeros.add(eq(n(v), tamanho=36, cor=TEXTO).next_to(base, DOWN, buff=0.14))
        for v in range(-1, 4):
            base = eixos.c2p(-0.6, v, 0)
            tracos.add(Line(base, eixos.c2p(-0.45, v, 0), color=ESCURO, stroke_width=2.5))
            numeros.add(eq(n(v), tamanho=36, cor=TEXTO).next_to(base, LEFT, buff=0.14))
    nomes = VGroup(eq("w", tamanho=40).next_to(VGroup(borda, numeros), DOWN, buff=0.2),
                   eq("b", tamanho=40).next_to(VGroup(borda, numeros), LEFT, buff=0.25))
    return borda, tracos, VGroup(numeros, nomes)


def curvas_de_nivel(eixos, x, t, niveis):
    """As curvas de perda constante, as elipses em volta do mínimo, cortadas no chão, em pedaços contínuos.

    A perda da reta é uma forma quadrática em (w, b): L = L_min + ½ dᵀ H d, com d
    a distância ao mínimo e H a hessiana, a tabela das derivadas segundas. Cada
    curva de nível é então uma elipse, com os eixos nas direções dos autovetores
    de H.
    """
    w_mq, b_mq = minimos_quadrados(x, t)
    H = (2 / len(x)) * np.array([[np.sum(x ** 2), np.sum(x)], [np.sum(x), len(x)]])
    lam, V = np.linalg.eigh(H)
    L_min = perda(w_mq, b_mq, x, t)
    grupo = VGroup()
    for nivel in niveis:
        r = np.sqrt(2 * (nivel - L_min))
        th = np.linspace(0, 2 * np.pi, 600)
        ws = w_mq + r * (V[0, 0] * np.cos(th) / np.sqrt(lam[0]) + V[0, 1] * np.sin(th) / np.sqrt(lam[1]))
        bs = b_mq + r * (V[1, 0] * np.cos(th) / np.sqrt(lam[0]) + V[1, 1] * np.sin(th) / np.sqrt(lam[1]))
        dentro = (ws > -0.6) & (ws < 4.2) & (bs > -1.2) & (bs < 3.2)
        trecho = []
        for a, c, ok in zip(ws, bs, dentro):
            if ok:
                trecho.append(eixos.c2p(a, c, 0))
            elif len(trecho) > 1:
                grupo.add(VMobject(stroke_color=CINZA, stroke_width=3).set_points_smoothly(trecho))
                trecho = []
        if len(trecho) > 1:
            grupo.add(VMobject(stroke_color=CINZA, stroke_width=3).set_points_smoothly(trecho))
    return grupo


def trecho_ate(eixos, f, u_de, u_ate, teto=23.5):
    """Os valores de u entre u_de e u_ate em que f(u) fica abaixo do teto do gráfico, o maior pedaço contínuo."""
    us = np.linspace(u_de, u_ate, 400)
    ok = np.array([f(u) <= teto for u in us])
    melhor, atual = (0, 0), None
    for i, v in enumerate(ok):
        if v and atual is None:
            atual = i
        if (not v or i == len(ok) - 1) and atual is not None:
            fim = i if v else i - 1
            if fim - atual > melhor[1] - melhor[0]:
                melhor = (atual, fim)
            atual = None
    return us[melhor[0]], us[melhor[1]]


class Parcial(ThreeDScene):
    """As derivadas parciais como dois cortes na superfície da perda, e o gradiente que junta as duas.

    A superfície é L(w, b) para os 20 pontos de reta.py, e o ponto P é
    (w, b) = (0,5; −0,3), o mesmo do slide da derivada parcial. Primeiro, um
    plano com b parado em −0,3 corta a superfície: o corte é uma parábola em w,
    e a inclinação da tangente a ela em P é ∂L/∂w = −5,66. Depois, o plano com
    w parado em 0,5: a parábola em b, com ∂L/∂b = −5,36. No fim, a câmera sobe,
    e as duas inclinações viram as duas componentes do gradiente, no chão, com
    as curvas de nível: o gradiente sai da curva em ângulo reto, e descer é
    andar contra ele.
    """

    def construct(self):
        self.camera.background_color = FUNDO
        x, t = dados()
        eixos = eixos_da_perda()
        W0, B0 = 0.5, -0.3

        def L(w, b):
            return perda(w, b, x, t)

        L0 = L(W0, B0)
        dw, db = gradiente(W0, B0, x, t)
        superficie = Surface(lambda u, v: eixos.c2p(u, v, L(u, v)), u_range=[-0.6, 4.2], v_range=[-1.2, 3.2],
                             resolution=(24, 24), fill_opacity=0.55, stroke_width=0.3, stroke_color=BORDA,
                             checkerboard_colors=["#7FA6CF", "#6E97C4"])
        self.set_camera_orientation(phi=66 * DEGREES, theta=-72 * DEGREES, zoom=0.92,
                                    frame_center=eixos.c2p(1.8, 1.0, 12))
        rotulos = VGroup(eq("w", tamanho=40).move_to(eixos.c2p(4.75, -1.2, 0)),
                         eq("b", tamanho=40).move_to(eixos.c2p(-0.6, 3.75, 0)),
                         eq("L", tamanho=40).move_to(eixos.c2p(-0.6, -1.2, 26.5)))
        self.add_fixed_orientation_mobjects(*rotulos)
        self.remove(*rotulos)

        # os textos fixos na tela: a frase do que se vê, no alto à esquerda, e a conta, no alto à direita,
        # onde a superfície não chega nas duas vistas
        canto_frase = np.array([-6.7, 3.45, 0])
        canto_conta = np.array([6.8, 3.3, 0])
        atual = {}

        def fixa(chave, novo, onde, alinhamento, tempo=1.2):
            """Troca um texto fixo na tela: o velho sai, o novo entra escrito."""
            if chave in atual:
                self.play(FadeOut(atual[chave]), run_time=0.4)
            novo.move_to(onde, aligned_edge=alinhamento)
            self.add_fixed_in_frame_mobjects(novo)
            self.remove(novo)
            self.play(Write(novo), run_time=tempo)
            atual[chave] = novo

        self.play(Create(eixos), Write(rotulos), run_time=1.6)
        self.play(Create(superficie), run_time=2.5)
        fixa("frase", frase("a altura é a perda de cada par", "$(w,\\ b)$", tamanho=32), canto_frase, LEFT)
        P = Dot3D(eixos.c2p(W0, B0, L0), radius=0.1, color=ESCURO)
        haste = DashedLine(eixos.c2p(W0, B0, 0), eixos.c2p(W0, B0, L0), color=ESCURO, stroke_width=3,
                           dash_length=0.1)
        self.play(Create(haste), GrowFromCenter(P), run_time=1.0)
        fixa("conta", eq(rf"P = (0{{,}}5;\ -0{{,}}3), \quad L = {br(L0)}", tamanho=40), canto_conta, RIGHT)
        self.wait(1.0)

        def corte(fixo_em_b, cor):
            """O plano de corte, a parábola que ele tira da superfície e a tangente a ela em P.

            fixo_em_b=True: o plano b = −0,3, e a parábola é L(w, −0,3), função de w.
            fixo_em_b=False: o plano w = 0,5, e a parábola é L(0,5; b), função de b.
            """
            if fixo_em_b:
                plano = Polygon(eixos.c2p(-0.6, B0, 0), eixos.c2p(4.2, B0, 0), eixos.c2p(4.2, B0, 24),
                                eixos.c2p(-0.6, B0, 24))
                u_de, u_ate = trecho_ate(eixos, lambda u: L(u, B0), -0.6, 4.2)
                parabola = ParametricFunction(lambda u: eixos.c2p(u, B0, L(u, B0)), t_range=[u_de, u_ate])
                d = 0.55
                tangente = Line(eixos.c2p(W0 - d, B0, L0 - d * dw), eixos.c2p(W0 + d, B0, L0 + d * dw))
            else:
                plano = Polygon(eixos.c2p(W0, -1.2, 0), eixos.c2p(W0, 3.2, 0), eixos.c2p(W0, 3.2, 24),
                                eixos.c2p(W0, -1.2, 24))
                u_de, u_ate = trecho_ate(eixos, lambda u: L(W0, u), -1.2, 3.2)
                parabola = ParametricFunction(lambda u: eixos.c2p(W0, u, L(W0, u)), t_range=[u_de, u_ate])
                d = 0.55
                tangente = Line(eixos.c2p(W0, B0 - d, L0 - d * db), eixos.c2p(W0, B0 + d, L0 + d * db))
            plano.set_fill(cor, opacity=0.16).set_stroke(cor, width=2)
            parabola.set_stroke(cor, width=7)
            tangente.set_stroke(ESCURO, width=6)
            return plano, parabola, tangente

        # ------------------------------------------------ o corte com b parado: a derivada parcial em w
        plano_b, parabola_b, tangente_b = corte(True, LARANJA)
        fixa("frase", frase("com", "$b$", "parado em", "$-0{,}3,$", "só", "$w$", "varia", tamanho=32, cor=LARANJA),
             canto_frase, LEFT)
        self.play(Create(plano_b), run_time=1.3)
        self.play(Create(parabola_b), run_time=1.6)
        self.play(Create(tangente_b), run_time=1.0)
        self.add(P)
        conta_w = eq(r"\frac{\partial L}{\partial w} = " + br(dw), tamanho=52, cor=LARANJA)
        fixa("conta", conta_w, canto_conta, RIGHT, 1.4)
        self.wait(2.0)

        # ------------------------------------------------ o corte com w parado: a derivada parcial em b
        self.play(FadeOut(plano_b), FadeOut(parabola_b), FadeOut(tangente_b), FadeOut(atual.pop("conta")),
                  run_time=0.8)
        self.move_camera(theta=-18 * DEGREES, run_time=2.5)
        plano_w, parabola_w, tangente_w = corte(False, AZUL)
        fixa("frase", frase("com", "$w$", "parado em", "$0{,}5,$", "só", "$b$", "varia", tamanho=32, cor=AZUL),
             canto_frase, LEFT)
        self.play(Create(plano_w), run_time=1.3)
        self.play(Create(parabola_w), run_time=1.6)
        self.play(Create(tangente_w), run_time=1.0)
        self.add(P)
        conta_b = eq(r"\frac{\partial L}{\partial b} = " + br(db), tamanho=52, cor=AZUL)
        fixa("conta", conta_b, canto_conta, RIGHT, 1.4)
        self.wait(2.0)

        # ------------------------------------------------ de cima: as duas inclinações são as componentes do gradiente
        # O chão de cima é desenhado num segundo par de eixos, à esquerda da tela: mudar
        # o centro da câmera para os lados arrastaria junto os textos fixos na tela.
        self.play(FadeOut(plano_w), FadeOut(parabola_w), FadeOut(tangente_w), FadeOut(atual.pop("frase")),
                  FadeOut(atual.pop("conta")), FadeOut(P), FadeOut(haste), FadeOut(rotulos), run_time=0.8)
        self.move_camera(phi=0, theta=-90 * DEGREES, zoom=1.0, frame_center=np.array([0.0, 0.0, 0.0]),
                         added_anims=[FadeOut(superficie), FadeOut(eixos)], run_time=2.5)
        de_cima = eixos_da_perda()
        de_cima.shift(np.array([-3.5, -0.1, 0]) - de_cima.c2p(1.8, 1.0, 0))
        borda, tracos, numeros = chao(de_cima)
        curvas = curvas_de_nivel(de_cima, x, t, (0.3, 0.8, 2, 4, L0, 12))
        self.play(Create(borda), Create(tracos), Write(numeros), run_time=1.3)
        self.play(Create(curvas), run_time=1.8)
        base = de_cima.c2p(W0, B0, 0)
        E = 0.12                                         # o gradiente mede 7,8; desenhado em 12% do tamanho

        def seta(dx, dy, cor):
            return Arrow(base, de_cima.c2p(W0 + E * dx, B0 + E * dy, 0), buff=0, color=cor, stroke_width=7,
                         max_tip_length_to_length_ratio=0.28)
        seta_w, seta_b, seta_g, seta_d = seta(dw, 0, LARANJA), seta(0, db, AZUL), seta(dw, db, ESCURO), \
            seta(-dw, -db, VERMELHO)
        marca_p = Dot(base, radius=0.08, color=ESCURO)

        # à direita, fixa na tela, a legenda das quatro setas, na ordem em que aparecem
        def item(cor, *pedacos):
            return VGroup(Arrow(LEFT * 0.3, RIGHT * 0.3, buff=0, color=cor, stroke_width=6),
                          frase(*pedacos, tamanho=30, cor=cor)).arrange(RIGHT, buff=0.22)
        itens = VGroup(item(LARANJA, "$\\partial L / \\partial w = " + br(dw) + "$"),
                       item(AZUL, "$\\partial L / \\partial b = " + br(db) + "$"),
                       item(ESCURO, "o gradiente: a subida"),
                       item(VERMELHO, "contra ele: a descida")).arrange(DOWN, aligned_edge=LEFT, buff=0.42)
        gradiente_eq = eq(r"\vec{\nabla} L = (" + br(dw) + r";\ " + br(db) + ")", tamanho=46)
        coluna = VGroup(gradiente_eq, itens).arrange(DOWN, aligned_edge=LEFT, buff=0.6)
        coluna.move_to(np.array([1.1, 0, 0]), aligned_edge=LEFT)
        for m in (gradiente_eq, *itens):
            self.add_fixed_in_frame_mobjects(m)
            self.remove(m)
        self.play(GrowFromCenter(marca_p), run_time=0.4)
        self.play(GrowArrow(seta_w), Write(itens[0]), run_time=1.1)
        self.play(GrowArrow(seta_b), Write(itens[1]), run_time=1.1)
        self.play(GrowArrow(seta_g), Write(gradiente_eq), run_time=1.4)
        self.play(Write(itens[2]), run_time=1.1)
        self.wait(1.0)
        self.play(GrowArrow(seta_d), Write(itens[3]), run_time=1.4)
        self.wait(4)


# ================================================================ 6. a descida em 3D

class Descida3D(ThreeDScene):
    """A descida na superfície da perda, com a reta se ajustando aos pontos ao lado, e no fim a mesma descida de cima.

    A superfície é L(w, b) para os 20 pontos de reta.py. Um ponto desce pelo
    gradiente, com η = 0,1, a partir de (0, 0), deixando o rastro. À direita,
    fixos na tela, os 20 pontos e a reta do passo atual, com o passo e a perda
    escritos em cima: cada ponto do chão é uma reta, e descer é a reta chegar
    aos pontos. No fim, a câmera sobe, e a mesma descida aparece vista de cima,
    sobre as curvas de nível, com a moldura do chão no lugar dos eixos.
    """

    def construct(self):
        self.camera.background_color = FUNDO
        x, t = dados()
        eixos = eixos_da_perda()
        # A tigela fica na esquerda da tela. O centro da câmera não sai do eixo vertical,
        # porque, fora dele, o Manim arrasta junto os textos fixos na tela: quem anda é a
        # tigela, deslocada no espaço para o lado que aparece como esquerda.
        teta = -52 * DEGREES
        direita = np.array([-np.sin(teta), np.cos(teta), 0.0])      # a direção que aparece como direita na tela
        eixos.shift(-2.6 * direita - eixos.c2p(1.8, 1.0, 0) * np.array([1, 1, 0]))
        altura = eixos.c2p(1.8, 1.0, 11)[2]
        self.set_camera_orientation(phi=62 * DEGREES, theta=teta, zoom=0.9,
                                    frame_center=np.array([0.0, 0.0, altura]))

        def L(w, b):
            return perda(w, b, x, t)

        superficie = Surface(lambda u, v: eixos.c2p(u, v, L(u, v)), u_range=[-0.6, 4.2], v_range=[-1.2, 3.2],
                             resolution=(26, 26), fill_opacity=0.9, stroke_width=0.3, stroke_color=BORDA,
                             checkerboard_colors=["#7FA6CF", "#6E97C4"])

        # o caminho do gradiente descendente, com η = 0,1
        caminho = [(0.0, 0.0)]
        for _ in range(60):
            dw, db = gradiente(*caminho[-1], x, t)
            caminho.append((caminho[-1][0] - 0.1 * dw, caminho[-1][1] - 0.1 * db))
        pontos3d = [eixos.c2p(a, c, L(a, c) + 0.25) for a, c in caminho]

        regra = eq(r"(w,\ b) \leftarrow (w,\ b) - \eta\,\vec{\nabla} L", tamanho=46).to_corner(UP + LEFT, buff=0.45)
        self.add_fixed_in_frame_mobjects(regra)
        self.remove(regra)

        # ------------------------------------------------ o quadro dos dados, fixo à direita da tela
        g = Moldura((0, 2.1), (-0.5, 6), 3.6, 3.6, (2.9, -2.45), [0, 1, 2], [0, 2, 4, 6], "x", "t", tam_marca=30,
                    tam_nome=36)
        quadro_dados = g.desenho()
        pontos = VGroup(*[Dot(g.p(xi, ti), radius=0.065, color=AZUL) for xi, ti in zip(x, t)])

        def reta_do_passo(k):
            w, b = caminho[k]
            a, c = g.reta(0, b, w)
            return Line(a, c, color=LARANJA, stroke_width=5)

        def placar(k):
            """O passo e a perda, em cima do quadro dos dados; um novo a cada passo, fixo na tela."""
            linhas = VGroup(
                VGroup(texto("passo", 32, SUAVE), Numero(k, num_decimal_places=0, font_size=44, color=ESCURO)
                       ).arrange(RIGHT, buff=0.2),
                VGroup(eq("L =", tamanho=42, cor=SUAVE), Numero(L(*caminho[k]), num_decimal_places=3, font_size=44,
                                                                color=LARANJA)).arrange(RIGHT, buff=0.2))
            return linhas.arrange(RIGHT, buff=0.6).next_to(g.caixa(), UP, buff=0.45)

        for m in (quadro_dados, pontos):
            self.add_fixed_in_frame_mobjects(m)
            self.remove(m)

        self.play(Write(regra), run_time=1.4)
        rotulos = VGroup(eq("w", tamanho=40).move_to(eixos.c2p(4.75, 0, 0)),
                         eq("b", tamanho=40).move_to(eixos.c2p(0, 3.75, 0)),
                         eq("L", tamanho=40).move_to(eixos.c2p(0, 0, 26.5)))
        self.add_fixed_orientation_mobjects(*rotulos)
        self.remove(*rotulos)
        self.play(Create(eixos), Write(rotulos), run_time=1.6)
        self.play(Create(superficie), run_time=2.5)
        self.play(Create(quadro_dados[0]), Create(VGroup(*quadro_dados[1:-4])), Write(VGroup(*quadro_dados[-4:])),
                  run_time=1.4)
        self.play(LaggedStart(*[GrowFromCenter(p) for p in pontos], lag_ratio=0.05), run_time=1.0)

        atual_placar, atual_reta = placar(0), reta_do_passo(0)
        for m in (atual_placar, atual_reta):
            self.add_fixed_in_frame_mobjects(m)
            self.remove(m)
        bolinha = Dot3D(pontos3d[0], radius=0.1, color=LARANJA)
        self.play(GrowFromCenter(bolinha), Create(atual_reta), Write(atual_placar), run_time=1.2)
        self.bring_to_front(pontos)
        rastro = TracedPath(bolinha.get_center, stroke_color=LARANJA, stroke_width=6)
        self.add(rastro)
        self.wait(0.8)
        for k in range(1, len(pontos3d)):
            tempo = 0.55 if k <= 6 else (0.2 if k <= 30 else 0.09)
            novo_placar, nova_reta = placar(k), reta_do_passo(k)
            self.add_fixed_in_frame_mobjects(novo_placar, nova_reta)
            self.remove(atual_placar, atual_reta)
            self.bring_to_front(pontos)
            atual_placar, atual_reta = novo_placar, nova_reta
            self.play(bolinha.animate.move_to(pontos3d[k]), run_time=tempo)
        self.wait(1.5)

        # ------------------------------------------------ a mesma descida vista de cima
        # Saem os eixos 3D e a regra; a câmera sobe. O chão de cima é desenhado num
        # segundo par de eixos, na esquerda da tela, e o quadro dos dados fica à direita.
        self.play(FadeOut(eixos), FadeOut(rotulos), FadeOut(bolinha), FadeOut(rastro), FadeOut(regra), run_time=1)
        self.move_camera(phi=0, theta=-90 * DEGREES, zoom=0.9, frame_center=np.array([0.0, 0.0, 0.0]),
                         added_anims=[FadeOut(superficie)], run_time=2.5)
        de_cima = eixos_da_perda()
        de_cima.shift(np.array([-3.9, -0.1, 0]) - de_cima.c2p(1.8, 1.0, 0))
        borda, tracos, numeros = chao(de_cima)
        curvas = curvas_de_nivel(de_cima, x, t, (0.3, 0.8, 1.5, 4, 8))
        trilha = VMobject(stroke_color=LARANJA, stroke_width=6).set_points_as_corners(
            [de_cima.c2p(a, c, 0) for a, c in caminho])
        self.play(Create(borda), Create(tracos), Write(numeros), run_time=1.3)
        self.play(Create(curvas), run_time=2.0)
        self.play(Create(trilha), run_time=2.2)
        self.wait(4)


# ================================================================ 7. a taxa de aprendizado

class Taxa(Scene):
    """O gradiente descendente na parábola L(w) = (w − 2)², com η = 0,1, 0,9 e 1,05.

    Cada passo é uma seta que vai do ponto atual ao seguinte, sobre a curva. À
    direita, a distância até o fundo, w − 2, com o sinal, é escrita a cada
    passo: ela é multiplicada por 1 − 2η, o fator de cada taxa, e o sinal que
    troca mostra o ponto pulando de um lado do fundo para o outro. As setas de
    cada taxa saem antes da seguinte, para não se cruzarem; as da última ficam.
    No fim, a regra que separa as três: converge se |1 − 2η| < 1, isto é, se
    0 < η < 1.
    """

    CASOS = [(0.1, AZUL, "devagar"), (0.9, ESCURO, "oscila e chega"), (1.05, LARANJA, "diverge")]
    PASSOS = 3

    def construct(self):
        self.camera.background_color = FUNDO
        g = Moldura((-1.5, 5.5), (0, 12.5), 5.4, 6.2, (-5.2, -3.0), [-1, 0, 1, 2, 3, 4, 5], [0, 4, 8, 12],
                    "w", "L(w)")
        eixos = g.desenho()

        def L(w):
            return (w - 2) ** 2

        curva = g.curva(L, -1.5, 5.5, cor=CINZA)
        X0 = 0.9
        regra = eq(r"w \leftarrow w - \eta\, L'(w)", tamanho=50).move_to(np.array([X0, 3.3, 0]), aligned_edge=LEFT)
        derivada = eq(r"L'(w) = 2\,(w - 2)", tamanho=40, cor=SUAVE).next_to(regra, DOWN, aligned_edge=LEFT,
                                                                         buff=0.2)

        self.play(Create(eixos[0]), Create(VGroup(*eixos[1:-4])), Write(VGroup(*eixos[-4:])), run_time=1.5)
        self.play(Create(curva), run_time=1.2)
        self.play(Write(regra), run_time=1.4)
        self.play(Write(derivada), run_time=1.1)
        self.wait(0.8)

        blocos_y = [1.45, -0.15, -1.75]
        setas = VGroup()
        for i, ((eta, cor, nome), y0) in enumerate(zip(self.CASOS, blocos_y)):
            fator = 1 - 2 * eta
            cabeca = eq(rf"\eta = {br(eta, 2 if eta > 1 else 1)}", rf"\quad\text{{{nome}: fator }} {{{br(fator, 1)}}}",
                        tamanho=36, cor=cor)
            cabeca.move_to(np.array([X0, y0, 0]), aligned_edge=LEFT)
            self.play(Write(cabeca), run_time=1.1)
            w = 0.0
            ponto = Dot(g.p(w, L(w)), radius=0.11, color=cor)
            self.play(GrowFromCenter(ponto), run_time=0.4)
            # a fileira das distâncias até o fundo, com o sinal: o rótulo à esquerda, e um número a cada passo
            rotulo_d = eq(r"w - 2{:}", tamanho=34, cor=SUAVE).move_to(np.array([X0, y0 - 0.62, 0]), aligned_edge=LEFT)
            distancias = VGroup(eq(br(w - 2, 0), tamanho=34, cor=cor)).next_to(rotulo_d, RIGHT, buff=0.25)
            self.play(Write(rotulo_d), Write(distancias[0]), run_time=0.8)
            setas = VGroup()
            for k in range(self.PASSOS):
                novo_w = w - eta * 2 * (w - 2)
                seta = Arrow(g.p(w, L(w)), g.p(novo_w, L(novo_w)), buff=0.13, color=cor,
                             stroke_width=5, max_tip_length_to_length_ratio=0.12)
                casas = 1 if abs(round((novo_w - 2) * 10) - (novo_w - 2) * 10) < 1e-9 else 2
                d = eq(br(novo_w - 2, casas), tamanho=34, cor=cor).next_to(distancias, RIGHT, buff=0.3)
                self.play(GrowArrow(seta), ponto.animate.move_to(g.p(novo_w, L(novo_w))), run_time=0.8)
                self.play(Write(d), run_time=0.45)
                distancias.add(d)
                setas.add(seta)
                w = novo_w
            self.wait(1.2)
            if i < len(self.CASOS) - 1:
                self.play(FadeOut(setas), FadeOut(ponto), run_time=0.6)

        conclusao = eq(r"|1 - 2\eta| < 1 \;\Leftrightarrow\; 0 < \eta < 1", tamanho=46, cor=ESCURO)
        conclusao.move_to(np.array([X0, -3.35, 0]), aligned_edge=LEFT)
        self.play(Write(conclusao), run_time=1.8)
        self.wait(4)


# ================================================================ a gravação

CENAS = {"perceptron-e": PerceptronE, "perceptron-xor": PerceptronXOR, "perda-de-w": PerdaDeW,
         "derivada": Derivada, "parcial": Parcial, "descida-3d": Descida3D, "taxa": Taxa}


def grava(nome, largura=1920, altura=1080, quadros=30, pasta_videos=None, pasta_quadros=None, crf=24):
    """Desenha a cena e grava o mp4 e o png do último quadro.

    O padrão é 1920 por 1080, a 30 quadros por segundo, nas pastas videos/ e
    figuras/ da aula. Uma prévia pequena, para conferir a disposição antes da
    gravação final, pede tamanho menor e outras pastas.
    O manim trabalha numa pasta temporária, uma por vídeo, dentro da pasta dos
    vídeos, 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.
    """
    pasta_videos = Path(pasta_videos or AULA / "videos")
    pasta_quadros = Path(pasta_quadros or AULA / "figuras")
    pasta_videos.mkdir(parents=True, exist_ok=True)
    trabalho = pasta_videos / f"_manim-{nome}"   # uma pasta por vídeo: gravar vários ao mesmo tempo não dá conflito
    with tempconfig({"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}):
        cena = CENAS[nome]()
        cena.render()
        mp4 = Path(cena.renderer.file_writer.movie_file_path)
    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)
    png = pasta_quadros / f"{nome}-quadro.png"   # -quadro: não pisar nas figuras estáticas
    subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-sseof", "-0.5", "-i", str(destino),
                    "-frames:v", "1", "-update", "1", str(png)], check=True)
    shutil.rmtree(trabalho, ignore_errors=True)
    for arquivo in (destino, png):
        print(f"gravado: {arquivo} ({arquivo.stat().st_size / 1e6:.1f} MB)")


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")      # os nomes com acento passam também quando a saída vai para arquivo
    for nome in (sys.argv[1:] or CENAS):
        grava(nome)

figuras_slides.py

Figuras dos slides da aula 2.

286 linesDownload
"""Figuras dos slides da aula 2.

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

Gera, a 200 dpi, em aulas/02-gradiente/figuras/:
    neuronio-linear.png   o neurônio da aula 1 sem o degrau: a saída é a própria soma
    dados.png             os 20 pontos, uma reta qualquer e o erro de cada ponto
    direcao.png           o sinal da derivada diz para que lado fica o fundo
    curvas.png            as curvas de nível da perda e o gradiente, perpendicular a elas
    limiar.png            a perda depois de 100 passos contra a taxa: o limiar

Os dados e as contas da reta vêm de reta.py, ao lado, para as figuras e a
solução de referência usarem os mesmos pontos. A figura da perda a cada passo,
taxas.png, sai do próprio reta.py. A derivada, a descida em 3D e as taxas na
parábola viraram vídeos, em animacoes.py.
"""
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

from reta import PASSOS, dados, descer, gradiente, limiar_pela_hessiana, minimos_quadrados, perda

# O que vem entre cifrões, como $x_i$, 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]
AZUL, VERMELHO, LARANJA, ESCURO = "#1f5fad", "#c0392b", "#a8500f", "#14213d"
REGIAO, CINZA, SUAVE, FUNDO = "#cfe3f5", "#8a94a6", "#5e6a7d", "#F6F5F1"
CLARO_LARANJA = "#f6e2d3"    # fundo da região em que o treino diverge

# 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 br(v, casas=2):
    """Número com vírgula decimal e o menos tipográfico, para rótulos e legendas."""
    return f"{v:.{casas}f}".replace(".", ",").replace("-", "−")


def eixos_br(ax, tamanho=17):
    """Vírgula decimal nos dois eixos e o tamanho dos números das marcas."""
    ax.xaxis.set_major_formatter(VIRGULA)
    ax.yaxis.set_major_formatter(VIRGULA)
    ax.tick_params(labelsize=tamanho)


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 neuronio_linear():
    """O neurônio da aula 1, em bolinhas ligadas por linhas, sem o degrau.

    Da esquerda para a direita: a entrada x e a entrada fixa 1, que carrega o
    viés; uma linha de cada até a soma, com o peso que a multiplica; a soma Σ;
    e a saída y, que é a própria soma: y = w·x + b. Com uma entrada só, os
    pontos (x, y) formam uma reta, e é por isso que ajustar o neurônio é
    ajustar uma reta. Tudo é simétrico em torno da linha do meio, y = 2,4 nas
    coordenadas do desenho.
    """
    fig, ax = plt.subplots(figsize=(12, 4.8), facecolor=FUNDO)
    ax.set_facecolor(FUNDO)
    ax.set_xlim(0, 12)
    ax.set_ylim(0, 4.8)
    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):
        ax.annotate("", xy=para, xytext=de, zorder=2,
                    arrowprops=dict(arrowstyle="-|>", lw=2.5, color=ESCURO, mutation_scale=24, shrinkA=0, shrinkB=0))

    meio = 2.4
    soma, r_soma, r_entrada = np.array([5.5, meio]), 0.95, 0.55
    entradas = [("$x$", "$w$", 3.7, "white", AZUL), ("$1$", "$b$", 1.1, "#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 da soma, 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 45% do caminho, afastado da linha na perpendicular, para não encostar nela
        normal = np.array([-direcao[1], direcao[0]])
        pos = centro + 0.45 * (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)
    saida = np.array([10.4, meio])
    bolinha(saida, r_entrada, LARANJA, LARANJA, "$y$", "white", 32)
    seta(soma + [r_soma, 0], saida - [r_entrada, 0])
    # a conta vai em cima da seta da saída: a saída é a soma, sem degrau
    ax.text((soma[0] + r_soma + saida[0] - r_entrada) / 2, meio + 0.35, r"$y = w\,x + b$",
            ha="center", va="bottom", fontsize=28, color=ESCURO)

    # legendas embaixo de cada parte, numa mesma altura
    for x, texto in [(1.6, "entradas"), (soma[0], "soma ponderada"), (saida[0], "saída")]:
        ax.text(x, 0.12, texto, ha="center", fontsize=23, color=SUAVE)
    salva(fig, "neuronio-linear.png")


def pontos():
    """Os 20 pontos, uma reta ainda ruim e o erro de cada ponto, em segmentos verticais.

    A reta é w = 1 e b = 1,5, escolhida longe da melhor para os erros
    aparecerem. O erro do ponto i é yᵢ − tᵢ: a distância, na vertical, entre o
    que a reta prevê e a resposta certa; a perda é a média dos quadrados deles.
    Devolve a perda dessa reta, para o texto do slide.
    """
    x, t = dados()
    w, b = 1.0, 1.5
    fig, ax = plt.subplots(figsize=(7.2, 7.4))
    xs = np.array([0, 2])
    ax.plot(xs, w * xs + b, color=ESCURO, lw=3, zorder=2)
    for xi, ti in zip(x, t):
        ax.plot([xi, xi], [ti, w * xi + b], color=LARANJA, lw=2.2, ls=(0, (4, 2)), zorder=1)
    ax.scatter(x, t, s=130, c=AZUL, edgecolors="white", linewidths=1.2, zorder=3)
    ax.set_xlim(-0.05, 2.05)
    ax.set_ylim(0, 6)
    ax.set_xticks([0, 0.5, 1, 1.5, 2])
    ax.set_xlabel("entrada $x$", fontsize=20)
    ax.set_ylabel("resposta certa $t$", fontsize=20)
    eixos_br(ax)
    L = perda(w, b, x, t)
    alcas = [plt.Line2D([], [], marker="o", ls="", ms=12, color=AZUL, label=r"exemplos $(x_i,\ t_i)$"),
             plt.Line2D([], [], color=ESCURO, lw=3, label=r"a reta $y = x + 1{,}5$"),
             plt.Line2D([], [], color=LARANJA, lw=2.2, ls=(0, (4, 2)), label=r"o erro $y_i - t_i$")]
    # a legenda fica embaixo, fora do gráfico, para não cobrir nenhum ponto
    fig.legend(handles=alcas, loc="lower center", ncol=1, frameon=False, fontsize=18)
    fig.tight_layout(rect=(0, 0.2, 1, 1))
    salva(fig, "dados.png")
    return L


def direcao():
    """A parábola L(w) = (w − 2)² com a tangente em dois pontos e o passo de cada um.

    Em w = 0,5, a inclinação é L′ = −3: descer é andar para a direita, o
    sentido contrário ao da inclinação. Em w = 3,5, L′ = +3: descer é andar
    para a esquerda. Nos dois casos o passo é −η·L′, e as setas mostram o
    passo com η = 0,25, que leva w a 1,25 e a 2,75.
    """
    fig, ax = plt.subplots(figsize=(8.4, 6.6))
    ws = np.linspace(-0.5, 4.5, 300)
    ax.plot(ws, (ws - 2) ** 2, color=ESCURO, lw=3.5, zorder=2)
    eta = 0.25
    for w0, cor in ((0.5, AZUL), (3.5, LARANJA)):
        L0, inclinacao = (w0 - 2) ** 2, 2 * (w0 - 2)
        trecho = np.array([w0 - 0.6, w0 + 0.6])
        ax.plot(trecho, L0 + inclinacao * (trecho - w0), color=cor, lw=3, zorder=3)
        ax.scatter([w0], [L0], s=130, color=cor, zorder=4)
        w1 = w0 - eta * inclinacao
        # o passo, desenhado no chão, na altura zero, para mostrar só o sentido e o tamanho
        ax.annotate("", xy=(w1, -0.9), xytext=(w0, -0.9),
                    arrowprops=dict(arrowstyle="-|>", lw=3, color=cor, mutation_scale=26))
        ax.plot([w0, w0], [-0.9, L0], color=cor, lw=1.2, ls=":", zorder=1)
        # o rótulo fica dentro da tigela, acima do fundo, onde não há curva nem tangente
        lado = "left" if w0 < 2 else "right"
        ax.text(1.05 if w0 < 2 else 2.95, 3.4, f"$L′$ = {'−3' if w0 < 2 else '+3'}", ha=lado, va="center",
                fontsize=22, color=cor)
    ax.scatter([2], [0], s=160, marker="*", color=ESCURO, zorder=4)
    ax.set_xlim(-0.5, 4.5)
    ax.set_ylim(-1.6, 6.2)
    ax.set_xticks([0, 1, 2, 3, 4])
    ax.set_yticks([0, 2, 4, 6])
    ax.set_xlabel("peso $w$", fontsize=20)
    ax.set_ylabel("perda $L(w)$", fontsize=20)
    eixos_br(ax)
    alcas = [plt.Line2D([], [], color=ESCURO, lw=3.5, label=r"$L(w) = (w - 2)^2$"),
             plt.Line2D([], [], marker="*", ls="", ms=16, color=ESCURO, label="o fundo, em $w$ = 2"),
             plt.Line2D([], [], color=ESCURO, lw=3, marker=">", ms=10, label=r"o passo $-\eta\, L′$, com $\eta$ = 0,25")]
    fig.legend(handles=alcas, loc="lower center", ncol=1, frameon=False, fontsize=18)
    fig.tight_layout(rect=(0, 0.22, 1, 1))
    salva(fig, "direcao.png")


def grade_da_perda(ws, bs):
    """A perda em cada ponto de uma grade de (w, b), para as curvas de nível e a superfície."""
    x, t = dados()
    W, B = np.meshgrid(ws, bs)
    L = np.mean((W[..., None] * x + B[..., None] - t) ** 2, axis=-1)
    return W, B, L


def curvas():
    """As curvas de nível da perda e o gradiente em pontos de uma delas.

    Curva de nível é o conjunto dos pontos (w, b) de mesma perda, como as linhas
    de altitude de um mapa. As setas saem de pontos da curva de perda 1,5, uma
    a cada 45 graus em volta do mínimo, e apontam na direção do gradiente, para
    onde a perda mais cresce; todas têm o mesmo tamanho, curto o bastante para
    não chegar à curva seguinte, e cada uma sai da curva em ângulo reto. Os dois
    eixos têm a mesma escala; sem isso, o ângulo reto não apareceria. Nada se
    cruza na figura além do que se pede para ver: a seta saindo da própria curva.
    """
    x, t = dados()
    w_mq, b_mq = minimos_quadrados(x, t)
    ws, bs = np.linspace(-0.5, 4.5, 400), np.linspace(-1.5, 3.5, 400)
    W, B, L = grade_da_perda(ws, bs)
    fig, ax = plt.subplots(figsize=(7.6, 8.4))
    niveis = [0.3, 0.8, 1.5, 4, 8, 14]      # de 1,5 para 4, espaço para a seta caber inteira
    ax.contour(W, B, L, levels=niveis, colors=CINZA, linewidths=1.6, zorder=1)
    ax.contour(W, B, L, levels=[1.5], colors=ESCURO, linewidths=2.4, zorder=2)
    # pontos da curva de perda 1,5: a partir do mínimo, em cada direção, anda-se até a perda valer 1,5
    L_min = perda(w_mq, b_mq, x, t)
    for ang in np.deg2rad(np.arange(0, 360, 45) + 22.5):
        d = np.array([np.cos(ang), np.sin(ang)])
        # a perda cresce como o quadrado da distância ao mínimo: L = Lmin + c·s², com c pela própria perda
        c = perda(w_mq + d[0], b_mq + d[1], x, t) - L_min
        s = np.sqrt((1.5 - L_min) / c)
        w0, b0 = w_mq + s * d[0], b_mq + s * d[1]
        g = np.array(gradiente(w0, b0, x, t))
        g = 0.3 * g / np.linalg.norm(g)
        ax.annotate("", xy=(w0 + g[0], b0 + g[1]), xytext=(w0, b0), zorder=3,
                    arrowprops=dict(arrowstyle="-|>", lw=2.6, color=AZUL, mutation_scale=22, shrinkA=0, shrinkB=0))
    ax.scatter([w_mq], [b_mq], s=420, marker="*", color=ESCURO, zorder=4)
    ax.set_xlim(-0.5, 4.5)
    ax.set_ylim(-1.5, 3.5)
    ax.set_aspect("equal")
    ax.set_xlabel("peso $w$", fontsize=20)
    ax.set_ylabel("viés $b$", fontsize=20)
    eixos_br(ax)
    alcas = [plt.Line2D([], [], color=CINZA, lw=1.6, label="curvas de nível: pontos de mesma perda"),
             plt.Line2D([], [], color=ESCURO, lw=2.4, label="a curva de perda 1,5"),
             plt.Line2D([], [], color=AZUL, lw=2.6, marker=">", ms=10,
                        label="o gradiente, para onde a perda mais cresce"),
             plt.Line2D([], [], marker="*", ls="", ms=18, color=ESCURO, label="a menor perda")]
    fig.legend(handles=alcas, loc="lower center", ncol=1, frameon=False, fontsize=17)
    fig.tight_layout(rect=(0, 0.2, 1, 1))
    salva(fig, "curvas.png")


def limiar():
    """A perda depois de 100 passos contra a taxa: abaixo do limiar converge, acima explode.

    Cada ponto da curva é um treino inteiro, de 100 passos, com uma taxa. A
    perda final cai enquanto a taxa cresce, porque passos maiores chegam mais
    perto do fundo em 100 passos; perto de 0,51 ela dispara. A faixa laranja
    começa no limiar pela hessiana, 2/λ máximo, e é a borda dela que marca o
    limiar, sem linha por cima da curva. É o mapa do projeto de dezembro com
    uma taxa só, em vez de duas.
    """
    x, t = dados()
    taxas = np.arange(0.005, 0.6001, 0.0025)
    finais = np.array([descer(x, t, taxa)[0][-1] for taxa in taxas])
    eta_estrela = limiar_pela_hessiana(x)
    fig, ax = plt.subplots(figsize=(14, 6.4))
    ax.axvspan(eta_estrela, 0.6, color=CLARO_LARANJA, zorder=0)
    ax.semilogy(taxas, finais, color=ESCURO, lw=3, zorder=2)
    ax.set_xlim(0, 0.6)
    ax.set_ylim(0.05, 1e7)
    ax.set_xlabel(r"taxa de aprendizado $\eta$", fontsize=20)
    ax.set_ylabel("perda depois de 100 passos", fontsize=20)
    ax.xaxis.set_major_formatter(VIRGULA)
    ax.yaxis.set_major_formatter(FuncFormatter(
        lambda v, _: f"{v:,.0f}".replace(",", ".") if v >= 1 else f"{v:g}".replace(".", ",")))
    ax.tick_params(labelsize=17)
    alcas = [plt.Line2D([], [], color=ESCURO, lw=3, label="cada ponto: um treino de 100 passos"),
             Patch(facecolor=CLARO_LARANJA, label=rf"o treino diverge: $\eta$ acima de {br(eta_estrela, 3)}")]
    fig.legend(handles=alcas, loc="lower center", ncol=2, frameon=False, fontsize=18, columnspacing=2.4)
    fig.tight_layout(rect=(0, 0.1, 1, 1))
    salva(fig, "limiar.png")


if __name__ == "__main__":
    neuronio_linear()
    L = pontos()
    print(f"perda da reta y = x + 1,5 da figura dos dados: {L:.4f}")
    direcao()
    curvas()
    limiar()

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