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.
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.