Multiclasse: Quando um Peso Vira uma Matriz
Aula 3c, e o professor troca o dataset de 2 classes por um de 4. A primeira tentativa é honestamente reusar tudo que já existe sem mudar nada, só pra mostrar, na prática, exatamente onde isso quebra.
O dataset: quatro blobs, um em cada canto
def createMulticlassDataset(n=40):
X, y = make_blobs(n_samples=n,
centers=[[0.2,0.2], [0.8, 0.2], [0.2, 0.8], [0.8, 0.8]],
n_features=2,
cluster_std=0.05,
center_box=(0,1))
return X, y
set(y_train)
Saída:
{0, 1, 2, 3}. Quatro classes, um número inteiro cada.
Primeira tentativa: reusar o que já existe (e falhar de propósito)
model = NeuralNetwork() # o mesmo de sempre, sign(X @ w_)
model.fit(X, y)
y_pred = model.predict(X)
print(f"Accuracy: {accuracy_score(y, y_pred)}")
Saída: acurácia 0.25.
Vale entender por que exatamente 0.25, não é um número aleatório qualquer: com 4 classes bem balanceadas, chutar sempre a mesma coisa acerta em média 1 em cada 4, ou seja, 25%. O NeuralNetwork de sempre usa sign(X @ w_), que só devolve -1 ou +1, dois valores possíveis, nunca 0, 1, 2 ou 3. Comparar isso contra um rótulo que pode ser 0, 1, 2 ou 3 é comparar coisas de naturezas diferentes. O modelo não está "quase acertando", ele literalmente não consegue expressar 3 das 4 respostas possíveis. A acurácia de 0.25 é, na prática, o mesmo nível de um chute cego.
One-hot: cada classe vira sua própria coluna
y_hot = np.zeros((y_train.shape[0], len(set(y_train))), dtype=int)
for i, label in enumerate(list(set(y_train))):
idxs = np.where(y_train == label)[0]
y_hot[idxs, i] = 1
Em vez de um rótulo escalar (0, 1, 2 ou 3), cada exemplo vira uma linha com um 1 na coluna da sua classe e 0 nas outras. O professor confirma que isso bate exatamente com o LabelBinarizer do scikit-learn:
from sklearn.preprocessing import LabelBinarizer
lb = LabelBinarizer()
y_hot = lb.fit_transform(y_train)
Mesma matriz, duas implementações, o mesmo tipo de conferência "bate com a ferramenta profissional" que já apareceu antes nessa playlist.
A correção: peso vira matriz, sign vira argmax
class SGD(TrainingAlgorithm):
def get_w(self, X, y):
self.w_ = np.random.random(size=(X.shape[1], y.shape[1]))
for _ in range(self.max_iter):
y_pred = X @ self.w_
self.w_ += self.learning_rate * self.cost_function.get_gradient(X, y, y_pred)
return self.w_
class NeuralNetwork(BaseEstimator, ClassifierMixin):
def predict(self, X):
X = include_bias(X)
logits = X @ self.w_
idxs = np.argmax(logits, axis=1)
return np.array([self.labels[idx] for idx in idxs])
A mudança que resolve tudo: self.w_ deixa de ser um vetor ((features,)) e vira uma matriz ((features, classes)), uma coluna de pesos por classe. X @ self.w_ agora devolve, pra cada ponto, 4 números (um "quão confiante" por classe), não mais 1 só. E a previsão final troca sign() por argmax: em vez de perguntar "positivo ou negativo?", pergunta "qual das 4 colunas teve o maior valor?". Isso é exatamente a arquitetura de camada de saída múltipla que o Aggarwal descreve pra classificação categórica: um peso por classe, e a decisão final é qual delas "venceu". A única peça que falta pra virar a versão completa dele (com softmax, transformando os 4 números em probabilidades que somam 1) é a normalização. Aqui o modelo só compara os números crus, sem transformar em probabilidade, mas o vencedor do argmax já não muda.
Com números reais fica mais fácil de segurar essa ideia: pega o ponto (0.6968, 0.1669) do dataset de 4 blobs (é a classe 1 de verdade) e os pesos reais que o argmax interativo logo abaixo usa, w = [[1.06, -0.81, -0.82], [0.22, 0.81, -0.78], [0.26, -0.85, 0.84], [-0.55, 0.84, 0.76]] (uma linha por classe, [bias, w_x, w_y] cada). Pra cada classe, o logit é bias + w_x·x + w_y·y: classe 0 dá 1.06 - 0.81×0.6968 - 0.82×0.1669 ≈ 0.36, classe 1 dá 0.22 + 0.81×0.6968 - 0.78×0.1669 ≈ 0.66, classe 2 dá 0.26 - 0.85×0.6968 + 0.84×0.1669 ≈ -0.19, classe 3 dá -0.55 + 0.84×0.6968 + 0.76×0.1669 ≈ 0.17. Quatro números, um por classe, e argmax escolhe o maior: 0.66, da classe 1. Bate exatamente com o rótulo verdadeiro desse ponto.
Saída: acurácia 1.0.
Interativo: as quatro regiões de decisão
Reconstruí o mesmo dataset (make_blobs, mesmos 4 centros) e treinei a versão com matriz de peso. Cada cor de fundo é a região onde aquela classe vence o argmax.
Repara nas quatro fronteiras se encontrando perto do meio do gráfico, dividindo o plano em quatro fatias, uma por classe. Cada blob cai limpinho dentro da cor certa.
Fechando
| O que eu já sabia | O que essa aula assentou |
|---|---|
sign() classifica em duas classes | Com mais de duas classes, sign() estruturalmente não tem como funcionar, sobra só 2 saídas possíveis pra N classes |
| One-hot encoding transforma rótulo categórico em vetor | Isso não é só conveniência de formato, é o que permite o peso virar matriz (uma coluna por classe) |
argmax escolhe o maior valor | É a generalização direta de sign() (que é basicamente "argmax entre 2 opções: positivo ou negativo") pra qualquer número de classes |
Aplicação Prática
Testei a mesma ideia (one-hot + matriz de peso + argmax) no Wine (load_wine, 3 castas de uva, 13 variáveis químicas), com um detalhe a mais: as variáveis aqui têm escalas bem diferentes entre si, o mesmo problema já visto antes nessa playlist, então normalizei antes de treinar.
X_train_s = StandardScaler().fit_transform(X_train)
Mesmo normalizado, learning_rate=0.01 (o padrão do notebook) ainda divergiu com 13 variáveis, e precisei cair pra 0.001.
| Abordagem | Acurácia treino | Acurácia teste |
|---|---|---|
Ingênua (sign, rótulo escalar) | 0.403 | - |
One-hot + matriz de peso + argmax | 1.0 | 0.9815 |
A versão ingênua nem chega nos 3 valores de classe possíveis (só consegue prever -1 ou +1, nunca a classe 2), então mesmo o número de 0.403 já é enganoso, ele conta como "acerto" qualquer caso em que o rótulo por coincidência já era -1 ou 1. A versão com matriz de peso acerta praticamente tudo, treino e teste, confirmando que o mesmo truque que funcionou no dataset sintético de 4 blobs generaliza pra um problema real de classificação multiclasse.
O código que treinou isso é o mesmo SGD/NeuralNetwork de lá de cima, sem mudar uma linha, só que agora X tem 13 colunas em vez de 2:
X_train_s = StandardScaler().fit_transform(X_train)
w = SGD(learning_rate=0.001, max_iter=1000, cost_function=WidrowHoff()).get_w(include_bias(X_train_s), one_hot(y_train))
13 dimensões não dá pra plotar direto, mas dá pra visualizar o mesmo mecanismo com só 2 das 13 variáveis químicas, flavanoides e álcool (as duas que separam as 3 castas melhor, das combinações que testei), treinando o mesmíssimo código só que com X_train[:, ['flavanoids', 'alcohol']] no lugar das 13 colunas:
Os 54 pontos de teste, nunca vistos no treino dessa versão de 2 variáveis, caem quase todos na fatia certa (acurácia de teste 0.889 nessa versão reduzida, menor que os 0.9815 da versão com as 13 variáveis originais, porque jogar fora 11 colunas jogou fora informação real). Mas o ponto principal continua de pé: as três fronteiras se encontram no meio, dividindo o plano em três fatias, exatamente a mesma mecânica do dataset sintético de 4 blobs, só que agora separando castas de uva de verdade em vez de blobs artificiais.