← Voltar pra playlist

Classificação com um Limiar: Quando Regressão Vira Decisão

Aula 3, dividida em duas partes: classificação (3a) e normalização (3b). E o professor começa de um jeito provocador, reaproveitando a mesma classe de regressão do post anterior pra resolver um problema que não é bem de regressão.

O dataset: câncer de mama, 569 pacientes

O professor troca de dataset: agora é o load_breast_cancer do scikit-learn, 569 pacientes, 30 variáveis medidas a partir de imagem de biópsia (raio, textura, perímetro, área, e por aí vai), e um alvo binário, maligno ou benigno. Antes de escolher qualquer variável, ele calcula a correlação de cada uma das 30 com o alvo e ordena.

correlations = df.corr()['target'].drop('target')

Saída: a mais correlacionada é worst concave points (-0.79), seguida de worst perimeter (-0.78) e mean concave points (-0.78). Faz sentido clinicamente: pontos de concavidade no contorno do tumor e o perímetro dele são exatamente o tipo de coisa que um patologista olha pra suspeitar de malignidade.

Ele fica com worst concave points como a única variável de entrada por enquanto, guardando as outras 29 pra usar mais adiante.

O alvo binário, invertido de propósito

O scikit-learn já vem com uma convenção pra esse dataset: target=0 é maligno, target=1 é benigno. O professor inverte isso na mão:

y = np.array(y==0, dtype=int)  # agora 1 = maligno

Por quê? Porque a convenção mais comum em problema de detecção (fraude, doença, defeito) é fazer o rótulo 1 representar o caso que você quer flagar, o evento raro e preocupante, não o caso "normal". Depois da troca, a fração de pacientes malignos é 0.373, ou seja, 37% dos 569 casos.

Regressão pra classificar?

Aqui que a aula fica provocadora. Em vez de criar um classificador do zero, o professor reaproveita a mesma classe LinearRegressor do post anterior (a que resolve por pseudo-inversa) e simplesmente treina ela em cima do alvo 0/1, como se fosse um número contínuo qualquer:

regressor = LinearRegressor()
regressor.fit(X_train, y_train)
y_pred = regressor.predict(X_train)

Saída: w_ = [-0.303, 5.844], MSE = 0.0863.

Um MSE de 0.0863 parece ótimo à primeira vista (é bem menor que 1), mas repara no que acontece quando o professor mede a acurácia desse resultado:

def accuracy(y, y_pred):
    return np.sum(y == y_pred) / len(y)

print(accuracy(y_train, y_pred))

Saída: 0.0. Zero por cento de acerto.

Isso não é um bug, é uma pegadinha proposital. y_pred é a saída contínua da regressão, tipo 0.312 ou 0.847, nunca exatamente 0 ou 1. Comparar y == y_pred com == entre um inteiro e um float quase nunca dá igual, então a acurácia calculada desse jeito sempre vai bater zero, não importa o quão bom o modelo seja. Falta uma peça: transformar aquele número contínuo numa decisão.

O limiar: de número contínuo pra decisão

A correção é pequena: comparar a saída da regressão com 0.5 antes de virar rótulo.

class LinearClassifier(BaseEstimator, ClassifierMixin):
    def fit(self, X, y):
        X = include_bias(X)
        self.w_ = np.linalg.pinv(X) @ y
        return self
    def predict(self, X):
        X = include_bias(X)
        y_pred = X @ self.w_
        return (y_pred.reshape(X.shape[0],) > 0.5).astype(int)

Saída: acurácia de treino agora é 0.9165. No conjunto de teste, 0.8947.

O mesmo ajuste (o mesmo w_), só que agora com um limiar decidindo o rótulo final. É exatamente o que o Bishop chama de : y(x)=wTx+w0y(\mathbf{x}) = \mathbf{w}^{\mathsf{T}}\mathbf{x} + w_0, e o ponto onde y(x)y(\mathbf{x}) cruza o limiar de decisão é a fronteira de decisão. No caso clássico do Bishop essa fronteira fica em y(x)=0y(\mathbf{x})=0, aqui fica em y(x)=0.5y(\mathbf{x})=0.5 porque o alvo tá codificado como 0/1 em vez de centralizado em zero, mas a ideia geométrica é idêntica: dos dois lados de uma linha, a decisão muda.

Uma identidade elegante

O professor soma acurácia com MSE (ambos calculados sobre as previsões já limiarizadas, 0 ou 1):

accuracy(y_train, y_pred) + mean_squared_error(y_train, y_pred)

Saída: 1.0, exatamente.

Não é coincidência. Quando y e y_pred são só 0 ou 1, o erro quadrático (y - y_pred)² vale 0 quando acerta e 1 quando erra, exatamente igual ao erro absoluto. Então o MSE médio é literalmente a taxa de erro, e taxa de erro mais taxa de acerto sempre soma 1. Uma identidade boba de algebrismo, mas que mostra uma coisa importante: MSE sobre rótulo binário limiarizado é só outro nome pra "fração de erro".

O ajuste ótimo pro erro contínuo não é o ajuste ótimo pra classificar

Aqui mora o ponto mais sutil da aula. O professor pega o w_ que a pseudo-inversa encontrou (o que minimiza o erro quadrático no alvo 0/1 contínuo) e varre valores próximos do coeficiente w_[1], medindo o MSE depois de limiarizar em cada um:

w1_values = np.linspace(original_w[1] - 3, original_w[1] + 3, 100)
for w1_candidate in w1_values:
    classifier.w_ = np.array([original_w[0], w1_candidate])
    mse_values.append(mean_squared_error(y_train, classifier.predict(X_train)))

Saída: o menor MSE encontrado (0.0791) acontece em w_[1] = 5.5709, não no w_[1] = 5.8436 que a pseudo-inversa devolveu.

O ajuste que minimiza o erro quadrático antes de limiarizar não é o mesmo ajuste que minimiza o erro depois de limiarizar. São dois objetivos parecidos, mas não idênticos. O Bishop explica o motivo: mínimos quadrados corresponde a assumir que o ruído nos dados segue uma distribuição gaussiana (capítulo 3), o que faz todo sentido pra prever um número contínuo. Mas um rótulo binário não tem ruído gaussiano, e por isso mínimos quadrados aplicado direto em classificação sofre de um problema específico: pontos que já estão "certos demais", bem longe da fronteira, do lado certo, ainda puxam o ajuste, porque contribuem erro quadrático mesmo sem precisar. O Bishop mostra isso com um exemplo onde adicionar pontos extras, todos do lado certo da fronteira, muda a fronteira de decisão pra pior, coisa que não acontece com um método pensado direito pra classificação (regressão logística, que a matéria ainda não chegou, mas que existe exatamente pra resolver essa mancada).

Todas as 30 variáveis

Com o mesmo LinearClassifier, agora treinado nas 30 colunas em vez de 1:

modelo = LinearClassifier()
modelo.fit(X_train, y_train)
print(accuracy_score(y_test, modelo.predict(X_test)))

Saída: acurácia de teste 0.9561, contra 0.8947 usando só uma variável.

O mesmo salto que eu já vi nos dois posts anteriores: mais informação, sem mudar o algoritmo, sem mudar o limiar, e o resultado melhora bastante.

Normalizando, mas por um motivo diferente

A segunda metade da aula (aula03b) muda de assunto: em vez de regressão limiarizada, o professor usa o KNeighborsClassifier do scikit-learn (, K vizinhos mais próximos, tema de uma aula só sua mais na frente, aqui ele só aparece de relance) direto no dataset de câncer, sem normalizar nada:

modelo = KNeighborsClassifier()
modelo.fit(X_train, y_train)
modelo.score(X_test, y_test)

Saída: 0.9298.

Depois ele normaliza (min-max, na faixa 0 a 1, calculado só com estatística do treino e aplicado igual no teste) e repete:

Saída: 0.9649.

E confere que padronizar (, a mesma normalização da playlist da especialização) dá o mesmo resultado: 0.9649 também.

Eu já vi normalização importar antes, mas por um motivo diferente: lá na playlist da especialização, normalizar ajudava o gradiente descendente a convergir mais rápido, porque a tigela de custo ficava menos alongada. Aqui o motivo é outro. KNN decide a classe olhando a distância entre pacientes, geralmente distância euclidiana, a raiz da soma dos quadrados das diferenças em cada variável. Se uma variável (tipo mean area, que varia de 143 até 2501) tem uma escala centenas de vezes maior que outra (tipo mean smoothness, que varia de 0.05 até 0.16), a distância acaba sendo decidida quase inteiramente pela variável de escala grande, as outras 29 praticamente não pesam na conta. Normalizar bota todo mundo na mesma régua antes de medir distância, e de repente as 30 variáveis contribuem de verdade, não só uma.

Fechando

O que eu já sabiaO que essa aula assentou
Regressão ajusta um número contínuoThreshold (um > 0.5) transforma esse número numa decisão binária, virando um classificador
MSE e acurácia medem coisas diferentesPra rótulo binário limiarizado, elas são literalmente complementares: acurácia + MSE = 1
Normalizar ajuda o gradiente descendente a convergirNormalizar também ajuda qualquer método baseado em distância (como o KNN) a não deixar uma variável de escala grande dominar sozinha

E ficou um gancho puro pra próxima aula, que ainda não chegou nessa playlist: mínimos quadrados aplicado em classificação tem um viés estrutural (o Bishop mostrou o porquê), e o jeito certo de resolver isso é trocar a função de erro por uma pensada pra classificação, regressão logística.

Aplicação Prática

Repito o classificador de uma variável (worst concave points) e visualizo, paciente por paciente no conjunto de teste, onde ele acerta e onde erra, junto com a fronteira de decisão real.

classifier = LinearClassifier()
classifier.fit(X_train, y_train)  # 1 variável: worst concave points
y_pred_test = classifier.predict(X_test)

Refiz esse ajuste com random_state=42 (o notebook original não fixa a semente, então os números variam a cada execução, aqui uso um treino/teste fixo pra poder mostrar os pacientes exatos) e obtive w_ = [-0.296, 5.841], o que coloca a fronteira de decisão em worst concave points ≈ 0.136: abaixo disso o modelo prevê benigno, acima, maligno.

Os triângulos vermelhos (os erros) se concentram bem perto da fronteira pontilhada, dos dois lados, exatamente onde eu esperaria: são os casos ambíguos, onde worst concave points sozinho não separa bem maligno de benigno. Longe da fronteira, dos dois lados, o modelo acerta quase sempre. Faz sentido: uma variável só carrega bastante sinal (ela tinha 0.79 de correlação, afinal), mas não é suficiente pra nunca errar, e é exatamente por isso que as 30 variáveis juntas (0.9561) batem a variável sozinha (0.8947).