Manim na prática: efetividade x eficiência

Manim é a biblioteca Python de animação matemática que o Grant Sanderson escreveu para o canal 3Blue1Brown. Usei ela para responder uma pergunta que me incomodava: por que dar passos maiores quase nunca faz você chegar mais rápido. Abaixo está a pergunta, o vídeo de 44 segundos, o modelo que o sustenta e todo o processo de fazer, inclusive o que deu errado.

A pergunta

Dois agentes precisam ir do ponto A ao ponto B num plano. Cada um só sabe fazer uma coisa: olhar para B, escolher uma direção e andar um trecho reto. Os dois erram a mira. A diferença entre eles é o tamanho do passo e o tamanho do erro.

O primeiro é efetivo: mira melhor, passo curto. O segundo é eficiente no sentido em que a palavra costuma ser usada quando se fala de produtividade com IA: cobre quase 4x mais terreno por passo, ao custo de errar mais a direção. A pergunta é qual dos dois chega, e a resposta não é a que a intuição sugere.

Efetivo

L = 0,47  ·  σ = 22°

Também erra feio a mira, mas anda pouco por passo, então o erro não tem espaço para virar desvio. Chega em 23 passos.

Eficiente

L = 1,80  ·  σ = 35°

Cobre quase 4x mais terreno por passo e erra 1,6x mais a direção. Chega perto de B em 6 passos e depois orbita.

Adaptativo

Lk = c · dk  ·  σ = 35°

A mesma mira torta do eficiente. Só encolhe o passo conforme se aproxima. Chega em 8 passos na mediana.

44 segundos, 1920x1080 a 60 fps, sem áudio. Renderizado com Manim Community v0.20.1 em Python 3.14. O arquivo tem 1,7 MB.

O modelo

O agente opera em malha fechada: a cada passo ele re-mira em B. Isso é importante, porque o caso de mirar uma única vez e andar em linha reta é trivial e não tem graça. Com re-mira, um passo é isto:

$$ x_{k+1} = x_k + L \cdot R(\varepsilon_k)\,\frac{B - x_k}{\|B - x_k\|}, \qquad \varepsilon_k \sim \mathcal{N}(0, \sigma^2) $$
Um passo: mira em B, erra o ângulo por ε_k, anda L. R(.) é a matriz de rotação. O agente re-mira a cada passo (malha fechada).

Chamando de dk a distância até B, dá para escrever o que acontece com essa distância depois de um passo. O termo negativo aproxima, o termo positivo afasta:

$$ \mathbb{E}\!\left[d_{k+1}^{2}\right] = d_k^{2} - 2\,L\,d_k\,c + L^{2}, \qquad c = \mathbb{E}[\cos\varepsilon] = e^{-\sigma^{2}/2} $$
Distância esperada ao alvo depois de um passo, com d_k = ||B - x_k||. O termo -2Ldc aproxima; o termo +L² afasta.

É aqui que a coisa fica interessante. Existe uma distância em que os dois termos se anulam e o progresso esperado zera. Igualando 2Ldkc = L²:

$$ d^{*} = \frac{L}{2\,e^{-\sigma^{2}/2}} $$
Ponto fixo: onde o progresso esperado zera. Abaixo dessa distância o passo, em média, não desce mais. Passo grande com mira torta produz d* grande.

Esse d* é um piso. Não importa quantos passos você dê: em média, você não desce abaixo dele. O agente fica orbitando um anel de raio d* em volta do alvo. E note o que a fórmula diz: o piso é proporcional ao tamanho do passo. Dobrar o passo dobra a distância mínima em que você consegue parar.

Rodando os números dos dois perfis com o alvo tendo raio ρ = 0,35:

Piso teórico de cada perfil contra o raio do alvo.
Perfil L σ c = e-σ²/2 d* = L / 2c Entra no alvo?
Efetivo 0,47 22° 0,9289 0,253 sim, com folga
Eficiente 1,80 35° 0,8298 1,085 não, para nunca

1,085 é três vezes o raio do alvo. O agente eficiente não erra o alvo por azar. Ele erra por construção: o passo dele é grande demais para o próprio erro de mira, e existe um anel em volta de B onde ele fica preso.

Os números medidos

A fórmula é bonita, mas eu queria saber se ela descrevia mesmo a simulação. Rodei 3.000 execuções de cada perfil, com sementes diferentes, orçamento generoso de 400 passos:

3.000 execuções por perfil. Trajeto A até B = 10,0 unidades. Alvo com raio 0,35.
Perfil Chegou Mediana Média p90 Pior caso Caminho andado
Efetivo 100% 23 22,9 24 26 10,8
Eficiente 100% 28 37,9 84 294 50,4
Adaptativo 100% 8 8,4 10 15 14,2

O agente eficiente chega. Só que não por convergência: por sorteio. Ele orbita o anel e, de vez em quando, um passo aleatório o joga dentro do alvo. Por isso a mediana (28) é bem menor que a média (37,9), e por isso existe uma cauda que vai até 294 passos. Enquanto isso ele andou 50,4 unidades para percorrer um trajeto de 10,0. O efetivo andou 10,8.

Compare as caudas, que é onde mora a diferença prática. O efetivo vai de 23 passos na mediana a 26 no pior caso das 3.000 execuções: três passos de dispersão. O eficiente vai de 28 a 294, e o p90 dele é três vezes a própria mediana. Os dois erram a mira; só um deles converte esse erro em imprevisibilidade de prazo.

Vale reparar num momento no meio do trajeto: no passo 20 os dois estão praticamente empatados, o efetivo a 1,33 de B e o eficiente a 1,04. Se o vídeo terminasse ali, o placar seria de empate técnico. Dali em diante um fecha e o outro não consegue mais, e é essa segunda metade que decide.

A média medida da distância do eficiente ao alvo, depois de estabilizar, foi 1,085. A fórmula previa 1,085. Foi o momento em que decidi que o vídeo valia a pena.

O desfecho

Se o problema é o passo ser grande demais perto do alvo, a correção é óbvia depois que você vê: faça o passo encolher junto com a distância. Pondo Lk = α·dk na recursão:

$$ L_k = \alpha\,d_k \;\Longrightarrow\; \mathbb{E}\!\left[d_{k+1}^{2}\right] = d_k^{2}\left(1 - 2\alpha c + \alpha^{2}\right) $$
Passo proporcional à distância: contrai para qualquer α < 2c, com taxa ótima em α = c. A pontaria define o passo que se pode dar.

Agora o piso some: a distância contrai por um fator constante a cada passo, o que é convergência geométrica. E o α ótimo é exatamente c, o fator de pontaria. Ou seja: o tamanho de passo que você pode se dar ao luxo de usar é definido pela sua precisão. Quem mira bem pode andar muito. Quem mira mal não pode, e insistir no passo grande é o que produz a órbita.

O agente adaptativo do vídeo tem a mesma mira péssima de 35° do eficiente. A única mudança é encolher o passo perto de B. Ele chega em 8 passos, contra 23 do preciso e 38 do eficiente. É o argumento inteiro: eficiência não substitui pontaria, ela é consequência dela.

Como fiz com o Manim

Manim é uma biblioteca Python: você declara objetos geométricos (mobjects) e anima transformações neles dentro de um método construct(). O render sai em mp4 via ffmpeg. A versão que usei é a Manim Community v0.20.1, que é o fork mantido pela comunidade e o que se deve usar hoje (o repositório pessoal do Grant, o manimgl, é otimizado para o fluxo dele, não para terceiros).

1. Instalação

Ambiente virtual dentro da pasta do projeto, para a biblioteca e o cache de render nunca encostarem no repositório do site. O Manim precisa de cairo e pango (renderização vetorial e de texto) e de ffmpeg. Para fórmulas com MathTex ele precisa de uma instalação LaTeX, porque as equações são compiladas de verdade em LaTeX e convertidas em SVG por dvisvgm.

bash
# dependências de sistema (Arch; nos demais, os pacotes equivalentes)
sudo pacman -S --needed cairo pango ffmpeg \
     texlive-basic texlive-latexextra texlive-fontsrecommended texlive-binextra

# a lib fica num venv local, fora do versionamento
python -m venv manim/.venv
manim/.venv/bin/python -m pip install manim

Em Python 3.14 não havia wheels prontos para pycairo e manimpango, então o pip compilou os dois do código-fonte. Funcionou sem intervenção porque os headers de cairo e pango já estavam instalados. Se faltarem, a mensagem de erro é sobre cairo.h não encontrado, e não sobre Python.

2. Separar o modelo da cena

Esta foi a decisão que mais economizou tempo. Toda a matemática vive em sim.py, que só depende de numpy. A cena importa dele. Assim eu consigo rodar 3.000 simulações em segundos, sem tocar no Manim, e o render nunca vira o gargalo da exploração.

manim/
├── sim.py            o modelo: perfis, um passo, a recursão. Só numpy.
├── efetividade.py    a cena Manim. Só encenação.
├── seed_search.py    escolhe a semente da execução que vai ao ar.
├── diagnostico.py    os números de 3.000 execuções.
├── sweep.py          varredura de parâmetros.
├── requirements.txt
├── .venv/            gitignore
├── media/            gitignore: cache de render, centenas de MB
└── out/              gitignore: frames de inspeção
sim.py
@dataclass
class Perfil:
    nome: str
    passo: float
    sigma_graus: float
    adaptativo: bool = False

    @property
    def c(self) -> float:
        """E[cos eps] para erro gaussiano: o 'fator de pontaria', em (0, 1]."""
        return math.exp(-(self.sigma ** 2) / 2)

    @property
    def raio_orbita(self) -> float:
        """d* = L / (2c). Distância abaixo da qual o agente não desce."""
        return self.passo / (2 * self.c)

3. Escolher a semente sem trapacear

O modelo é aleatório, mas o vídeo mostra uma execução só. Existe uma tentação óbvia aqui: rodar até achar a semente em que o agente eficiente se dá mal de forma espetacular. Isso seria fabricar o resultado.

O que fiz foi diferente. Defini critérios de legibilidade (o eficiente precisa liderar de forma visível no começo, o efetivo precisa chegar perto da mediana medida) e depois contei quantas sementes passavam. Foram 1.081 de 2.999, ou seja 36,0%. A execução que está no vídeo é o caso comum, não a exceção. Esse número está impresso na saída do script justamente para poder ser cobrado.

Os dois agentes usam a mesma semente. Isso é de propósito: mesma sequência de azar, estratégias diferentes. É um experimento controlado, não duas corridas separadas.

4. O truque que faz a animação fluir

A forma ingênua de animar 26 passos é chamar self.play() 26 vezes. O resultado é picotado, porque cada chamada tem começo e fim próprios, e o render fica lento. A forma certa no Manim é um ValueTracker: um número animável que serve de relógio, mais updaters que redesenham os objetos em função dele. Aí toda a corrida cabe em uma chamada só, com velocidade constante.

Cada passo é desenhado como uma seta, não como um trecho de linha. É o que o modelo diz que ele é: um vetor, com magnitude e ângulo. E tem um efeito colateral ótimo no terceiro ato, onde as setas visivelmente encolhem conforme o agente adaptativo se aproxima do alvo.

efetividade.py
class Agente:
    """Um vetor por passo, revelados em sequência por um ValueTracker."""

    def __init__(self, pontos, cor, espessura, raio, ponta):
        self.pontos = pontos
        self.t = ValueTracker(0.0)
        self.opacidade = ValueTracker(1.0)  # escurecer sem brigar com o updater

        # Setas dos passos concluídos. A geometria NUNCA é mexida: só a opacidade.
        self.setas = VGroup(*[
            Arrow(em_3d(a), em_3d(b), **self._estilo)
            for a, b in pairwise(pontos)
        ])
        self.setas.set_opacity(0)
        self.setas.add_updater(self._atualizar_setas)

        # A seta do passo em curso é um objeto à parte, refeito a cada quadro.
        self.corrente = VMobject()
        self.corrente.add_updater(self._atualizar_corrente)

    def _atualizar_setas(self, grupo):
        concluidos = int(np.floor(self.t.get_value()))
        alfa = self.opacidade.get_value()
        for i, seta in enumerate(grupo):
            seta.set_opacity(alfa if i < concluidos else 0.0)

    def _atualizar_corrente(self, m):
        t = self.t.get_value(); k = int(np.floor(t)); fracao = t - k
        # Abaixo de 5% do passo a seta é degenerada e o Manim não a orienta.
        if k >= len(self.pontos) - 1 or fracao <= 0.05:
            m.set_opacity(0); return
        fim = self.pontos[k] + fracao * (self.pontos[k + 1] - self.pontos[k])
        m.become(Arrow(em_3d(self.pontos[k]), em_3d(fim), **self._estilo))
        m.set_opacity(self.opacidade.get_value())

# a corrida inteira, os dois agentes em lockstep, numa chamada:
self.play(
    efetivo.t.animate.set_value(24),
    eficiente.t.animate.set_value(24),
    run_time=6.2,
    rate_func=linear,   # sem ease: passo tem que ter cadência constante
)

Três detalhes que só aparecem quando você erra. O rate_func=linear é obrigatório: o padrão do Manim é smooth, que acelera no meio e desacelera nas pontas, e isso destrói a leitura de cadência de passos. Os updaters precisam ser removidos com clear_updaters() antes do FadeOut, senão o objeto continua sendo redesenhado enquanto tenta desaparecer. E o motivo de a seta em curso ser um objeto separado está no diário de erros abaixo.

A opacidade também tem que passar por um ValueTracker. Como o updater reescreve a opacidade a cada quadro, um .animate.set_opacity() por fora seria sobrescrito na hora. O updater lê o tracker e multiplica, então escurecer o agente eficiente durante a chegada do efetivo vira eficiente.opacidade.animate.set_value(0.35).

5. Paleta

Preto e branco puro, como o Manim é por padrão. Sem cor nenhuma, os dois agentes precisam se distinguir por outra coisa: o efetivo são setas brancas finas (2,4 px), o eficiente são setas cinzas grossas (3,8 px). A espessura carrega o significado, já que passo grande é literalmente traço mais pesado. E a ordem de adição na cena define o empilhamento, então o branco entra depois do cinza para não sumir no emaranhado.

A razão da ponta precisa ser diferente entre os dois. O Manim aceita max_tip_length_to_length_ratio, que dimensiona a cabeça em função do comprimento da seta. As setas do eficiente são quase 4x mais longas, então com a mesma razão a cabeça delas virava um borrão perto de B: 0,16 para o eficiente contra 0,30 para o efetivo.

6. Render e iteração

O ciclo que funcionou: renderizar rápido e feio, extrair quadros com ffmpeg, olhar, corrigir. Nunca abri o vídeo inteiro durante a iteração, só grades de frames em pontos escolhidos da linha do tempo.

bash
# iteração: 480p a 15 fps, uns 20 segundos de render
.venv/bin/manim -ql --disable_caching efetividade.py Efetividade

# inspeção: quadros em pontos-chave, mais rápido que assistir
for t in 4.5 21 30 38; do
  ffmpeg -ss $t -i media/videos/efetividade/480p15/Efetividade.mp4 \
         -frames:v 1 -y out/frames/f$t.png
done

# final: 1080p a 60 fps, cerca de um minuto em 16 núcleos
.venv/bin/manim -qh efetividade.py Efetividade

# compressão para a web: 2,6 MB viram 1,7 MB, com faststart
ffmpeg -i media/videos/efetividade/1080p60/Efetividade.mp4 \
  -c:v libx264 -preset slow -crf 21 -pix_fmt yuv420p \
  -movflags +faststart -an assets/video/efetividade-eficiencia.mp4

O --disable_caching importa durante a iteração. O Manim guarda trechos já renderizados e reaproveita, o que é ótimo em produção e péssimo quando você está mexendo em updaters, porque ele às vezes serve um trecho velho e você fica caçando um bug que já corrigiu.

O -movflags +faststart move o índice do mp4 para o começo do arquivo. Sem isso o navegador precisa baixar o vídeo quase inteiro antes de começar a tocar.

O que deu errado

A parte que os tutoriais não mostram. Em ordem cronológica:

  1. O modelo estava fácil demais para o agente eficiente Com o alvo em raio 0,45, ele acabava caindo dentro por sorteio em 90% das execuções. Eu já tinha um seed search filtrando os 10% em que ele falhava, o que teria sido escolher o resultado a dedo. Varri o espaço de parâmetros procurando um regime em que a tese valesse no caso típico e não achei nenhum: mesmo no melhor ponto ele ainda chegava em 37% das vezes. A correção não foi mexer nos parâmetros, foi trocar a pergunta. Medir "quantos passos ele leva e quanto caminho ele anda" em vez de "ele chega ou não" produziu números muito mais fortes (37,9 passos de média contra 21, cauda de 294, 5x o caminho andado) e honestos.
  2. Um agente sem erro nenhum, em três diagnósticos Com passo 0,50 e trajeto de 10,0, o efetivo chegava em exatamente 20 passos nas 3.000 execuções: mediana, p90 e pior caso, todos 20. Parece resultado fraudado. Primeiro diagnóstico: a culpa é 10,0 ÷ 0,5 dar inteiro redondo. Troquei o passo para 0,47, incomensurável com o trajeto, e a variância continuou zero, agora em 21. Segundo: a causa não era aritmética. Com erro de 5°, cada passo entrega cos(5°) = 99,6% do avanço pretendido, e ao longo de 21 passos o ruído nunca soma um passo inteiro. O agente é determinístico na prática, e publiquei isso como resultado.
  3. O sintoma que os números não mostravam Vendo o vídeo pronto, o agente efetivo não parece ter erro nenhum: a trajetória dele é uma reta. Isso apaga a premissa inteira, que é os dois erram, um erra menos. Um espectador vê um agente perfeito contra um bêbado, e não dois agentes imperfeitos com estratégias diferentes de passo. O reflexo foi trocar a semente, e ele estava errado: com σ = 5° o sistema é determinístico, então nenhuma semente produz um resultado diferente. A medição de 3.000 execuções já provava isso, eu é que não tinha ligado uma coisa à outra. A alavanca era o σ, não a semente. Subi o erro do efetivo para 22° e conferi renderizando os candidatos lado a lado, porque "a trajetória parece torta?" é pergunta visual e não se responde por tabela. Com 22° o caminho zigzagueia de forma óbvia, a chegada ganha dispersão real (23 na mediana, 26 no pior caso) e o d* = 0,253 continua cabendo com folga no alvo de 0,35. Efeito colateral: a tese ficou mais forte, porque agora os dois erram feio e o que os separa é quanto espaço o passo dá para o erro virar desvio.
  4. A fonte padrão saiu serifada O Text() do Manim usa a fonte padrão do sistema via Pango, que aqui resolvia para uma serifada. Ficou conflitando com a Computer Modern do MathTex, que é serifada de propósito. Fixei font="Noto Sans" em todo texto de interface e deixei a serifada só para as equações, que é onde ela pertence.
  5. A linha branca sumia no emaranhado cinza A chegada do agente efetivo, que é o clímax do segundo ato, acontecia bem no meio da bagunça de trajetórias do eficiente. Duas correções: inverter a ordem de adição na cena, para o branco ficar por cima, e acrescentar um halo circular permanente no ponto de chegada.
  6. Colisões de layout que só aparecem no quadro A legenda do terceiro ato entrava por cima do anel tracejado. Depois de mover a fórmula para a esquerda, esqueci de mover a fórmula que a substitui no Transform, então ela pulava de volta para o centro e colidia de novo. Achei os dois olhando frames extraídos, não lendo código.
  7. A ponta da seta descolando do marcador Ao trocar os traços por setas, reaproveitei o mesmo objeto Arrow para o passo em curso e para o passo concluído, chamando put_start_and_end_on() a cada quadro para fazer a seta crescer. O resultado é que a ponta ia ficando desalinhada do ponto de chegada: esse método reescala a cabeça em função do novo comprimento, e o erro acumula ao longo de dezenas de chamadas no mesmo objeto. A correção foi separar as responsabilidades. As setas dos passos concluídos são construídas uma vez e nunca têm a geometria tocada, só a opacidade; a seta em curso é um VMobject à parte que faz become() de uma Arrow nova a cada quadro. Custa uma construção de objeto por quadro e elimina o acúmulo.
  8. Um ponto órfão no cartão final O ponto A ficou de fora da lista do FadeOut final e permaneceu aceso atrás da frase de encerramento. Só notei porque um frame do fade mostrou tudo escurecendo menos ele.

Reproduzir

A pasta manim/ do repositório deste site tem os cinco arquivos Python e um requirements.txt. O venv, o cache de render e os frames de inspeção estão no gitignore, então clonar e rodar custa uma instalação:

bash
cd manim
python -m venv .venv
.venv/bin/python -m pip install -r requirements.txt

.venv/bin/python sim.py           # os pisos teóricos de cada perfil
.venv/bin/python diagnostico.py   # os números de 3.000 execuções
.venv/bin/python seed_search.py   # a semente e a fração de casos típicos

.venv/bin/manim -qh efetividade.py Efetividade

Ressalvas

O d* é o ponto fixo da recursão do valor esperado de , não a média da distribuição estacionária da distância. Para o agente eficiente os dois coincidiram até a terceira casa (1,085 previsto contra 1,085 medido), mas isso não é garantido em geral: quando o passo é grande em relação à distância, a dinâmica vira um vaivém em torno do alvo cuja média pode ficar acima do ponto fixo.

A distância só é conferida no fim de cada passo. Um passo longo pode passar raspando em B no meio do caminho e não contar. Isso é deliberado: a metáfora aqui é a de decisões discretas, em que o que importa é onde você para, não por onde você passou.

O erro de mira é gaussiano e independente entre passos. Erro correlacionado, que é o caso realista de um viés sistemático, só piora o quadro do agente de passo grande.

Referências

  1. Manim Community. Manim: a community-maintained Python library for creating mathematical animations. manim.community Fork mantido pela comunidade, versão 0.20.1 usada aqui. É o que se deve instalar via pip.
  2. Sanderson, G. 3Blue1Brown. 3blue1brown.com Autor original do Manim e do estilo visual que este vídeo imita.
  3. Robbins, H., & Monro, S. (1951). A Stochastic Approximation Method. Annals of Mathematical Statistics, 22(3), 400-407. As condições clássicas sobre o tamanho do passo. O passo adaptativo do terceiro ato é um caso particular.
  4. Bottou, L., Curtis, F. E., & Nocedal, J. (2018). Optimization Methods for Large-Scale Machine Learning. SIAM Review, 60(2), 223-311. Formaliza por que passo constante estaciona num raio em torno do ótimo em vez de convergir. É o mesmo fenômeno do d* deste estudo.
  5. Drucker, P. F. (1967). The Effective Executive. Harper & Row. Origem da distinção entre fazer as coisas certas e fazer as coisas do jeito certo.