svn commit: r1933162 - spamassassin/trunk/lib/Mail/SpamAssassin/Plugin

[email protected] Sun, 19 Apr 2026 13:57:03 -0000
Newsgroups gmane.mail.spam.spamassassin.cvs
Message-ID <177660702364.408797.9912822775394273193@svn03-he-fi>
Author: gbechis
Date: Sun Apr 19 13:57:03 2026
New Revision: 1933162

Log:
use 2 hidden neurons in the neural network
consistently use correct SIGMOID algorithm
use chi-squared algorithm to prune unused terms

Modified:
   spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm

Modified: spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm	Sun Apr 19 13:15:49 2026	(r1933161)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm	Sun Apr 19 13:57:03 2026	(r1933162)
@@ -44,7 +44,7 @@ use strict;
 use warnings;
 use re 'taint';
 
-my $VERSION = 0.7.3;
+my $VERSION = 0.8;
 
 use AI::FANN qw(:all);
 use Storable qw(store retrieve);
@@ -706,7 +706,13 @@ sub learn_message {
     $locker->safe_unlock($dataset_path);
     return;
   }
-  my $num_hidden_neurons = int(sqrt($num_input)) || 1;
+  # Two-layer hidden sizing: layer1 ~10% of inputs (clamped 32..512),
+  # layer2 half of layer1 (min 16).
+  my $num_hidden1 = int($num_input / 10);
+  $num_hidden1 = 512 if $num_hidden1 > 512;
+  $num_hidden1 = 32  if $num_hidden1 < 32;
+  my $num_hidden2 = int($num_hidden1 / 2);
+  $num_hidden2 = 16  if $num_hidden2 < 16;
   my $num_output_neurons = 1;
 
   my $network;
@@ -744,9 +750,10 @@ sub learn_message {
       $network = $self->_retrain_from_vocabulary($self->{main}->{conf}, $nn_data_dir, $num_input);
       if (!defined $network) {
         # No vocabulary stats available yet, create a fresh network
-        $network = AI::FANN->new_standard($num_input, $num_hidden_neurons, $num_output_neurons);
-        $network->hidden_activation_function(FANN_SIGMOID_STEPWISE);
-        $network->output_activation_function(FANN_SIGMOID_STEPWISE);
+        $network = AI::FANN->new_standard($num_input, $num_hidden1, $num_hidden2, $num_output_neurons);
+        my $act_fn = ($train_algorithm == FANN_TRAIN_RPROP) ? FANN_SIGMOID_STEPWISE : FANN_SIGMOID;
+        $network->hidden_activation_function($act_fn);
+        $network->output_activation_function($act_fn);
       }
     }
   }
@@ -825,8 +832,8 @@ sub learn_message {
       my $hvec_ok = grep { $_ != 0 } @$hvec;
 
       if ($svec_ok && $hvec_ok) {
-        my $replay_cycles = int(sqrt($weighted_epochs / 10.0) + 0.5) || 1;
-        $replay_cycles = 6 if $replay_cycles > 6;
+        my $replay_cycles = int(sqrt($weighted_epochs / 5.0) + 0.5) || 1;
+        $replay_cycles = 12 if $replay_cycles > 12;
 
         if ($isspam) {
           for (1 .. $replay_cycles) {
@@ -1021,8 +1028,21 @@ sub check_neuralnetwork_ham {
   return $pms->{neuralnetwork_ham};
 }
 
-# Prune vocabulary to keep only the top $vocab_cap terms, balanced between
-# spam and ham terms.
+# Compute chi-squared score measuring how discriminative a vocabulary term
+# is between spam and ham.  Returns 0 when there is insufficient data.
+sub _chi2_score {
+  my ($spam, $ham, $total_spam, $total_ham) = @_;
+  my $total = $total_spam + $total_ham;
+  return 0 unless $total > 0;
+  my $total_spam_noterm = $total_spam - $spam;
+  my $total_ham_noterm = $total_ham  - $ham;
+  my $denom = ($spam+$ham) * ($total - $spam - $ham) * $total_spam * $total_ham;
+  return 0 unless $denom > 0;
+  return ($total * ($spam*$total_ham_noterm - $ham*$total_spam_noterm)**2) / $denom;
+}
+
+# Prune vocabulary to keep only the top $vocab_cap terms ranked by chi-squared
+# discriminativeness score, ensuring the most class-separating terms are kept.
 sub _prune_vocabulary {
   my ($self, $vocabulary, $vocab_cap) = @_;
 
@@ -1033,19 +1053,22 @@ sub _prune_vocabulary {
   # Prune to 90% of cap so the vocabulary has room to grow before the next
   # prune is triggered.
   my $prune_target = int($vocab_cap * 0.9) || 1;
-  my $half = int($prune_target / 2);
 
-  my %kept;
-  for my $w (sort { ($terms->{$b}{spam}||0) <=> ($terms->{$a}{spam}||0) } keys %{$terms}) {
-    last if scalar keys %kept >= $half;
-    $kept{$w} = $terms->{$w};
-  }
-  for my $w (sort { ($terms->{$b}{ham}||0) <=> ($terms->{$a}{ham}||0) } keys %{$terms}) {
-    last if scalar keys %kept >= $prune_target;
-    $kept{$w} = $terms->{$w};
+  my $spam_docs = $vocabulary->{_spam_count} || 1;
+  my $ham_docs  = $vocabulary->{_ham_count}  || 1;
+
+  # Score every term by chi-squared discriminativeness and keep the best
+  my %scores;
+  for my $w (keys %{$terms}) {
+    $scores{$w} = _chi2_score(
+      $terms->{$w}{spam} || 0,
+      $terms->{$w}{ham}  || 0,
+      $spam_docs, $ham_docs
+    );
   }
-  # Fill any remaining slots
-  for my $w (sort { ($terms->{$b}{total}||0) <=> ($terms->{$a}{total}||0) } keys %{$terms}) {
+
+  my %kept;
+  for my $w (sort { $scores{$b} <=> $scores{$a} } keys %{$terms}) {
     last if scalar keys %kept >= $prune_target;
     $kept{$w} = $terms->{$w};
   }
@@ -1174,10 +1197,15 @@ sub _retrain_from_vocabulary {
       "spam_reps=$spam_reps, ham_reps=$ham_reps, epochs=$train_epochs");
 
   # Create and train new network
-  my $num_hidden = int(sqrt($vocab_size)) || 1;
-  my $network = AI::FANN->new_standard($vocab_size, $num_hidden, 1);
-  $network->hidden_activation_function(FANN_SIGMOID_STEPWISE);
-  $network->output_activation_function(FANN_SIGMOID_STEPWISE);
+  my $num_hidden1 = int($vocab_size / 10);
+  $num_hidden1 = 512 if $num_hidden1 > 512;
+  $num_hidden1 = 32  if $num_hidden1 < 32;
+  my $num_hidden2 = int($num_hidden1 / 2);
+  $num_hidden2 = 16  if $num_hidden2 < 16;
+  my $network = AI::FANN->new_standard($vocab_size, $num_hidden1, $num_hidden2, 1);
+  my $act_fn = ($train_algorithm == FANN_TRAIN_RPROP) ? FANN_SIGMOID_STEPWISE : FANN_SIGMOID;
+  $network->hidden_activation_function($act_fn);
+  $network->output_activation_function($act_fn);
   $network->learning_rate($learning_rate);
   $network->learning_momentum($momentum);
   $network->training_algorithm($train_algorithm);