← Voltar pra playlist

Trocando a Função de Custo Como Quem Troca de Roupa

Aula 3a e 3b. Até aqui eu vi 4 algoritmos diferentes (perceptron, perceptron vetorizado, Adaline via pseudo-inversa, Adaline via gradiente) meio que como coisas separadas. Essa aula mostra que eles são, na verdade, a mesma receita, só trocando um ingrediente: a função de custo.

Primeiro, o algoritmo de treino vira plugável

class TrainingAlgorithm(ABC):
  @abstractmethod
  def get_w(self, X, y):
    pass

class PseudoInverse(TrainingAlgorithm):
  def get_w(self, X, y):
    return np.linalg.pinv(X) @ y

class NeuralNetwork(BaseEstimator, ClassifierMixin):
  def __init__(self, training_algorithm=PseudoInverse()):
    self.training_algorithm = training_algorithm

  def fit(self, X, y):
    X = include_bias(X)
    self.w_ = self.training_algorithm.get_w(X, y)
    return self

  def predict(self, X):
    X = include_bias(X)
    return np.sign(X @ self.w_)

Isso é o padrão de projeto Strategy: NeuralNetwork não sabe mais como os pesos são calculados, só que existe um objeto training_algorithm com um método get_w. Trocar PseudoInverse() por SGD() no construtor troca o algoritmo de treino inteiro, sem tocar em NeuralNetwork.

Saída (PseudoInverse): acurácia 0.95, pesos [-1.905, 2.656, 1.049]. Saída (SGD): acurácia 0.95, pesos praticamente idênticos.

Confirma de novo, agora com o código organizado de um jeito mais limpo, o que já vi nos dois posts passados: pseudo-inversa e gradiente descendente resolvem o mesmíssimo problema.

Agora a função de custo também vira plugável

class CostFunction(ABC):
  @abstractstaticmethod
  def get_cost(y, y_pred):
    pass
  @abstractstaticmethod
  def get_gradient(X, y, y_pred):
    pass

class WidrowHoff(CostFunction):
  @staticmethod
  def get_cost(y, y_pred):
    return np.mean((y-y_pred)**2)
  @staticmethod
  def get_gradient(X, y, y_pred):
    return X.T @ (y-y_pred)

O SGD agora recebe um cost_function também, e usa self.cost_function.get_gradient(...) em vez de calcular o gradiente na mão. WidrowHoff é exatamente a regra delta que eu vi na aula passada: erro contínuo (y - y_pred, sem sign()) vezes a entrada. Trocar a função de custo aqui é trocar o que "erro" significa, sem tocar no laço de treino.

Cada função de custo recupera um algoritmo diferente

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

Repara no np.sign(y_pred) dentro do gradiente: isso volta a medir erro depois do limiar, exatamente como o perceptron do Rosenblatt. O nome SmoothedSurrogate bate com o que o Aggarwal chama de critério do perceptron: L=max(0,yy^)L = \max(0, -y \cdot \hat{y}), zero quando o ponto já está do lado certo, crescendo linearmente quando está errado. Não é coincidência a acurácia bater 1.0: essa função de custo, plugada nesse framework genérico, é o perceptron original de novo, só que expresso na linguagem de "função de custo" em vez de "regra de atualização".

class LogLikehood(CostFunction):
  @staticmethod
  def get_cost(y, y_pred):
    return np.sum(np.maximum(np.zeros(y.shape), 1 - y * y_pred))
  @staticmethod
  def get_gradient(X, y, y_pred):
    return X.T @ (y - expit(y_pred))

Saída: acurácia 0.65, pesos [-60.78, 27.44, -24.79]. Bem pior que tudo que eu vi até aqui, e os pesos ficaram enormes.

Duas coisas erradas aqui, e vale separar. Primeiro, um detalhe que não afeta o resultado: o get_cost dessa classe usa a fórmula do hinge loss (max(0, 1 - y·ŷ)), não uma fórmula de log-verossimilhança de verdade. Isso não quebra nada na prática porque get_gradient é a única coisa que o SGD chama, get_cost nunca é usado durante o treino, sobrou como resíduo de copiar e colar de outra célula.

O segundo problema é de verdade, e explica a acurácia ruim: expit (a função sigmoide) devolve valores só entre 0 e 1, mas o rótulo y é -1 ou +1. Pra classe y=-1, o erro y - expit(y_pred) nunca consegue chegar perto de zero, porque expit nunca fica negativo: mesmo com a previsão infinitamente confiante do lado certo, -1 - expit(y_pred) fica travado perto de -1, nunca de 0. Conferi isso na mão: expit(-1000) = 0.0, então -1 - expit(-1000) = -1.0 cravado, não 0. O gradiente pra metade dos pontos nunca some, então o treino nunca sossega, e os pesos ficam crescendo tentando compensar um erro que é estruturalmente impossível de zerar.

class LogLikehood(CostFunction):
  @staticmethod
  def get_gradient(X, y, y_pred):
    return X.T @ (y - tanh(y_pred))

model = NeuralNetwork(training_algorithm=SGD(max_iter=10000, cost_function=LogLikehood()))

Saída: acurácia 1.0, pesos [-9.84, 12.40, 8.37] (com max_iter=10000, dez vezes mais iterações).

Trocando expit por tanh (mesma classe redefinida, o Python deixa isso rodando ao vivo numa sessão de notebook), o problema some. Faz sentido: tanh varia entre -1 e +1, exatamente a faixa dos rótulos. Conferi de novo na mão: tanh(-1000) = -1.0, então -1 - tanh(-1000) = 0.0, o erro consegue mesmo chegar a zero dessa vez. O Aggarwal cita essa relação direto no capítulo 1: tanh(v)=2sigmoide(2v)1\tanh(v) = 2 \cdot \text{sigmoide}(2v) - 1, e é exatamente esse deslocamento de faixa, de (0,1)(0,1) pra (1,1)(-1,1), que resolve o descompasso com rótulo ±1.

class HingeLoss(CostFunction):
  @staticmethod
  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

Saída: acurácia 1.0, pesos [-8.70, 11.59, 7.71].

Repara no marginal_ys[~marginal_errors] = 0: pontos que já estão bem classificados, com folga (y · ŷ ≥ 1), são zerados e não contribuem nada pro gradiente. Só os pontos dentro da margem (ou errados) participam da atualização. Essa é literalmente a ideia central da SVM: só os pontos perto da fronteira (os "vetores de suporte") importam pra decidir onde ela fica. O resto do dataset é ignorado depois que já está bem separado.

As quatro curvas, lado a lado

O professor tem uma imagem de referência guardada no notebook comparando as quatro curvas de penalidade em função da "margem" (yy^y \cdot \hat{y}: positivo e grande é acerto confiante, negativo é erro). Recriei a mesma ideia aqui, interativa:

Passa o mouse em cima de qualquer ponto do eixo x e compara as quatro. Repara nos formatos: Widrow-Hoff é uma parábola, penaliza até ponto que já acertou com folga (margem > 1), porque ela não sabe que "acerto é acerto", só sabe medir distância até o alvo contínuo. Perceptron e Hinge são os dois únicos que zeram de vez quando o ponto está bem classificado (perceptron zera assim que passa de 0, hinge exige passar de 1, com folga). Logística nunca zera de verdade, só se aproxima de zero, o que é o preço de ela devolver uma probabilidade suave em vez de uma decisão binária.

Fechando

O que eu já sabiaO que essa aula assentou
Perceptron, Adaline, gradiente batch pareciam algoritmos separadosSão o mesmo framework (NeuralNetwork + TrainingAlgorithm + CostFunction), só trocando qual função de custo plugar
Sigmoide devolve probabilidade entre 0 e 1Usar sigmoide direto contra rótulo ±1 trava o gradiente, porque a faixa de saída não bate com a faixa do alvo. tanh resolve isso
SVM usa "vetores de suporte"Isso não é jargão vazio: o gradiente do hinge loss literalmente zera a contribuição de todo ponto que não é um vetor de suporte

Aplicação Prática

Rodei as quatro funções de custo (Widrow-Hoff, critério do perceptron, hinge, e a versão log-verossimilhança com tanh) no Iris (setosa vs. versicolor), mais a versão com expit de propósito, pra confirmar que o problema da sigmoide não é exclusivo do dataset sintético de brinquedo.

Função de custoAcurácia treinoAcurácia teste
Widrow-Hoff1.01.0
Perceptron1.01.0
Hinge1.01.0
Log-verossimilhança (tanh)1.01.0
Log-verossimilhança (expit, sigmoide)0.8430.933

Quatro das cinco batem 100% (o Iris tem margem generosa o bastante pra qualquer uma delas achar uma fronteira perfeita), e a sigmoide de novo fica pra trás, dessa vez em dado real, não só no dataset sintético do notebook. Confirma que não foi coincidência de uma rodada: o descompasso de faixa entre expit e rótulo ±1 prejudica a convergência de verdade, em qualquer dataset onde eu tentei.