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ácia0.95, pesos[-1.905, 2.656, 1.049]. Saída (SGD): acurácia0.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: , 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](commax_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: , e é exatamente esse deslocamento de faixa, de pra , 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" (: 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á sabia | O que essa aula assentou |
|---|---|
| Perceptron, Adaline, gradiente batch pareciam algoritmos separados | São o mesmo framework (NeuralNetwork + TrainingAlgorithm + CostFunction), só trocando qual função de custo plugar |
| Sigmoide devolve probabilidade entre 0 e 1 | Usar 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 custo | Acurácia treino | Acurácia teste |
|---|---|---|
| Widrow-Hoff | 1.0 | 1.0 |
| Perceptron | 1.0 | 1.0 |
| Hinge | 1.0 | 1.0 |
Log-verossimilhança (tanh) | 1.0 | 1.0 |
Log-verossimilhança (expit, sigmoide) | 0.843 | 0.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.