← Voltar pra playlist

Só pra complementar: Pegadinhas do Scikit-Learn

Esse post não vem de nenhuma seção obrigatória do lab, é o que sobrou depois que separei o essencial de scikit-learn e gradiente estocástico no post principal. São as armadilhas que só aparecem quando o código sai do notebook educacional e vai pra um projeto de verdade.

fit_transform e transform não são a mesma coisa

Esse é o ponto onde mais gente escorrega. fit_transform aprende e aplica. transformaplica o que já foi aprendido. Nos dados de treino, fit_transform. Em qualquer outro dado (validação, teste, produção), transform.

Separei o dataset de 100 casas em 75 treino / 25 teste e comparei a média do tamanho calculada dos dois jeitos:

mu_certo = tamanho[treino].mean()          # certo: só o treino
mu_errado = tamanho[teste].mean()          # errado: se eu recalculasse no teste

Saída: média do treino = 1449.93, média do teste = 1305.04, diferença de 144.89

Se eu chamasse fit_transform no teste em vez de transform, o scaler passaria a usar 1305.04 em vez de 1449.93, uma diferença grande o bastante pra distorcer qualquer previsão feita depois. E aí a métrica de validação fica otimista demais sem eu perceber, porque o modelo "viu" estatística de um dado que deveria ser desconhecido.

SGD sem normalizar simplesmente explode

Diferente do LinearRegression, aqui normalizar não é opcional. Com features de escalas muito diferentes, o passo padrão vira um passo gigante na direção da feature de maior escala. Rodei gradiente estocástico direto nos dados crus (sem normalizar), no tamanho da casa em pés²:

α\alphaPassosResultado
9×1079\times10^{-7}43diverge, custo explode pra 2.1×1062.1\times10^6
1×1071\times10^{-7}200estável, mas ainda longe do mínimo (custo 1.6×1031.6\times10^3)

O mesmo fenômeno de escala que eu já vi com gradiente batch nos posts anteriores, só que agora no SGD. A diferença é que aqui normalizar não é só "recomendado", é praticamente obrigatório, e o motivo é que o SGD já chega numa situação mais frágil antes mesmo de a escala entrar em cena: cada passo usa o gradiente de um único exemplo, não a média suavizada de todo o dataset como no batch, então o passo já pula de um jeito barulhento de exemplo pra exemplo. Multiplica esse gradiente barulhento por uma feature de escala gigante (tamanho da casa em pés², na casa dos milhares) e o tamanho do passo passa a variar violentamente de uma iteração pra outra, sem a média do batch pra absorver esse solavanco. É a mesma direção errada de sempre, só que sem o amortecedor que o batch tinha de graça.

Pipeline e validação cruzada: o jeito profissional

Toda métrica que eu mostrei no post principal foi medida no treino, ela diz o quanto o modelo decorou, não o quanto ele generaliza. E tem um segundo problema: se eu normalizar uma vez e depois fizer validação cruzada, o scaler já viu os dados de todos os folds. Vazamento de novo, só que mais sutil.

Pipeline resolve os dois: encadeia normalização e modelo num único estimador, e a cada ajuste (inclusive dentro de cada fold da validação cruzada) o scaler é reajustado com a porção de treino daquele fold.

Rodei validação cruzada de 5 folds de verdade, comparando LinearRegression e Ridge(alpha=1):

ModeloR² por foldMédia ± desvio
LinearRegression0.955, 0.952, 0.915, 0.944, 0.960.9450 ± 0.0160
Ridge(alpha=1)0.953, 0.954, 0.919, 0.942, 0.9550.9444 ± 0.0137

Repara que a média de validação (0.945) é menor que o R² de treino que eu citei no post principal (0.9594), exatamente como esperado: validação sempre é mais honesta que treino. E os dois modelos ficam praticamente empatados, com 100 exemplos e 4 features não sobra capacidade demais pra Ridge precisar segurar.

As outras pegadinhas, em lista

  • intercept_ é um array no SGDRegressor e um escalar no LinearRegression. Código que assume um dos dois formatos quebra no outro.
  • max_iter é um teto, não uma meta (já vimos no post principal). Sempre confira n_iter_.
  • penalty='l2' vem ligado por padrão no SGDRegressor (post principal). Pra mínimos quadrados puros, passe penalty=None.
  • random_state não é opcional se você quer reproduzir resultado.
  • ConvergenceWarning significa que o modelo bateu no max_iter sem satisfazer o tol. Não ignora: aumenta max_iter, ajusta a taxa de aprendizado, ou confere se normalizou.
  • Normalizar o alvo y não é feito automaticamente por nada disso. Se y tiver magnitude extrema, considere normalizar, e lembre de desnormalizar as previsões antes de reportar qualquer métrica.
  • fit reinicia o modelo do zero. Pra treino incremental, com dado chegando aos poucos (em vez de tudo de uma vez), existe partial_fit: o modelo se atualiza sem nunca precisar ver o dataset inteiro junto. É o caso de uso real por trás de "dado que não cabe na memória" que mencionei no post principal, e é literalmente o que o simulador estocástico do post principal já mostra: cada passo enxerga uma casa só, nunca o dataset inteiro de uma vez, e ainda assim o modelo caminha pro mesmo lugar que o batch.

Exercícios

Tenta antes de abrir a resposta.

Exercício 1: reproduza o normalizador

Implementa uma versão sua de normalização por z-score com os métodos fit, transform e fit_transform, seguindo a convenção de atributos com underscore no fim. Confere que bate com o resultado do post de normalização.

Resposta
class MeuScaler:
    def fit(self, X):
        self.mean_ = X.mean(axis=0)
        self.scale_ = X.std(axis=0)
        self.scale_[self.scale_ == 0] = 1.0
        return self
    def transform(self, X):
        return (X - self.mean_) / self.scale_
    def fit_transform(self, X):
        return self.fit(X).transform(X)

Devolver self no fit é o que permite encadear MeuScaler().fit(X).transform(X), o mesmo padrão que todo transformador do scikit-learn segue.

Exercício 2: o efeito da taxa de aprendizado no SGD

Roda o gradiente estocástico com taxa constante em vários valores. O que acontece nos dois extremos?

Resposta

Com taxa pequena demais, o modelo não chega perto do mínimo dentro do número de passos disponível. Com taxa grande demais, diverge, igual eu mostrei nesse post com os dados crus. Uma taxa que decresce ao longo do treino (o padrão do scikit-learn) existe justamente pra não precisar acertar isso na mão: começa maior, dá passo grande no início quando ainda tá longe, e encolhe conforme se aproxima.

Exercício 3: quanto o Ridge escondido está custando?

Compara penalty=None com vários valores de α\alpha usando validação cruzada. Em que ponto a regularização começa a atrapalhar? E se o dataset tivesse só 20 exemplos em vez de 100?

Resposta

Com 100 exemplos e 4 features não há capacidade sobrando, então qualquer α\alpha grande só atrapalha. Com 20 exemplos a história muda: a razão exemplos/parâmetros fica apertada, e um α\alpha moderado passa a ajudar de verdade. A lição de sempre: regularização não é boa nem ruim em si, é resposta a um excesso de capacidade que pode ou não existir no seu dado.

Exercício 4: validação cruzada com e sem Pipeline

Compara o R² de validação cruzada em dois cenários: normalizando uma vez antes e passando o resultado pra validação cruzada, contra usando um Pipeline. Os números batem?

Resposta

Nesse dataset a diferença é pequena, porque os folds são parecidos entre si. Mas o primeiro cenário está conceitualmente errado: o normalizador viu os dados de validação. Em dataset pequeno, com outlier, ou com estrutura temporal, essa diferença deixa de ser pequena. Usa Pipeline sempre, não porque o número muda muito hoje, mas porque ele torna o erro impossível de cometer sem querer.

Exercício 5: o modelo generaliza pra casas fora da faixa?

Separa as 20 casas mais caras como teste e treina nas 80 restantes. O que acontece com o R² de teste?

Resposta

O R² despenca, e pode até ficar negativo. O motivo não é overfitting, é que a divisão não é aleatória: o teste ficou sistematicamente diferente do treino (extrapolação, o mesmo problema que eu vi com polinômio no post de engenharia de features). Divisão aleatória supõe que os dados são intercambiáveis, quando não são (tempo, geografia, faixa de preço), a divisão precisa respeitar isso.