githublinkedinemail
← /articles

Você não entende Machine Learning até implementar do zero

Chamar sklearn.fit() não é entender. Construir perceptron, regressão logística e uma rede que resolve XOR em Ruby é. ML é álgebra e otimização — não mágica.

5 minml

Você não entende Machine Learning até implementar do zero

Tem uma diferença gigante entre:

from sklearn.linear_model import LogisticRegression
model = LogisticRegression().fit(X, y)

…e saber o que essas duas linhas estão fazendo.

A primeira é uso. A segunda é entendimento.

E a indústria inteira confunde as duas.


A ilusão do fit()

Importou. Chamou .fit(). Modelo treinou. 92% de acurácia.

Você "fez ML"?

Não. Você usou a interface.

┌──────────────────────────────────────┐
│   model.fit(X, y)                    │
│                                      │
│   o que você vê: 1 linha             │
│   o que está dentro: 5000 linhas     │
└──────────────────────────────────────┘

Dentro dessa linha tem:

  • inicialização de pesos (e qual estratégia?)
  • escolha de função de ativação
  • forward pass
  • cálculo de loss
  • backpropagation
  • update de pesos com algum otimizador
  • critério de parada
  • regularização

Você não escolheu nada disso. O default escolheu por você.

E aí vem o dia em que o modelo não converge. E você não sabe nem por onde começar.


Minha experiência: uma lib de ML em Ruby

Resolvi aprender ML do jeito que aprendi tudo em programação: escrevendo, não lendo.

Não me importava se ficaria lento. Não queria gem. Queria ver o sangue do algoritmo.

Em Ruby. Array puro. Sem NMatrix, sem Numo, sem nada.

O caminho foi este:

1. Perceptron        → entendi "neurônio" e fronteira linear
2. Logistic regression → entendi probabilidade e sigmoide
3. Rede 1 camada      → entendi backprop
4. XOR                → entendi por que rede precisa ser não-linear

Cada etapa quebrou uma ilusão diferente.


Etapa 1 — Perceptron

O perceptron é um neurônio só.

     x1 ──w1──┐
              ├──[ Σ ]──[ step ]── y
     x2 ──w2──┘
              ↑
              b

Pesos w, bias b, soma ponderada, função degrau na saída.

class Perceptron
  def initialize(n_inputs, lr: 0.1)
    @w = Array.new(n_inputs) { rand(-0.5..0.5) }
    @b = 0.0
    @lr = lr
  end

  def predict(x)
    z = x.zip(@w).sum { |xi, wi| xi * wi } + @b
    z >= 0 ? 1 : 0
  end

  def train(xs, ys, epochs: 20)
    epochs.times do
      xs.zip(ys).each do |x, y|
        pred = predict(x)
        error = y - pred
        @w = @w.zip(x).map { |wi, xi| wi + @lr * error * xi }
        @b += @lr * error
      end
    end
  end
end

# AND lógico
xs = [[0,0],[0,1],[1,0],[1,1]]
ys = [0, 0, 0, 1]

p = Perceptron.new(2)
p.train(xs, ys)
xs.each { |x| puts "#{x.inspect} → #{p.predict(x)}" }

Funciona pro AND. Pro OR. Pra qualquer problema linearmente separável.

Aí veio o tapa.


Etapa 2 — XOR quebra tudo

Tentei treinar pro XOR:

xs = [[0,0],[0,1],[1,0],[1,1]]
ys = [0, 1, 1, 0]

Não converge. Nunca.

E aí entendi pela primeira vez o que separabilidade linear significa.

Plota XOR num plano:

   x2
    │
  1 ●   ○      ● = classe 1
    │           ○ = classe 0
    │
  0 ○   ●
    │
    └────────── x1
        0   1

Não tem reta que separa os ● dos ○. Não tem.

Perceptron é uma reta. Logo, perceptron não resolve XOR.

Esse foi o problema que matou o hype de IA nos anos 70.

E até eu implementar, era só uma curiosidade de livro.

Depois de implementar, virou conhecimento.


Etapa 3 — Regressão logística

Trocar step por sigmoid resolve algo importante: a saída vira probabilidade.

sigmoid(z) = 1 / (1 + e^-z)

  1.0 │            ___________
      │         __/
  0.5 │      __/
      │   __/
  0.0 │__/
      └──────────────────────── z

E como sigmoide é derivável, dá pra usar gradient descent de verdade.

class LogReg
  def initialize(n_inputs, lr: 0.1)
    @w = Array.new(n_inputs, 0.0)
    @b = 0.0
    @lr = lr
  end

  def sigmoid(z) = 1.0 / (1.0 + Math.exp(-z))

  def predict_proba(x)
    z = x.zip(@w).sum { |xi, wi| xi * wi } + @b
    sigmoid(z)
  end

  def train(xs, ys, epochs: 1000)
    n = xs.length.to_f
    epochs.times do
      dw = Array.new(@w.length, 0.0)
      db = 0.0
      xs.zip(ys).each do |x, y|
        p = predict_proba(x)
        err = p - y
        @w.each_index { |i| dw[i] += err * x[i] }
        db += err
      end
      @w.each_index { |i| @w[i] -= @lr * dw[i] / n }
      @b -= @lr * db / n
    end
  end
end

Ainda linear. Ainda não resolve XOR. Mas agora tem probabilidade, gradient descent e loss diferenciável.

Falta uma coisa: profundidade.


Etapa 4 — Uma camada escondida resolve XOR

Adiciona uma camada de neurônios no meio:

   x1 ─┐    ┌──[ h1 ]──┐
       ├────┤          ├──[ y ]
   x2 ─┘    └──[ h2 ]──┘

Cada h é uma transformação não-linear da entrada. A saída combina os h.

Agora a rede consegue traçar duas retas internamente e combiná-las. XOR vira separável.

class TinyNet
  # 2 inputs → 2 hidden (sigmoid) → 1 output (sigmoid)
  def initialize(lr: 0.5)
    @w1 = Array.new(2) { Array.new(2) { rand(-1.0..1.0) } } # 2x2
    @b1 = Array.new(2, 0.0)
    @w2 = Array.new(2) { rand(-1.0..1.0) }                  # 2
    @b2 = 0.0
    @lr = lr
  end

  def sig(z) = 1.0 / (1.0 + Math.exp(-z))
  def dsig(s) = s * (1 - s)

  def forward(x)
    @h = Array.new(2) do |j|
      sig(x[0] * @w1[j][0] + x[1] * @w1[j][1] + @b1[j])
    end
    @y = sig(@h[0] * @w2[0] + @h[1] * @w2[1] + @b2)
  end

  def train(xs, ys, epochs: 10_000)
    epochs.times do
      xs.zip(ys).each do |x, target|
        # forward
        out = forward(x)

        # backward (chain rule)
        d_out = (out - target) * dsig(out)
        d_h = Array.new(2) { |j| d_out * @w2[j] * dsig(@h[j]) }

        # updates
        2.times { |j| @w2[j] -= @lr * d_out * @h[j] }
        @b2 -= @lr * d_out

        2.times do |j|
          @w1[j][0] -= @lr * d_h[j] * x[0]
          @w1[j][1] -= @lr * d_h[j] * x[1]
          @b1[j]    -= @lr * d_h[j]
        end
      end
    end
  end
end

xs = [[0,0],[0,1],[1,0],[1,1]]
ys = [0, 1, 1, 0]

net = TinyNet.new
net.train(xs, ys)
xs.each { |x| puts "#{x.inspect} → #{net.forward(x).round(3)}" }

Saída esperada:

[0, 0] → 0.04
[0, 1] → 0.96
[1, 0] → 0.96
[1, 1] → 0.05

Resolveu XOR. Em Ruby. Em 60 linhas. Sem gem nenhuma.

E só ali eu entendi backprop. Porque escrevi a regra da cadeia na mão.


O que fit() esconde

Olha tudo o que apareceu nesse exercício:

┌─ forward pass ─────────────────────────┐
│  composição de funções não-lineares    │
└────────────────────────────────────────┘
┌─ loss ─────────────────────────────────┐
│  como mede o erro                      │
└────────────────────────────────────────┘
┌─ backprop ─────────────────────────────┐
│  regra da cadeia em camadas            │
└────────────────────────────────────────┘
┌─ weight init ──────────────────────────┐
│  zero quebra. random pequeno funciona  │
└────────────────────────────────────────┘
┌─ ativação ─────────────────────────────┐
│  step, sigmoid, tanh, ReLU…            │
│  cada uma muda o que a rede consegue   │
└────────────────────────────────────────┘
┌─ learning rate ────────────────────────┐
│  α grande explode, α pequeno trava     │
└────────────────────────────────────────┘

Tudo isso some atrás de uma linha de sklearn.

Você nunca vai entender ML olhando essa linha.


Senior vs junior

  • júnior importa um modelo, ajusta hiperparâmetro até a acurácia subir, e vai pra próxima task
  • sênior sabe, pra cada linha de fit(), qual operação está acontecendo e por quê

O júnior trata o modelo como caixa preta.

O sênior trata como álgebra com loop.

Diferença de salário e diferença de problema que cada um consegue resolver.


Por que isso muda tudo

Quando você implementa:

  • entende por que normalizar dados importa (gradiente explode senão)
  • entende por que inicialização importa (zero faz toda neurônio aprender igual)
  • entende por que ReLU vingou (sigmoide satura, gradiente some)
  • entende por que profundidade ajuda (mais composição = mais expressividade)
  • entende por que rede grande precisa de muito dado (mais parâmetro = mais grau de liberdade)

Nada disso entra na cabeça lendo blog post.

Entra escrevendo o código.


Conclusão

Machine Learning não é mágica.

É álgebra linear, cálculo e otimização — orquestrados num loop.

E você só vê isso quando escreve o loop com as próprias mãos.

sklearn, PyTorch, TensorFlow são ferramentas excelentes — depois que você entende.

Antes, são camadas que escondem o que você precisava aprender.

Se você nunca implementou um perceptron, uma regressão logística e uma rede com uma camada escondida, você não sabe ML.

Você sabe a API.

E API muda. Fundamento, não.

Você não entende Machine Learning até implementar do zero
Você não entende Machine Learning até implementar do zero