← Voltar pra playlist

A Sigmoide Foi Consertada, a Hinge Perdeu a Margem

Aula 4a, e ela pega exatamente onde o post passado parou. Mesmo dataset, mesmo framework de CostFunction plugável, mas o professor voltou nas implementações e mexeu em duas delas. Vale conferir linha por linha o que mudou, porque uma mudança conserta um bug de verdade, e a outra, sem querer, cria um novo.

O scaffolding não mudou, só um detalhe cosmético

def createDataset(n=20):
  X = np.random.rand(n,2)
  coefs = np.array([1, 1])
  intercept = 1
  labels = X @ coefs - intercept
  y = np.sign(labels)
  return X, y

Única diferença real do dataset do post passado: y = np.sign(labels) no lugar de np.array(labels>0, dtype=int)*2-1. Duas contas diferentes, mesmíssimo resultado (-1 ou +1), só um jeito mais direto de escrever a mesma coisa. TrainingAlgorithm, SGD e WidrowHoff vieram colados, sem mudar uma vírgula, então não replico de novo aqui.

Saída (WidrowHoff): acurácia 1.0, pesos [-1.98, 1.20, 2.36].

SmoothedSurrogate: mesma essência, get_cost ainda mais decorativo

class SmoothedSurrogate(CostFunction):
  @staticmethod
  def get_cost(y, y_pred):
    y_pred = (y_pred >= 0).astype(int)
    return np.sum(np.maximum(np.zeros(y.shape), -y * y_pred))
  @staticmethod
  def get_gradient(X, y, y_pred):
    y_pred = np.sign(y_pred)
    return X.T @ (y - y_pred)

get_gradient continua idêntico em espírito ao critério do perceptron que já vi: mede erro depois do sign(). O get_cost mudou (y_pred >= 0 vira 0/1, não mais -1/+1), mas como já vi antes, get_cost nunca entra no treino, só get_gradient é chamado pelo SGD. Fica ainda mais torto que antes, mas continua sem importar pra nada.

Saída: acurácia 1.0, pesos [-0.51, 0.56, 0.47].

O conserto de verdade: expit finalmente bate com o rótulo

class LogLikehood(CostFunction):
  @staticmethod
  def get_gradient(X, y, y_pred):
    y = (y>0).astype(int)
    return X.T @ (y - expit(y_pred))

Compara com a versão quebrada que eu investiguei na aula passada:

# aula03b, o bug original
def get_gradient(X, y, y_pred):
  return X.T @ (y - expit(y_pred))

A diferença é uma linha só: y = (y>0).astype(int). Isso converte o rótulo de -1/+1 pra 0/1 antes de subtrair de expit(y_pred), que só devolve valores em (0,1). É exatamente o conserto que eu previ no post passado: alinhar a faixa do rótulo com a faixa da sigmoide, em vez de comparar -1 (impossível pra expit alcançar) contra um número que nunca fica negativo.

Saída: acurácia 1.0, pesos [-5.44, 3.33, 5.82]. Comparado com o 0.65 que eu tinha achado antes, a diferença fala por si.

A hinge loss "consertada" que, na real, virou outra coisa

class HingeLoss(CostFunction):
  @staticmethod
  def get_gradient(X, y, y_pred):
    y_pred = np.sign(y_pred)
    marginal_errors = (y * y_pred) < 1
    marginal_ys = np.copy(y)
    marginal_ys[~marginal_errors] = 0
    return X.T @ marginal_ys

De novo, compara com a versão de antes:

# aula03b
def get_gradient(X, y, y_pred):
  marginal_errors = (y * y_pred) < 1
  marginal_ys = np.copy(y)
  marginal_ys[~marginal_errors] = 0
  return X.T @ marginal_ys

A única mudança é a linha y_pred = np.sign(y_pred), adicionada bem no início. Parece inofensiva, o mesmo sign() que já apareceu em toda essa playlist, mas ela muda o significado inteiro de marginal_errors. Antes, y * y_pred usava a pré-ativação contínua, y * ŷ < 1 incluía tanto ponto errado (y * ŷ < 0) quanto ponto certo mas sem folga (0 ≤ y * ŷ < 1), esses últimos são os "vetores de suporte" que eu expliquei no post passado: a hinge loss segue empurrando a fronteira até eles terem folga de sobra. Agora, com y_pred já passado por sign(), y * y_pred só pode valer -1 (errado) ou +1 (certo), nunca um valor intermediário. < 1 vira sinônimo de "só errado", exatamente o critério do perceptron de novo, só que escrito com mais linhas.

Saída: acurácia 1.0, pesos [-0.58, 0.53, 0.59].

Pra confirmar que não é implicância minha com uma linha de código, montei um exemplo concreto: peguei uma fronteira já treinada (w = [-2.5, 3.0, 2.75], que separa os 20 pontos com acurácia 1.0) e conferi, ponto a ponto, quem cada regra considera "marginal" (ou seja, quem ainda pesa no gradiente).

Pontox0, x1classey·ŷ (margem contínua)Regra antiga (aula03b)Regra nova (aula04a)
6(0.091, 0.810)-10.001marginal (ajusta)ignorado
0(0.452, 0.560)+10.397marginal (ajusta)ignorado
8(0.982, 0.965)+13.100ignoradoignorado

O ponto 6 é o mais revelador: sua pré-ativação é -0.001, praticamente em cima da fronteira, o tipo de ponto que devia continuar "incomodando" o treino. A regra antiga (margem contínua) reconhece isso e segue ajustando o peso nele. A regra nova, como já aplicou sign(), só vê que ele caiu (por muito pouco) do lado certo, e desiste de mexer nele. No total, 9 dos 20 pontos caem nessa faixa (0 ≤ y·ŷ < 1, corretos mas sem folga): a regra antiga continua ajustando o peso neles, a regra nova considera todos os 9 já resolvidos.

As quatro fronteiras dessa aula, lado a lado

Mesmo exercício visual do post passado, agora com os pesos reais que essa versão consertada encontrou:

De novo as quatro convergem pra praticamente a mesma reta, nesse dataset fácil. A diferença que a análise de cima revelou não está em onde a fronteira final fica, está em quais pontos continuam sendo ajustados ao longo do treino, algo que esse gráfico (só o resultado final) não tem como mostrar sozinho.

Fechando

O que eu já sabiaO que essa aula assentou
Achei o bug do expit contra rótulo ±1O professor confirmou o diagnóstico: converter o rótulo pra {0,1} resolve, exatamente como eu previ
Hinge loss usa margem contínua pra decidir quem é "vetor de suporte"Aplicar sign() antes de medir a margem colapsa isso em "certo ou errado", perdendo a noção de margem por completo
Um conserto de bug pode, sem querer, introduzir outroVale conferir cada correção linha por linha, mesmo quando o resultado final (acurácia) parece bom

Aplicação Prática

Repeti a comparação hinge real (margem contínua) vs. hinge da aula04a (margem via sign()) em dois datasets reais já usados nessa playlist.

No Iris (setosa vs. versicolor, de novo), rodei as duas variantes com o mesmo learning_rate=0.01 e a mesma semente:

VersãoAcurácia treinoAcurácia teste
Hinge real (margem contínua)1.01.0
Hinge aula04a (margem via sign())1.01.0

Empate técnico, e faz sentido: o Iris tem margem generosa demais pra sobrar algum ponto na faixa 0 ≤ y·ŷ < 1 depois de convergir, então as duas regras acabam concordando sobre quem é "marginal": ninguém.

Troquei pro dataset de duas luas (make_moons, já usado no post do XOR, noise=0.25 dessa vez pra garantir sobreposição real entre as classes, sem separação linear perfeita) e rodei 20 sementes diferentes pra cada versão:

VersãoAcurácia teste (média de 20 sementes)Norma do vetor de peso (média)
Hinge real (margem contínua)0.8782.90
Hinge aula04a (margem via sign())0.8420.67

Com dado onde a fronteira linear não é perfeita, a diferença aparece: a hinge real bate a versão da aula04a em acurácia de teste nas 20 sementes, e a norma do peso é quase 4 vezes maior, exatamente porque ela continua ajustando a fronteira nos pontos que caem certos mas sem folga, empurrando pra uma separação mais confiante, em vez de parar assim que o sinal já bate. É a demonstração prática de que a margem contínua não é só detalhe teórico, ela muda o resultado quando o dataset não é trivialmente separável.

Pegando uma semente específica dessas 20 (acurácia de teste 0.878 na hinge real, 0.789 na versão sign(), bem perto da média das duas), plotando as duas fronteiras sobre os 90 pontos de teste, os mesmos já usados no post do ELM:

Dá pra ver a diferença de "confiança" nas duas retas: a hinge real (peso maior) corta mais perpendicular à faixa de sobreposição entre as luas, arriscando mais pra separar melhor; a versão da aula04a, com peso pequeno, fica mais "morna", parando de ajustar assim que os pontos claramente corretos saem da conta, mesmo sem folga de sobra.