🥷 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:

  1. Priorizar atendimento aos insatisfeitos
  2. Identificar produtos com problemas
  3. 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, use srand() 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?

  1. Interpretação Bayesiana: É um "prior uniforme" - assumimos que todas as palavras têm chance mínima de aparecer
  2. Conserva ordem: Palavras frequentes continuam mais prováveis que raras
  3. 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

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:

  1. Treine com dados reais do seu domínio
  2. Meça acurácia
  3. Compare com APIs
  4. 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! 💡