Classificador Naive Bayes em PHP Puro
Aprenda a construir um classificador Bayesiano para análise de sentimento com latência zero e custo zero, usando apenas PHP puro
🥷 Classificador Naive Bayes em PHP Puro
Neste tutorial, vamos construir um Classificador Bayesiano Ingênuo (Naive Bayes) do zero, usando apenas PHP puro, para realizar análise de sentimento em textos (comentários, reviews, feedbacks).
A grande vantagem? Latência praticamente zero e custo zero por classificação - tudo roda in-process, sem chamadas HTTP ou dependências externas.
💡 Pré-requisitos: PHP 8.1+. Conhecimento básico de arrays e classes em PHP.
[!WARNING] Trade-off Crucial: Este tutorial foca em velocidade e simplicidade. Se você precisa de >90% de precisão em análise de sentimento complexa (sarcasmo, ironia, contexto multicultural), considere modelos mais robustos (transformers, BERT, GPT). Use Naive Bayes quando latência (<1ms) e custo ($0) são mais críticos que precisão absoluta (~75-85%).
🧐 Por que Naive Bayes em Produção?
O Argumento "Por Quê?"
Você deve estar pensando: "Por que não usar uma API de ML como Google Cloud NLP ou OpenAI?"
A resposta está nos números:
| Métrica | API Externa (ex: Google NLP) | Naive Bayes PHP Puro |
|---|---|---|
| Latência | 50-200ms (rede + processamento) | < 1ms (in-process) |
| Custo | $1-5 por 1000 requests | $0 (zero) |
| Dependência | Requer internet e serviço externo | Nenhuma |
| Complexidade | HTTP client, autenticação, retry | Classe PHP simples |
| Precisão | 90-95% | 75-85% (suficiente para muitos casos) |
Quando Usar Naive Bayes?
✅ Use quando:
- Precisa de latência mínima (< 5ms)
- Volume alto de classificações (custo seria proibitivo)
- Classificação binária simples (positivo/negativo, spam/não-spam)
- Não pode depender de serviços externos
- Precisa rodar em ambiente restrito (sem extensões/bibliotecas)
❌ NÃO use quando:
- Precisa de precisão > 90% (use modelos mais sofisticados)
- Classificação multi-classe complexa com nuances
- Dataset de treinamento muito pequeno (< 100 exemplos)
- Textos extremamente curtos ou ambíguos
🎯 O Problema: Análise de Sentimento
Imagine que você tem um e-commerce e recebe milhares de comentários por dia:
- "Produto excelente! Chegou rápido e bem embalado." → Positivo
- "Péssima qualidade, não recomendo." → Negativo
- "Achei ok, nada de especial." → Neutro
Você quer classificar automaticamente cada comentário para:
- Priorizar atendimento aos insatisfeitos
- Identificar produtos com problemas
- Gerar métricas de satisfação
Solução tradicional: Contratar moderadores humanos (caro e lento). Nossa solução: Classificador Naive Bayes treinado com exemplos.
🧮 A Matemática Explicada (Sem Academicismo)
O Teorema de Bayes em Português
O Teorema de Bayes responde: "Qual a probabilidade de X, dado que observei Y?"
No nosso caso:
- X = "Este comentário é positivo"
- Y = As palavras do comentário ("excelente", "rápido", etc.)
Fórmula básica:
$$P(\text{Positivo} \mid \text{Palavras}) = \frac{P(\text{Palavras} \mid \text{Positivo}) \times P(\text{Positivo})}{P(\text{Palavras})}$$
Traduzindo:
"A chance de ser positivo, dado essas palavras" = "Quão comum são essas palavras em textos positivos" × "Quão comum é um texto ser positivo no geral"
Por que "Ingênuo" (Naive)?
Assumimos (ingenuamente) que cada palavra é independente das outras.
Exemplo:
- Frase: "muito bom"
- Naive Bayes: $P(\text{muito}) \times P(\text{bom})$ (independentes)
- Realidade: "muito" amplifica "bom" (dependentes)
Por que isso funciona? Na prática, essa simplificação funciona surpreendentemente bem! O algoritmo compensa com volume de dados.
Fluxo de Decisão
graph TD
A[Texto Novo:<br/>'produto excelente'] --> B[Tokenização]
B --> C[Palavras:<br/>produto, excelente]
C --> D{Calcular<br/>Probabilidades}
D --> E[P Positivo ≈ 0.85]
D --> F[P Negativo ≈ 0.15]
E --> G{Comparar}
F --> G
G --> H[Classificação:<br/>✅ POSITIVO]
style H fill:#0b4315
style E fill:#0b4315,color:#fff
style F fill:#501616,color:#fff
🏗️ Implementação Completa
Aqui está o classificador completo com exemplo de uso prático:
<?php
class NaiveBayesClassifier {
private array $vocabulary = [];
private array $classCounts = [];
private array $wordCounts = [];
private int $totalDocuments = 0;
private function tokenize(string $text): array {
$text = strtolower($text);
$text = preg_replace('/[^a-záàâãéèêíïóôõöúçñ\s]/u', '', $text);
$words = preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
$stopWords = ['o', 'a', 'de', 'da', 'do', 'e', 'é', 'em', 'um', 'uma'];
// Nota: Para capturar negações ("não é bom"), veja "Pré-processamento Avançado"
return array_filter($words, fn($w) => !in_array($w, $stopWords));
}
public function train(string $text, string $class): void {
$this->totalDocuments++;
if (!isset($this->classCounts[$class])) {
$this->classCounts[$class] = 0;
$this->wordCounts[$class] = [];
}
$this->classCounts[$class]++;
$words = $this->tokenize($text);
foreach ($words as $word) {
if (!isset($this->vocabulary[$word])) {
$this->vocabulary[$word] = true;
}
if (!isset($this->wordCounts[$class][$word])) {
$this->wordCounts[$class][$word] = 0;
}
$this->wordCounts[$class][$word]++;
}
}
private function wordProbability(string $word, string $class): float {
$wordCount = $this->wordCounts[$class][$word] ?? 0;
$totalWords = array_sum($this->wordCounts[$class]);
$vocabularySize = count($this->vocabulary);
// Laplace Smoothing
return ($wordCount + 1) / ($totalWords + $vocabularySize);
}
private function classProbability(string $class): float {
return $this->classCounts[$class] / $this->totalDocuments;
}
public function classify(string $text): array {
$words = $this->tokenize($text);
$scores = [];
foreach ($this->classCounts as $class => $count) {
$score = log($this->classProbability($class));
foreach ($words as $word) {
$score += log($this->wordProbability($word, $class));
}
$scores[$class] = $score;
}
// Normaliza para probabilidades
$maxScore = max($scores);
$probabilities = [];
$sumExp = 0;
foreach ($scores as $class => $score) {
$exp = exp($score - $maxScore);
$probabilities[$class] = $exp;
$sumExp += $exp;
}
foreach ($probabilities as $class => $prob) {
$probabilities[$class] = $prob / $sumExp;
}
arsort($probabilities);
return $probabilities;
}
public function predict(string $text): string {
$probabilities = $this->classify($text);
return array_key_first($probabilities);
}
}
// === EXEMPLO PRÁTICO ===
$classifier = new NaiveBayesClassifier();
// Dataset de treinamento
// NOTA: Este dataset pequeno é apenas para demonstração.
// Para produção, use pelo menos 100-500 exemplos por classe para resultados consistentes.
$trainingData = [
['Produto excelente! Superou minhas expectativas.', 'positivo'],
['Muito bom, chegou rápido e bem embalado.', 'positivo'],
['Adorei! Recomendo para todos.', 'positivo'],
['Qualidade incrível, valeu cada centavo.', 'positivo'],
['Perfeito! Exatamente como descrito.', 'positivo'],
['Maravilhoso, comprarei novamente.', 'positivo'],
['Ótimo produto, entrega rápida.', 'positivo'],
['Fantástico! Melhor compra do ano.', 'positivo'],
['Péssima qualidade, não recomendo.', 'negativo'],
['Produto horrível, veio quebrado.', 'negativo'],
['Muito ruim, não vale o preço.', 'negativo'],
['Decepcionante, esperava mais.', 'negativo'],
['Não gostei, péssima experiência.', 'negativo'],
['Terrível! Pior compra que já fiz.', 'negativo'],
['Lixo completo, joguei fora.', 'negativo'],
['Horrível, não comprem!', 'negativo'],
];
// Treina o modelo
foreach ($trainingData as [$text, $class]) {
$classifier->train($text, $class);
}
// Testa classificação
$testComments = [
'Produto maravilhoso, muito bom mesmo!',
'Não gostei, muito ruim.',
'Chegou rápido, qualidade excelente.',
];
echo "🔍 Classificando comentários:\n";
foreach ($testComments as $comment) {
$probabilities = $classifier->classify($comment);
$prediction = array_key_first($probabilities);
$confidence = $probabilities[$prediction] * 100;
echo "\n📝 \"$comment\"\n";
echo "🎯 " . strtoupper($prediction) . " (" . number_format($confidence, 1) . "%)\n";
}
// Saída esperada
// 🔍 Classificando comentários:
//
// 📝 "Produto maravilhoso, muito bom mesmo!"
// 🎯 POSITIVO (84.3%)
//
// 📝 "Não gostei, muito ruim."
// 🎯 NEGATIVO (95.6%)
//
// 📝 "Chegou rápido, qualidade excelente."
// 🎯 POSITIVO (88.0%)
Entendendo o Código
1. Tokenização (tokenize)
- Converte texto para minúsculas
- Remove pontuação
- Separa em palavras
- Remove stop words (palavras muito comuns)
2. Treinamento (train)
- Conta documentos por classe
- Conta frequência de cada palavra por classe
- Constrói vocabulário global
3. Classificação (classify)
- Calcula probabilidade para cada classe
- Usa logaritmo para evitar underflow numérico
- Normaliza para obter probabilidades entre 0-1
4. Laplace Smoothing (wordProbability)
- Adiciona 1 ao numerador
- Isso evita probabilidade zero para palavras nunca vistas
⚡ Performance: Benchmark Real
Vamos medir a velocidade na prática:
<?php
class NaiveBayesClassifier {
private array $vocabulary = [];
private array $classCounts = [];
private array $wordCounts = [];
private int $totalDocuments = 0;
private function tokenize(string $text): array {
$text = strtolower($text);
$text = preg_replace('/[^a-záàâãéèêíïóôõöúçñ\s]/u', '', $text);
$words = preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
$stopWords = ['o', 'a', 'de', 'da', 'do', 'e', 'é'];
return array_filter($words, fn($w) => !in_array($w, $stopWords));
}
public function train(string $text, string $class): void {
$this->totalDocuments++;
if (!isset($this->classCounts[$class])) {
$this->classCounts[$class] = 0;
$this->wordCounts[$class] = [];
}
$this->classCounts[$class]++;
$words = $this->tokenize($text);
foreach ($words as $word) {
if (!isset($this->vocabulary[$word])) $this->vocabulary[$word] = true;
if (!isset($this->wordCounts[$class][$word])) $this->wordCounts[$class][$word] = 0;
$this->wordCounts[$class][$word]++;
}
}
private function wordProbability(string $word, string $class): float {
$wordCount = $this->wordCounts[$class][$word] ?? 0;
$totalWords = array_sum($this->wordCounts[$class]);
$vocabularySize = count($this->vocabulary);
return ($wordCount + 1) / ($totalWords + $vocabularySize);
}
private function classProbability(string $class): float {
return $this->classCounts[$class] / $this->totalDocuments;
}
public function predict(string $text): string {
$words = $this->tokenize($text);
$scores = [];
foreach ($this->classCounts as $class => $count) {
$score = log($this->classProbability($class));
foreach ($words as $word) {
$score += log($this->wordProbability($word, $class));
}
$scores[$class] = $score;
}
arsort($scores);
return array_key_first($scores);
}
}
// Benchmark
$classifier = new NaiveBayesClassifier();
$positiveWords = ['excelente', 'ótimo', 'bom', 'maravilhoso', 'perfeito'];
$negativeWords = ['péssimo', 'ruim', 'horrível', 'terrível'];
for ($i = 0; $i < 500; $i++) {
$posText = $positiveWords[array_rand($positiveWords)] . ' produto ' . $positiveWords[array_rand($positiveWords)];
$negText = $negativeWords[array_rand($negativeWords)] . ' produto ' . $negativeWords[array_rand($negativeWords)];
$classifier->train($posText, 'positivo');
$classifier->train($negText, 'negativo');
}
$iterations = 5000;
$testText = 'produto excelente, muito bom';
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
$classifier->predict($testText);
}
$end = microtime(true);
$totalTime = ($end - $start) * 1000;
$avgTime = $totalTime / $iterations;
echo "⚡ BENCHMARK\n";
echo "Total: " . number_format($iterations) . " classificações\n";
echo "Tempo: " . number_format($totalTime, 2) . "ms\n";
echo "Média: " . number_format($avgTime, 4) . "ms por classificação\n";
echo "Memória: " . number_format(memory_get_peak_usage() / 1024 / 1024, 2) . "MB\n";
echo "\nAPI externa típica: ~100ms por chamada\n";
echo "Nossa solução: <0.1ms (1000x mais rápido!)\n";
// ⚡ BENCHMARK
// Total: 5,000 classificações
// Tempo: 10.38ms
// Média: 0.0021ms por classificação
// Memória: 2.50MB
//
// API externa típica: ~100ms por chamada
// Nossa solução: <0.1ms (1000x mais rápido!)
Análise de Complexidade
| Operação | Tempo | Espaço |
|---|---|---|
| Treinamento | $O(n \times m)$ | $O(v \times c)$ |
| Classificação | $O(w \times c)$ | $O(1)$ |
Onde:
- $n$ = documentos de treino
- $m$ = palavras por documento
- $v$ = tamanho do vocabulário
- $c$ = número de classes
- $w$ = palavras no texto a classificar
🧪 Validação e Métricas de Qualidade
Até agora implementamos o classificador e medimos velocidade. Mas como saber se ele está realmente bom? Precisamos validar com métricas objetivas.
O Problema: Treinar e Testar no Mesmo Dataset
// ❌ ERRADO - Testar onde treinou = 100% de "acerto" falso!
$classifier->train('produto bom', 'positivo');
$result = $classifier->predict('produto bom'); // Claro que acerta!
Isso é como estudar as respostas da prova e depois fazer a mesma prova. Precisamos de dados que o modelo nunca viu.
Train/Test Split
Dividimos os dados: 70-80% para treino, 20-30% para teste.
<?php
class DatasetSplitter {
/**
* Divide dataset em treino e teste
* @param array $data Array de [texto, classe]
* @param float $trainRatio Proporção para treino (ex: 0.7 = 70%)
* @return array ['train' => [...], 'test' => [...]]
*/
public static function split(array $data, float $trainRatio = 0.7): array {
$shuffled = $data;
shuffle($shuffled); // Aleatoriza
$trainSize = (int) (count($shuffled) * $trainRatio);
return [
'train' => array_slice($shuffled, 0, $trainSize),
'test' => array_slice($shuffled, $trainSize)
];
}
}
// Exemplo
$allData = [
['produto excelente', 'positivo'],
['muito ruim', 'negativo'],
['adorei', 'positivo'],
['péssimo', 'negativo'],
['ótimo', 'positivo'],
['horrível', 'negativo'],
];
$split = DatasetSplitter::split($allData, 0.7);
echo "Treino: " . count($split['train']) . " exemplos\n";
echo "Teste: " . count($split['test']) . " exemplos\n";
// Exemplo de saída (varia por causa do shuffle):
// Treino: 4 exemplos
// Teste: 2 exemplos
⚠️ Nota: A saída varia a cada execução devido ao
shuffle(). Em produção, usesrand()para resultados reproduzíveis.
Matriz de Confusão
A matriz mostra onde o modelo acerta e erra:
| Previsto: Positivo | Previsto: Negativo | |
|---|---|---|
| Real: Positivo | VP (Verdadeiro Positivo) | FN (Falso Negativo) |
| Real: Negativo | FP (Falso Positivo) | VN (Verdadeiro Negativo) |
<?php
class ModelEvaluator {
/**
* Calcula matriz de confusão
* @param array $actual Classes reais
* @param array $predicted Classes previstas
* @param array $classes Lista de classes possíveis
* @return array Matriz de confusão
*/
public static function confusionMatrix(array $actual, array $predicted, array $classes): array {
$matrix = [];
// Inicializa matriz
foreach ($classes as $realClass) {
$matrix[$realClass] = [];
foreach ($classes as $predClass) {
$matrix[$realClass][$predClass] = 0;
}
}
// Conta predições
for ($i = 0; $i < count($actual); $i++) {
$real = $actual[$i];
$pred = $predicted[$i];
$matrix[$real][$pred]++;
}
return $matrix;
}
/**
* Calcula acurácia (% de acertos totais)
*/
public static function accuracy(array $confusionMatrix): float {
$correct = 0;
$total = 0;
foreach ($confusionMatrix as $realClass => $predictions) {
foreach ($predictions as $predClass => $count) {
if ($realClass === $predClass) {
$correct += $count;
}
$total += $count;
}
}
return $total > 0 ? $correct / $total : 0.0;
}
/**
* Precision: De tudo que previu como X, quantos % eram realmente X?
*/
public static function precision(array $confusionMatrix, string $class): float {
$truePositive = $confusionMatrix[$class][$class] ?? 0;
$predicted = 0;
foreach ($confusionMatrix as $realClass => $predictions) {
$predicted += $predictions[$class] ?? 0;
}
return $predicted > 0 ? $truePositive / $predicted : 0.0;
}
/**
* Recall: De tudo que ERA X, quantos % o modelo encontrou?
*/
public static function recall(array $confusionMatrix, string $class): float {
$truePositive = $confusionMatrix[$class][$class] ?? 0;
$actual = array_sum($confusionMatrix[$class] ?? []);
return $actual > 0 ? $truePositive / $actual : 0.0;
}
/**
* F1-Score: Média harmônica de Precision e Recall
*/
public static function f1Score(array $confusionMatrix, string $class): float {
$precision = self::precision($confusionMatrix, $class);
$recall = self::recall($confusionMatrix, $class);
if ($precision + $recall == 0) return 0.0;
return 2 * ($precision * $recall) / ($precision + $recall);
}
}
// Exemplo determinístico
$actual = ['positivo', 'positivo', 'negativo', 'negativo', 'positivo', 'negativo'];
$predicted = ['positivo', 'negativo', 'negativo', 'positivo', 'positivo', 'negativo'];
$cm = ModelEvaluator::confusionMatrix($actual, $predicted, ['positivo', 'negativo']);
echo "=== Matriz de Confusão ===\n";
echo " Prev:Pos Prev:Neg\n";
echo "Real:Pos {$cm['positivo']['positivo']} {$cm['positivo']['negativo']}\n";
echo "Real:Neg {$cm['negativo']['positivo']} {$cm['negativo']['negativo']}\n\n";
$acc = ModelEvaluator::accuracy($cm);
$precPos = ModelEvaluator::precision($cm, 'positivo');
$recallPos = ModelEvaluator::recall($cm, 'positivo');
$f1Pos = ModelEvaluator::f1Score($cm, 'positivo');
echo "Acurácia: " . number_format($acc * 100, 1) . "%\n";
echo "Precision (positivo): " . number_format($precPos * 100, 1) . "%\n";
echo "Recall (positivo): " . number_format($recallPos * 100, 1) . "%\n";
echo "F1-Score (positivo): " . number_format($f1Pos, 3) . "\n";
// === Matriz de Confusão ===
// Prev:Pos Prev:Neg
// Real:Pos 2 1
// Real:Neg 1 2
//
// Acurácia: 66.7%
// Precision (positivo): 66.7%
// Recall (positivo): 66.7%
// F1-Score (positivo): 0.667
Entendendo as Métricas
Acurácia: Simples, mas engana em classes desbalanceadas.
- Exemplo: 95% dos emails são legítimos. Modelo que sempre diz "legítimo" tem 95% acurácia, mas é inútil!
Precision: "Quando digo que é spam, estou certo?"
- Alta precision = poucos falsos positivos
- Importante quando erro custa caro (ex: bloquear email legítimo)
Recall: "De todos os spams, quantos peguei?"
- Alto recall = poucos falsos negativos
- Importante quando deixar passar é grave (ex: spam perigoso)
F1-Score: Balanço entre Precision e Recall
- Use quando precisa equilibrar ambos
- Mais confiável que Acurácia para classes desbalanceadas
Visualização do Trade-off
graph LR
A[Modelo Conservador<br/>Só classifica com 99% certeza] --> B[Alta Precision<br/>Poucos falsos positivos]
A --> C[Baixo Recall<br/>Perde muitos casos]
D[Modelo Agressivo<br/>Classifica tudo suspeito] --> E[Baixa Precision<br/>Muitos falsos positivos]
D --> F[Alto Recall<br/>Pega quase tudo]
G[Modelo Balanceado<br/>F1-Score otimizado] --> H[Precision e Recall<br/>equilibrados]
style B fill:#0b4315,color:#fff
style C fill:#501616,color:#fff
style E fill:#501616,color:#fff
style F fill:#0b4315,color:#fff
style H fill:#0b4315,color:#fff
⚖️ Desbalanceamento de Classes
Um problema real que destrói a validação se ignorado.
O Problema
Imagine treinar com:
- 950 comentários positivos
- 50 comentários negativos
// Modelo "burro" que SEMPRE diz "positivo"
function stupidClassifier($text) {
return 'positivo';
}
// Acurácia = 950/1000 = 95% !!!
// Mas o modelo não aprendeu NADA sobre negativos!
Resultado: Acurácia alta, modelo inútil.
Como Detectar
<?php
function analyzeClassBalance(array $data): void {
$counts = [];
foreach ($data as [$text, $class]) {
$counts[$class] = ($counts[$class] ?? 0) + 1;
}
$total = array_sum($counts);
echo "=== Distribuição de Classes ===\n";
foreach ($counts as $class => $count) {
$pct = ($count / $total) * 100;
echo "$class: $count (" . number_format($pct, 1) . "%)\n";
if ($pct < 20 || $pct > 80) {
echo " ⚠️ DESBALANCEADO!\n";
}
}
}
$data = array_merge(
array_fill(0, 95, ['texto', 'positivo']),
array_fill(0, 5, ['texto', 'negativo'])
);
analyzeClassBalance($data);
// Exemplo de saída:
// === Distribuição de Classes ===
// positivo: 95 (95.0%)
// ⚠️ DESBALANCEADO!
// negativo: 5 (5.0%)
// ⚠️ DESBALANCEADO!
Soluções Práticas
1. Use F1-Score em vez de Acurácia
F1-Score penaliza modelos que ignoram a classe minoritária.
2. Oversampling Simples
<?php
function balanceDataset(array $data): array {
// Agrupa por classe
$byClass = [];
foreach ($data as $item) {
$class = $item[1];
$byClass[$class][] = $item;
}
// Encontra tamanho da maior classe
$maxSize = max(array_map('count', $byClass));
// Duplica exemplos das classes menores
$balanced = [];
foreach ($byClass as $class => $items) {
$repeated = $items;
while (count($repeated) < $maxSize) {
$repeated = array_merge($repeated, $items);
}
$balanced = array_merge($balanced, array_slice($repeated, 0, $maxSize));
}
shuffle($balanced);
return $balanced;
}
3. Class Weights (Ajuste no Prior)
Modificar a probabilidade inicial:
// Em vez de P(classe) = count / total
// Use P(classe) = weight * (count / total)
$classWeights = [
'positivo' => 1.0,
'negativo' => 19.0 // 95/5 = 19x mais peso
];
🔬 Pré-processamento Avançado
Técnicas que podem aumentar 10-15% a precisão.
1. Stemming em PHP
Reduz palavras à raiz: "correndo", "correr", "correu" → "corr"
<?php
class SimpleStemmer {
/**
* Stemming básico para português
*/
public static function stem(string $word): string {
$word = mb_strtolower($word);
// Remove sufixos comuns (stemming por remoção, não substituição)
// Nota: Este é um stemmer simplificado para demonstração.
// Para produção, considere o algoritmo RSLP ou SnowballStemmer.
$patterns = [
'/ando$/', // correndo → corr (remove sufixo gerúndio)
'/endo$/',
'/indo$/',
'/mente$/', // rapidamente → rapida
'/idade$/', // felicidade → felic
'/ação$/', // classificação → classific
'/ções$/', // ações → a
'/ável$/', // admirável → admir
'/ível$/',
];
foreach ($patterns as $pattern) {
$stemmed = preg_replace($pattern, '', $word);
if ($stemmed !== $word && strlen($stemmed) >= 3) {
return $stemmed;
}
}
return $word;
}
}
// Teste
$words = ['correndo', 'rapidamente', 'felicidade', 'classificação'];
foreach ($words as $word) {
echo "$word → " . SimpleStemmer::stem($word) . "\n";
}
// Saída esperada
// correndo → corr
// rapidamente → rapida
// felicidade → felic
// classificação → classific
2. N-grams Contextuais
Captura sequências de palavras para entender negações:
<?php
function extractNGrams(string $text, int $n = 2): array {
$words = preg_split('/\s+/', strtolower($text), -1, PREG_SPLIT_NO_EMPTY);
$ngrams = [];
for ($i = 0; $i <= count($words) - $n; $i++) {
$ngram = implode('_', array_slice($words, $i, $n));
$ngrams[] = $ngram;
}
return $ngrams;
}
$text = "não é bom";
$bigrams = extractNGrams($text, 2);
echo "Texto: $text\n";
echo "Bi-grams: " . implode(', ', $bigrams) . "\n";
// Saída esperada
// Texto: não é bom
// Bi-grams: não_é, é_bom
Agora "não_é" é tratado como token único, capturando a negação!
💡 Nota sobre Stop Words em N-grams: Propositalmente NÃO removemos stop words aqui. Diferente da tokenização simples, em bi-grams queremos manter "é" para capturar padrões como "não_é" (negação) ou "é_bom". Remover stop words antes de extrair n-grams destruiria esses padrões contextuais importantes.
3. Normalização Avançada
<?php
class TextNormalizer {
public static function normalize(string $text): string {
// URLs → TOKEN_URL
$text = preg_replace('#https?://[^\s]+#', ' TOKEN_URL ', $text);
// Emails → TOKEN_EMAIL
$text = preg_replace('/[\w\.-]+@[\w\.-]+/', ' TOKEN_EMAIL ', $text);
// Números → TOKEN_NUM
$text = preg_replace('/\d+/', ' TOKEN_NUM ', $text);
// Repetições excessivas (aaahhh → aah)
$text = preg_replace('/(.)\1{2,}/', '$1$1', $text);
// Remove pontuação extra
$text = preg_replace('/[!?]{2,}/', '!', $text);
return trim($text);
}
}
$text = "Visitei http://exemplo.com e achei muuuuito bom!!! Preço: 1500 reais";
echo TextNormalizer::normalize($text) . "\n";
// Saída esperada
// Visitei TOKEN_URL e achei muuito bom! Preço: TOKEN_NUM reais
Comparação de Impacto
| Técnica | Ganho Típico | Custo de Processamento |
|---|---|---|
| Stop Words | +2-5% | Baixo |
| Stemming | +5-10% | Médio |
| Bi-grams | +8-12% | Alto (vocabulário 10x maior) |
| TF-IDF | +10-15% | Médio |
| Normalização | +3-7% | Baixo |
🌍 Casos de Uso Reais
1. Filtro de Spam
<?php
class NaiveBayesClassifier {
private array $vocabulary = [];
private array $classCounts = [];
private array $wordCounts = [];
private int $totalDocuments = 0;
private function tokenize(string $text): array {
$text = strtolower($text);
$text = preg_replace('/[^a-záàâãéèêíïóôõöúçñ\s]/u', '', $text);
$words = preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
$stopWords = ['o', 'a', 'de', 'da', 'do', 'e', 'é', 'em'];
return array_filter($words, fn($w) => !in_array($w, $stopWords));
}
public function train(string $text, string $class): void {
$this->totalDocuments++;
if (!isset($this->classCounts[$class])) {
$this->classCounts[$class] = 0;
$this->wordCounts[$class] = [];
}
$this->classCounts[$class]++;
foreach ($this->tokenize($text) as $word) {
if (!isset($this->vocabulary[$word])) $this->vocabulary[$word] = true;
if (!isset($this->wordCounts[$class][$word])) $this->wordCounts[$class][$word] = 0;
$this->wordCounts[$class][$word]++;
}
}
public function predict(string $text): string {
$words = $this->tokenize($text);
$scores = [];
foreach ($this->classCounts as $class => $count) {
$score = log($this->classCounts[$class] / $this->totalDocuments);
foreach ($words as $word) {
$wordCount = $this->wordCounts[$class][$word] ?? 0;
$totalWords = array_sum($this->wordCounts[$class]);
$vocabSize = count($this->vocabulary);
$score += log(($wordCount + 1) / ($totalWords + $vocabSize));
}
$scores[$class] = $score;
}
arsort($scores);
return array_key_first($scores);
}
}
$spamFilter = new NaiveBayesClassifier();
$spamFilter->train('Ganhe dinheiro rápido! Clique aqui!!!', 'spam');
$spamFilter->train('Você ganhou um prêmio! Resgate agora!', 'spam');
$spamFilter->train('Oferta imperdível! Não perca!', 'spam');
$spamFilter->train('Reunião amanhã às 14h na sala 3', 'legítimo');
$spamFilter->train('Segue o relatório solicitado em anexo', 'legítimo');
$spamFilter->train('Confirmo presença no evento', 'legítimo');
$email = 'Promoção imperdível! Clique já e ganhe prêmios!';
$result = $spamFilter->predict($email);
if ($result === 'spam') {
echo "🚫 Email movido para spam\n";
} else {
echo "✅ Email legítimo\n";
}
// Saída esperada
// 🚫 Email movido para spam
2. Categorização de Tickets
<?php
class NaiveBayesClassifier {
private $data = [];
private $counts = [];
public function train($text, $category) {
if (!isset($this->counts[$category])) $this->counts[$category] = 0;
if (!isset($this->data[$category])) $this->data[$category] = [];
$this->counts[$category]++;
// Tokeniza de forma consistente
$text = strtolower($text);
$text = preg_replace('/[^a-záàâãéèêíïóôõöúçñ\s]/u', '', $text);
$words = preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
foreach ($words as $word) {
if (!isset($this->data[$category][$word])) $this->data[$category][$word] = 0;
$this->data[$category][$word]++;
}
}
public function classify($text) {
// Tokeniza de forma consistente (mesmo método do train)
$text = strtolower($text);
$text = preg_replace('/[^a-záàâãéèêíïóôõöúçñ\s]/u', '', $text);
$words = preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
$scores = [];
$total = array_sum($this->counts);
foreach ($this->counts as $cat => $count) {
$score = log($count / $total);
$catTotal = array_sum($this->data[$cat]);
foreach ($words as $word) {
$wordCount = $this->data[$cat][$word] ?? 0;
$score += log(($wordCount + 1) / ($catTotal + 1000));
}
$scores[$cat] = $score;
}
arsort($scores);
return array_key_first($scores);
}
}
$tickets = new NaiveBayesClassifier();
$tickets->train('não consigo fazer login senha incorreta', 'técnico');
$tickets->train('sistema travou erro interno', 'técnico');
$tickets->train('como funciona devolução produto', 'comercial');
$tickets->train('cancelar minha assinatura', 'comercial');
$tickets->train('pagamento foi recusado cartão', 'financeiro');
$tickets->train('cobrança duplicada fatura', 'financeiro');
$newTicket = 'erro ao processar pagamento cartão recusado';
$dept = $tickets->classify($newTicket);
echo "📨 Ticket roteado para: " . strtoupper($dept) . "\n";
// Saída esperada
// 📨 Ticket roteado para: FINANCEIRO
🎓 Entendendo o Laplace Smoothing
O Laplace Smoothing (também chamado de Add-One Smoothing) é uma técnica fundamental para resolver o problema da "palavra nunca vista".
O Problema Matemático
Sem smoothing, a fórmula de probabilidade de uma palavra é:
$$P(\text{palavra} \mid \text{classe}) = \frac{\text{count}(\text{palavra}, \text{classe})}{\text{total de palavras na classe}}$$
Problema: Se count(palavra, classe) = 0, então $P = 0$.
Como multiplicamos probabilidades de todas as palavras: $$P(\text{classe} \mid \text{texto}) \propto P(\text{classe}) \times P(w_1|c) \times P(w_2|c) \times ... \times P(w_n|c)$$
Se qualquer $P(w_i|c) = 0$, então o resultado inteiro é zero!
A Solução de Laplace
Adicionamos uma "contagem fantasma" de 1 para cada palavra:
$$P(\text{palavra} \mid \text{classe}) = \frac{\text{count}(\text{palavra}, \text{classe}) + 1}{\text{total de palavras} + |V|}$$
Onde $|V|$ é o tamanho do vocabulário (total de palavras únicas conhecidas).
Por que adicionar $|V|$ no denominador? Para manter a propriedade matemática de que a soma de todas as probabilidades = 1.
Exemplo Numérico
Suponha que treinamos com comentários positivos:
| Palavra | Aparições | Sem Smoothing | Com Laplace |
|---|---|---|---|
| "bom" | 50 | 50/200 = 0.25 | 51/210 = 0.243 |
| "excelente" | 30 | 30/200 = 0.15 | 31/210 = 0.148 |
| "azul" | 0 | 0/200 = 0 | 1/210 = 0.0048 |
- Total de palavras: 200
- Vocabulário: 10 palavras únicas ($|V| = 10$)
- Denominador com Laplace: $200 + 10 = 210$
Impacto:
- "azul" nunca foi vista, mas recebe probabilidade 0.48% (baixa, mas não zero)
- Palavras frequentes mantêm probabilidade alta
- Classificação continua funcionando!
Visualização do Fluxo
graph TD
A[Texto:<br/>produto azul bom] --> B[Tokenizar]
B --> C[palavras:<br/>produto, azul, bom]
C --> D{azul visto<br/>no treino?}
D -->|Não| E{Smoothing?}
E -->|Sem| F[P = 0<br/>❌ Multiplicação anula TUDO]
E -->|Com| G[P = 0.0048<br/>✅ Valor pequeno mas válido]
F --> H[Classificação:<br/>❌ FALHA]
G --> I[Classificação:<br/>✅ SUCESSO]
style F fill:#501616,color:#fff
style H fill:#501616,color:#fff
style G fill:#0b4315,color:#fff
style I fill:#0b4315,color:#fff
Por Que Funciona?
- Interpretação Bayesiana: É um "prior uniforme" - assumimos que todas as palavras têm chance mínima de aparecer
- Conserva ordem: Palavras frequentes continuam mais prováveis que raras
- Evita overfitting: Não "prende" o modelo apenas ao que viu no treino
Variantes
| Técnica | Fórmula | Quando Usar |
|---|---|---|
| Laplace | $(count + 1) / (total + |V|)$ | Padrão, funciona bem |
| Lidstone | $(count + \alpha) / (total + \alpha |V|)$ | $\alpha < 1$ para smoothing mais suave |
| Add-k | $(count + k) / (total + k |V|)$ | $k = 0.5$ comum em NLP |
Nossa implementação usa Laplace ($\alpha = 1$) por simplicidade e eficácia.
💾 Persistência do Modelo: Salvando para Reutilizar
Uma vez treinado, você quer salvar o modelo para reutilizar sem retreinar. Vamos explorar formatos recomendados:
Opção 1: JSON (Recomendado para Versionamento)
JSON é legível, versionável (Git) e portável entre linguagens.
<?php
class NaiveBayesClassifier {
private array $vocabulary = [];
private array $classCounts = [];
private array $wordCounts = [];
private int $totalDocuments = 0;
private function tokenize(string $text): array {
$text = strtolower($text);
$words = preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
return array_filter($words, fn($w) => strlen($w) > 2);
}
public function train(string $text, string $class): void {
$this->totalDocuments++;
if (!isset($this->classCounts[$class])) {
$this->classCounts[$class] = 0;
$this->wordCounts[$class] = [];
}
$this->classCounts[$class]++;
foreach ($this->tokenize($text) as $word) {
if (!isset($this->vocabulary[$word])) $this->vocabulary[$word] = true;
if (!isset($this->wordCounts[$class][$word])) $this->wordCounts[$class][$word] = 0;
$this->wordCounts[$class][$word]++;
}
}
public function predict(string $text): string {
$words = $this->tokenize($text);
$scores = [];
foreach ($this->classCounts as $class => $count) {
$score = log($this->classCounts[$class] / $this->totalDocuments);
foreach ($words as $word) {
$wordCount = $this->wordCounts[$class][$word] ?? 0;
$totalWords = array_sum($this->wordCounts[$class]);
$vocabSize = count($this->vocabulary);
$score += log(($wordCount + 1) / ($totalWords + $vocabSize));
}
$scores[$class] = $score;
}
arsort($scores);
return array_key_first($scores);
}
public function toJSON(): string {
$model = [
'version' => '1.0',
'trained_at' => date('Y-m-d H:i:s'),
'vocabulary' => array_keys($this->vocabulary),
'class_counts' => $this->classCounts,
'word_counts' => $this->wordCounts,
'total_documents' => $this->totalDocuments,
];
return json_encode($model, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
public function fromJSON(string $json): void {
$model = json_decode($json, true);
$this->vocabulary = array_fill_keys($model['vocabulary'], true);
$this->classCounts = $model['class_counts'];
$this->wordCounts = $model['word_counts'];
$this->totalDocuments = $model['total_documents'];
}
public function saveJSON(string $filepath): void {
$result = file_put_contents($filepath, $this->toJSON());
if ($result === false) {
throw new \RuntimeException("Erro ao salvar modelo: $filepath");
}
}
public function loadJSON(string $filepath): void {
if (!file_exists($filepath)) {
throw new \RuntimeException("Modelo não encontrado: $filepath");
}
$json = file_get_contents($filepath);
if ($json === false) {
throw new \RuntimeException("Erro ao ler arquivo: $filepath");
}
$this->fromJSON($json);
}
}
$classifier = new NaiveBayesClassifier();
// Treina
$classifier->train('produto excelente', 'positivo');
$classifier->train('produto horrível', 'negativo');
$classifier->train('muito bom', 'positivo');
$classifier->train('muito ruim', 'negativo');
// Salva
$classifier->saveJSON('modelo.json');
echo "✅ Modelo salvo\n";
// Carrega
$novo = new NaiveBayesClassifier();
$novo->loadJSON('modelo.json');
echo "🎯 " . $novo->predict('produto excelente') . "\n";
unlink('modelo.json');
// Saída esperada
// ✅ Modelo salvo
// 🎯 positivo
Estrutura do JSON Gerado
{
"version": "1.0",
"trained_at": "2025-12-04 18:30:00",
"vocabulary": ["produto", "excelente", "bom", "ruim"],
"class_counts": {
"positivo": 2,
"negativo": 2
},
"word_counts": {
"positivo": {
"produto": 1,
"excelente": 1,
"muito": 1,
"bom": 1
},
"negativo": {
"produto": 1,
"horrível": 1,
"muito": 1,
"ruim": 1
}
},
"total_documents": 4,
"metadata": {
"vocabulary_size": 6,
"classes": ["positivo", "negativo"]
}
}
Opção 2: PHP Serializado (Mais Compacto)
Mais rápido e compacto, mas não versionável e específico do PHP.
<?php
class NaiveBayesClassifier {
// ... métodos anteriores ...
public function save(string $filepath): void {
$model = [
'vocabulary' => $this->vocabulary,
'classCounts' => $this->classCounts,
'wordCounts' => $this->wordCounts,
'totalDocuments' => $this->totalDocuments,
];
file_put_contents($filepath, serialize($model));
}
public function load(string $filepath): void {
$model = unserialize(file_get_contents($filepath));
$this->vocabulary = $model['vocabulary'];
$this->classCounts = $model['classCounts'];
$this->wordCounts = $model['wordCounts'];
$this->totalDocuments = $model['totalDocuments'];
}
}
Comparação de Formatos
| Formato | Tamanho | Velocidade | Versionável | Portável | Recomendado |
|---|---|---|---|---|---|
| JSON | Médio | Média | ✅ Sim (Git) | ✅ Sim (qualquer linguagem) | ✅ Sim |
| PHP Serializado | Pequeno | Rápida | ❌ Não (binário) | ❌ Só PHP | Para cache |
| MessagePack | Muito Pequeno | Muito Rápida | ❌ Não | ✅ Sim | Para produção |
Boas Práticas
1. Versionamento
{
"version": "1.0",
"model_type": "naive_bayes",
"created_at": "2025-12-04T18:30:00Z"
}
Permite migração quando você mudar a estrutura.
2. Metadados
{
"metadata": {
"training_samples": 1000,
"accuracy": 0.85,
"classes": ["positivo", "negativo"]
}
}
Documenta qualidade do modelo.
3. Compressão (Opcional)
// Para modelos grandes (>1MB)
file_put_contents('modelo.json.gz', gzencode($json));
$json = gzdecode(file_get_contents('modelo.json.gz'));
Workflow Recomendado
graph LR
A[Treinar<br/>Modelo] --> B[Salvar<br/>JSON]
B --> C[Versionar<br/>Git]
C --> D[Deploy<br/>Produção]
D --> E[Carregar<br/>na Inicialização]
E --> F[Classificar<br/>Requisições]
style B fill:#0b4315,color:#fff
style C fill:#0b4315,color:#fff
Exemplo de pipeline:
# 1. Treina offline
php train_model.php --dataset=reviews.csv --output=model_v1.json
# 2. Versiona
git add model_v1.json
git commit -m "feat: modelo treinado com 10k reviews"
# 3. Em produção, carrega uma vez
$classifier->loadJSON('model_v1.json');
# 4. Reusa em todos os requests
$result = $classifier->predict($userComment);
⚠️ Limitações e Quando Migrar
Quando NÃO Funciona Bem
1. Sarcasmo
"Ah sim, excelente! Quebrou no primeiro dia."
→ Naive Bayes: POSITIVO (palavras "excelente")
→ Realidade: NEGATIVO (sarcasmo)
2. Negações
"Não é bom."
→ Ignora o "não", foca em "bom"
→ Classificação errada
3. Textos Muito Curtos
"Ok." → Ambíguo demais
Quando Evoluir
✅ Fique no Naive Bayes:
- Acurácia 75-85% OK
- Volume > 10k classificações/dia
- Latência crítica
🔄 Migre quando:
- Precisa > 90% acurácia
- Detectar sarcasmo/ironia
- Budget permite APIs
Quando os Números Dizem Não
Após validar com train/test split, se você observa:
| Métrica | Limiar Crítico | Ação |
|---|---|---|
| Acurácia | < 70% | ⚠️ Considere evolução |
| F1-Score | < 0.65 | ⚠️ Considere evolução |
| Recall (classe crítica) | < 50% | 🚨 Evolução necessária |
| Precision (classe crítica) | < 60% | 🚨 Evolução necessária |
Exemplo prático:
Se seu filtro de spam tem:
- Precision spam: 55% (45% de emails legítimos vão para spam!)
- Recall spam: 40% (60% dos spams passam!)
→ Modelo inaceitável, MIGRE para solução robusta
Regra de ouro: Comece simples (Naive Bayes), meça com métricas objetivas, evolua apenas se as métricas exigirem.
Opções
| Solução | Precisão | Latência | Custo |
|---|---|---|---|
| Naive Bayes PHP | 75-85% | <1ms | $0 |
| API Cloud | 90-95% | 100ms | $$$ |
Recursos
Artigos e Documentação
- Wikipedia - Naive Bayes - Teoria em português
- Stanford NLP - Livro acadêmico (Capítulo 4)
Tutoriais Relacionados
🎯 Conclusão
Você aprendeu a construir um Classificador Naive Bayes em PHP Puro:
✅ Latência < 1ms
✅ Custo $0
✅ Zero dependências
✅ Milhares de classificações/segundo
Use para:
- Análise de sentimento básica
- Filtros de spam
- Categorização de conteúdo
- Priorização de tickets
Próximos passos:
- Treine com dados reais do seu domínio
- Meça acurácia
- Compare com APIs
- Evolua quando necessário
Lembre-se: A melhor solução equilibra precisão, custo e performance para SEU caso! 🚀
Machine Learning não precisa ser complicado! 💡