Porque deitei fora o PyTorch e o TensorFlow. A jornada masoquista de calcular derivadas à mão — e o pesadelo inesperado do CORS no browser.
Vitor Neuenschwander
CS Student & Developer
Qualquer um pode fazer model.fit() no TensorFlow. Mas entender o que acontece dentro? Isso exige sujar as mãos. Este projeto foi minha resposta à pergunta: "Eu realmente entendo redes neurais, ou só sei usar a API?"
Inspirado pelo livro "Make Your Own Neural Network" do Tariq Rashid (2016, ISBN: 978-1530826605), decidi implementar um MLP completo usando apenas NumPy. Sem abstrações. Sem autograd. Cada multiplicação de matrizes, cada derivada parcial, cada atualização de peso — tudo manual, tudo exposto.
O resultado: 2.310 KB de repositório, 8 commits, e um reconhecedor de dígitos que funciona no browser sem servidor.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ENTRADA │ │ OCULTA │ │ SAÍDA │
│ 784 nós │────▶│ 200 nós │────▶│ 10 nós │
│ (pixels) │ │ (sigmoid) │ │ (classes) │
│ 28×28 = 784 │ │ N(0, 1/√n) │ │ 0-9 digits │
└──────────────┘ └──────────────┘ └──────────────┘
Input Hidden Output
Hiperparâmetros:
• Learning rate: 0.1
• Epochs (Python): 7
• Epochs (Browser): 5
• Inicialização: Distribuição Normal — N(0, 1/√n)
Cada pixel de um dígito 28×28 do MNIST vira um input normalizado entre 0.01 e 0.99. A rede olha para 784 valores e decide: "isso é um 7".
import numpy as np
class NeuralNetwork:
def __init__(self, input_nodes, hidden_nodes, output_nodes, lr):
self.lr = lr
# Inicialização de pesos: N(0, 1/√n)
self.wih = np.random.normal(
0.0, pow(input_nodes, -0.5),
(hidden_nodes, input_nodes)
)
self.who = np.random.normal(
0.0, pow(hidden_nodes, -0.5),
(output_nodes, hidden_nodes)
)
def sigmoid(self, x):
return 1 / (1 + np.exp(-np.clip(x, -500, 500)))
Não usei o MNIST completo (60.000 amostras). Trabalhei com um subconjunto minimalista:
train-1000.csv)test-10.csv)train-100.csv)Com data augmentation (rotação ±10° via scipy), o dataset de treino cresce de 1.000 para 3.000 amostras. É pouco comparado ao MNIST real, mas mais que suficiente para provar que a matemática funciona.
O coração do aprendizado. Cada iteração calcula o erro na saída, propaga de volta pelas camadas, e ajusta os pesos proporcionalmente à sua contribuição para o erro:
Fórmulas de atualização:ΔWho = lr × (hidden_outputsᵀ · (output_errors × σ(O) × (1 − σ(O))))ΔWih = lr × (inputsᵀ · (hidden_errors × σ(H) × (1 − σ(H))))def train(self, inputs_list, targets_list):
inputs = np.array(inputs_list, ndmin=2).T
targets = np.array(targets_list, ndmin=2).T
# Forward pass
hidden_inputs = np.dot(self.wih, inputs)
hidden_outputs = self.sigmoid(hidden_inputs)
final_inputs = np.dot(self.who, hidden_outputs)
final_outputs = self.sigmoid(final_inputs)
# Cálculo de erros
output_errors = targets - final_outputs
hidden_errors = np.dot(self.who.T, output_errors)
# Backpropagation — atualização de pesos
self.who += self.lr * np.dot(
(output_errors * final_outputs * (1 - final_outputs)),
hidden_outputs.T
)
self.wih += self.lr * np.dot(
(hidden_errors * hidden_outputs * (1 - hidden_outputs)),
inputs.T
)
Nota: A orientação das matrizes difere da formulação clássica de Tariq Rashid, mas a lógica é matematicamente equivalente.
No início, cometi um bug clássico: repetia os mesmos dados 3 vezes dentro do loop de epochs achando que era augmentation. A rede simplesmente memorizava os mesmos padrões com mais ênfase — sem ganho de generalização nenhum.
A correção usou scipy.ndimage para aplicar rotações reais de ±10° por amostra, criando variações genuínas dos dígitos:
from scipy.ndimage import rotate
def augment(image_array, angle=10):
img = image_array.reshape(28, 28)
rotated_plus = rotate(img, angle, reshape=False, order=1).flatten()
rotated_minus = rotate(img, -angle, reshape=False, order=1).flatten()
return [image_array, rotated_plus, rotated_minus]
# 1.000 originais × 3 = 3.000 amostras de treino reais
augmented_data = []
for sample in training_data:
augmented_data.extend(augment(sample))
O obstáculo mais irritante não foi a álgebra linear — foi o browser. Queria que a interface web fosse 100% client-side: abrir o ficheiro HTML com duplo clique (file:///) e funcionar. Sem servidor, sem npm, sem nada.
O problema? Quando abres um HTML via file:///, o browser bloqueia qualquer carregamento de JSON por políticas de CORS. O weights.json de 3.4 MB simplesmente não carregava.
A solução foi o script export_weights.py: converte toda a matriz pré-treinada para um ficheiro weights.js que se autocarrega como variável global:
# export_weights.py — Treina e exporta
import json, numpy as np
# Treina a rede...
nn = NeuralNetwork(784, 200, 10, 0.1)
for epoch in range(7):
for record in augmented_data:
nn.train(record[1:], targets)
# Exporta como JS (não JSON!)
weights = {
'wih': nn.wih.tolist(),
'who': nn.who.tolist()
}
with open('weights.js', 'w') as f:
f.write('const TRAINED_WEIGHTS = ')
f.write(json.dumps(weights))
f.write(';')
# Resultado: weights.js (3.4 MB)
# Carrega via <script src="weights.js"> no HTML
Duplo clique no index.html e funciona. Plug and play.
A rede treinava bem nos dados do MNIST. Mas quando desenhava no canvas do browser, tudo virava "2" ou "8". Accuracy catastrófica.
Porquê? O MNIST original tem os dígitos centrados pelo centro de massa. O desenho do rato no canvas começa onde o utilizador clica — completamente desalinhado.
A correção envolveu implementar, em JavaScript puro, o mesmo pré-processamento do MNIST:
function preProcessImage(imageData) {
// 1. Encontrar bounding box do desenho
const bbox = findBoundingBox(imageData);
// 2. Extrair e redimensionar para 20×20
const cropped = cropCanvas(imageData, bbox);
const resized = bilinearInterpolation(cropped, 20, 20);
// 3. Centralizar em frame 28×28 (margem de 4px)
const centered = new Float32Array(784).fill(0);
const offsetX = 4; // (28 - 20) / 2
const offsetY = 4;
for (let y = 0; y < 20; y++) {
for (let x = 0; x < 20; x++) {
centered[(y + offsetY) * 28 + (x + offsetX)] =
resized[y * 20 + x];
}
}
// 4. Normalizar para [0.01, 0.99]
return centered.map(v => v * 0.98 + 0.01);
}
Depois desta normalização, a accuracy no browser subiu dramaticamente. O canvas agora mostra um Live Preview 28×28 em tempo real enquanto desenhas.
O index.html (73% do repositório em bytes!) oferece uma interface completa sem dependências:
Tudo em Vanilla JS. Sem React, sem npm, sem build step.
| Bug | Efeito | Correção |
|---|---|---|
readlines() dentro do loop de epochs | Lista ficava vazia após 1ª epoch — treinava só 1× | Mover chamada para antes do loop |
| Learning rate = 0.01 | Convergência lentíssima | Aumentar para 0.1 |
| Augmentação "falsa" no browser | Mesmos dados repetidos 3× sem variação | Rotações reais ±10° via canvas transform |
| Pesos aleatórios na interface | Rede sem treino → sempre previa o mesmo dígito | Carregar weights.js automaticamente |
Neural-Network/
├── main.py # Treinamento interativo no terminal
├── export_weights.py # Treina + exporta para weights.js
├── verify.py # Verificação dos pesos (Python)
├── verify.js # Verificação dos pesos (Node.js)
├── index.html # Interface web standalone
├── weights.json # Pesos pré-treinados (JSON, 3.4 MB)
├── weights.js # Pesos pré-treinados (JS, 3.4 MB)
├── draw_table.pde # Sketch Processing para coleta de dados
├── train-1000.csv # Dataset treino (1.000 amostras)
├── train-100.csv # Dataset auxiliar (100 amostras)
├── test-10.csv # Dataset teste (10 amostras)
└── README.md # Documentação completa
Implementar uma rede neural do zero é como aprender a dirigir com câmbio manual antes de comprar um automático. Doloroso, frustrante, mas depois de fazer, nenhuma abstração do PyTorch te confunde mais.
As verdadeiras lições não foram sobre IA: