You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spamassassin.apache.org by gb...@apache.org on 2026/02/24 23:59:32 UTC
svn commit: r1932034 - spamassassin/trunk/lib/Mail/SpamAssassin/Plugin
Author: gbechis
Date: Tue Feb 24 23:59:31 2026
New Revision: 1932034
Log:
Retrain the model from vocabulary statistics when vocab size has changed
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 Tue Feb 24 23:12:18 2026 (r1932033)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm Tue Feb 24 23:59:31 2026 (r1932034)
@@ -699,20 +699,93 @@ sub check_neuralnetwork_ham {
return $pms->{neuralnetwork_ham};
}
-# Helper: ensure vector matches expected size by padding/truncating
-sub _adjust_vector_size {
- my ($vec, $expected) = @_;
- return unless defined $vec && defined $expected && $expected >= 0;
- my @v = @$vec;
- my $len = scalar @v;
- if ($len < $expected) {
- dbg("Adjusting input vector: padding from $len to $expected");
- push @v, (0) x ($expected - $len);
- } elsif ($len > $expected) {
- dbg("Adjusting input vector: truncating from $len to $expected");
- $#v = $expected - 1;
+# Retrain the model from vocabulary statistics when vocab size has changed.
+sub _retrain_from_vocabulary {
+ my ($self, $conf, $nn_data_dir, $vocab_size) = @_;
+
+ return unless defined $nn_data_dir && $vocab_size > 0;
+
+ my $learning_rate = $conf->{neuralnetwork_learning_rate};
+ my $momentum = $conf->{neuralnetwork_momentum};
+ my $train_epochs = $conf->{neuralnetwork_train_epochs};
+ my $train_algorithm = $conf->{neuralnetwork_train_algorithm};
+
+ my %vocabulary;
+ if (defined $conf->{neuralnetwork_dsn} && $self->{dbh}) {
+ my $vocab_ref = $self->_load_vocabulary_from_sql($self->{main}->{username});
+ %vocabulary = %{$vocab_ref} if ref($vocab_ref) eq 'HASH';
+ }
+ if (!keys %{$vocabulary{terms} || {}}) {
+ my $vocab_path = File::Spec->catfile($nn_data_dir, 'vocabulary-' . lc($self->{main}->{username}) . '.data');
+ if (-f $vocab_path) {
+ eval {
+ my $ref = retrieve($vocab_path);
+ %vocabulary = %{$ref} if ref $ref eq 'HASH';
+ 1;
+ } or do {
+ info("Failed to load vocabulary for retraining: " . ($@ || 'unknown'));
+ return;
+ };
+ }
+ }
+
+ my $terms = $vocabulary{terms} || {};
+ return unless scalar keys %{$terms};
+
+ my @vocab_keys = sort keys %{$terms};
+ my $actual_size = scalar @vocab_keys;
+ return unless $actual_size == $vocab_size;
+
+ # Build synthetic spam and ham TF-IDF vectors from vocabulary statistics
+ my $N = $vocabulary{_doc_count} || 1;
+ my (@spam_vec, @ham_vec);
+ for my $i (0 .. $#vocab_keys) {
+ my $w = $vocab_keys[$i];
+ my $td = $terms->{$w};
+ my $df = $td->{docs} || 0;
+ my $idf = log(($N + 1) / ($df + 1)) + 1;
+ my $spam_freq = $td->{spam} || 0;
+ my $ham_freq = $td->{ham} || 0;
+ my $total = $spam_freq + $ham_freq || 1;
+ $spam_vec[$i] = ($spam_freq / $total) * $idf;
+ $ham_vec[$i] = ($ham_freq / $total) * $idf;
+ }
+
+ # L2-normalize both vectors
+ for my $vec (\@spam_vec, \@ham_vec) {
+ my $norm = 0;
+ $norm += $_ * $_ for @$vec;
+ $norm = sqrt($norm) || 1;
+ @$vec = map { $_ / $norm } @$vec;
+ }
+
+ # 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);
+ $network->learning_rate($learning_rate);
+ $network->learning_momentum($momentum);
+ $network->training_algorithm($train_algorithm);
+
+ for my $e (1 .. $train_epochs) {
+ eval { $network->train(\@spam_vec, [1]); 1 } or dbg("Retrain spam step failed: " . ($@ || 'unknown'));
+ eval { $network->train(\@ham_vec, [0]); 1 } or dbg("Retrain ham step failed: " . ($@ || 'unknown'));
}
- return \@v;
+
+ my $dataset_path = File::Spec->catfile($nn_data_dir, 'fann-' . lc($self->{main}->{username}) . '.model');
+ eval {
+ $network->save($dataset_path) or die "save failed";
+ 1;
+ } and do {
+ $self->{neural_model} = $network;
+ info("Model retrained from vocabulary statistics and saved (inputs: $vocab_size)");
+ } or do {
+ info("Failed to save retrained model: " . ($@ || 'unknown'));
+ return;
+ };
+
+ return $network;
}
sub _check_neuralnetwork {
@@ -778,12 +851,11 @@ sub _check_neuralnetwork {
my $expected_size = $network->num_inputs();
if (scalar(@$input_vector) != $expected_size) {
- dbg("Prediction vector size mismatch. Got ".scalar(@$input_vector).", expected ".$expected_size.". Adjusting vector.");
- $input_vector = _adjust_vector_size($input_vector, $expected_size);
- # If adjustment failed for some reason, abort
- unless (defined $input_vector && scalar(@$input_vector) == $expected_size) {
+ dbg("Vocabulary size changed (got ".scalar(@$input_vector).", model expects ".$expected_size."), retraining model");
+ $network = $self->_retrain_from_vocabulary($conf, $nn_data_dir, $vocab_size);
+ unless (defined $network) {
$pms->{neuralnetwork_prediction} = undef;
- info("Adjusted vector invalid, skipping prediction.");
+ info("Model retraining failed, skipping prediction");
return;
}
}