svn commit: r1934166 - spamassassin/trunk/lib/Mail/SpamAssassin/Plugin
[email protected] Wed, 13 May 2026 13:15:33 -0000
| Newsgroups | gmane.mail.spam.spamassassin.cvs |
|---|---|
| Message-ID | <177867813309.2282739.10376835246979623492@svn03-he-fi> |
Author: gbechis
Date: Wed May 13 13:15:32 2026
New Revision: 1934166
Log:
more improvements to replay algorithm
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 Wed May 13 12:45:03 2026 (r1934165)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/NeuralNetwork.pm Wed May 13 13:15:32 2026 (r1934166)
@@ -44,7 +44,7 @@ use strict;
use warnings;
use re 'taint';
-my $VERSION = 0.9.2;
+my $VERSION = 0.10.1;
use AI::FANN qw(:all);
use Storable qw(store retrieve);
@@ -798,8 +798,8 @@ unless ($locker->safe_lock($dataset_path
$network = $existing_network;
} else {
my $stored_size = defined $stored_vocab_ref ? scalar(@$stored_vocab_ref) : 0;
- my $growth_ratio = ($model_size > 0) ? abs($num_input - $model_size) / $model_size : 1;
- if ($growth_ratio < 0.10) {
+ my $growth_ratio = ($model_size > 0) ? ($num_input - $model_size) / $model_size : 1;
+ if ($growth_ratio > 0 && $growth_ratio < 0.10) {
# Vocab changed by less than 10%: preserve existing weights by adapting feature
# vectors to the old model's input size rather than discarding a trained network.
dbg("Vocab/model size mismatch (new=$num_input vs model=$model_size, " .
@@ -855,25 +855,24 @@ unless ($locker->safe_lock($dataset_path
my $ham_docs = $raw_ham || 1;
# class_weight > 1 means this message belongs to the minority class and
- # should be trained harder; < 1 means it belongs to the majority class.
+ # should be trained harder. learn_message is always called with one
+ # message and represents an explicit correction, so never train less
+ # than the configured baseline regardless of class balance.
my $class_weight;
if ($isspam) {
- $class_weight = $ham_docs / $spam_docs; # < 1 when spam dominates
+ $class_weight = $ham_docs / $spam_docs;
} else {
- $class_weight = $spam_docs / $ham_docs; # < 1 when ham dominates
+ $class_weight = $spam_docs / $ham_docs;
}
- $class_weight = 0.25 if $class_weight < 0.25;
- $class_weight = 4.0 if $class_weight > 4.0;
+ $class_weight = 1.0 if $class_weight < 1.0;
+ $class_weight = 4.0 if $class_weight > 4.0;
my $weighted_epochs = int($train_epochs * $class_weight) || 1;
- # Scale epochs down for large vocabularies to keep per-message training time
- # roughly constant.
- if ($num_input > 1000) {
- $weighted_epochs = int($weighted_epochs * 1000 / $num_input) || 1;
- }
+
dbg("Incremental training: weighted_epochs=$weighted_epochs " .
"(base=$train_epochs, class_weight=$class_weight, " .
- "spam_docs=$spam_docs, ham_docs=$ham_docs, isspam=$isspam, num_input=$num_input)");
+ "spam_docs=$spam_docs, ham_docs=$ham_docs, isspam=$isspam, " .
+ "num_input=$num_input)");
my ($svec, $hvec);
my $replay_eligible =
@@ -890,25 +889,22 @@ unless ($locker->safe_lock($dataset_path
my $locked_num_input = $num_input;
my $locked_vocab_keys_ref = $vocab_keys_ref;
- for my $e (1 .. $weighted_epochs) {
- for my $i (0 .. $#$feature_vectors) {
- my $input = $feature_vectors->[$i]{vec};
- my $output = [$labels[$i] ? 1 : 0];
- eval { $network->train($input, $output); 1 } or dbg("Training step failed: " . ($@ || 'unknown'));
- }
- }
-
- # Dynamic replay algorithm
+ # Dynamic replay algorithm. Runs BEFORE the message training loop so the
+ # message training is the last thing the network sees, and the early-
+ # stopped prediction is preserved into the saved model. Replay here
+ # corrects drift accumulated from previous learns.
if ($replay_eligible && $svec && $hvec) {
# Use grep in scalar context: returns count of non-zero elements.
my $svec_ok = grep { $_ != 0 } @$svec;
my $hvec_ok = grep { $_ != 0 } @$hvec;
if ($svec_ok && $hvec_ok) {
- my $replay_cycles = int(sqrt($weighted_epochs / 5.0) + 0.5) || 1;
+ my $replay_cycles = int(sqrt($weighted_epochs / 5.0) + 0.5);
+ $replay_cycles = 3 if $replay_cycles < 3;
$replay_cycles = 12 if $replay_cycles > 12;
- # Alternate the order of (own, opposite) across cycles so the
- # very last gradient step is not locked to the message's class.
+ # Alternate (own, opposite) order across cycles so RPROP sees mixed
+ # gradient directions; this contracts step sizes before the message
+ # training that follows.
for my $i (1 .. $replay_cycles) {
if ($i % 2 == ($isspam ? 1 : 0)) {
eval { $network->train($hvec, [0]); 1 } or dbg("Replay ham step failed: " . ($@ || 'unknown'));
@@ -918,13 +914,38 @@ unless ($locker->safe_lock($dataset_path
eval { $network->train($hvec, [0]); 1 } or dbg("Replay ham step failed: " . ($@ || 'unknown'));
}
}
- dbg("RPROP replay: $replay_cycles cycle(s) after $weighted_epochs epoch(s) " .
+ dbg("RPROP replay: $replay_cycles cycle(s) before $weighted_epochs epoch(s) " .
"(isspam=$isspam, spam_docs=$spam_docs, ham_docs=$ham_docs)");
} else {
dbg("Skipping RPROP replay: degenerate vectors (svec_ok=$svec_ok, hvec_ok=$hvec_ok)");
}
}
+ # Early-stop margin: we stop a touch past the configured threshold so
+ # subsequent learns drifting the boundary don't immediately flip the
+ # classification of this message.
+ my $early_stop_margin = 0.1;
+ my $spam_stop = $conf->{neuralnetwork_spam_threshold} + $early_stop_margin;
+ my $ham_stop = $conf->{neuralnetwork_ham_threshold} - $early_stop_margin;
+ my $check_interval = int($weighted_epochs / 8) || 1;
+ for my $e (1 .. $weighted_epochs) {
+ for my $i (0 .. $#$feature_vectors) {
+ my $input = $feature_vectors->[$i]{vec};
+ my $output = [$labels[$i] ? 1 : 0];
+ eval { $network->train($input, $output); 1 } or dbg("Training step failed: " . ($@ || 'unknown'));
+ }
+ if ($e % $check_interval == 0 && scalar(@$feature_vectors) == 1) {
+ my $pred = eval { $network->run($feature_vectors->[0]{vec}) };
+ $pred = ref($pred) ? $pred->[0] : $pred;
+ if (defined $pred &&
+ (($isspam && $pred > $spam_stop) || (!$isspam && $pred < $ham_stop))) {
+ dbg("Early stop after $e/$weighted_epochs epochs " .
+ "(pred=$pred, isspam=$isspam, spam_stop=$spam_stop, ham_stop=$ham_stop)");
+ last;
+ }
+ }
+ }
+
if (scalar(@$feature_vectors) == 1) {
my $pred_after = eval { $network->run($feature_vectors->[0]{vec}) };
$pred_after = ref($pred_after) ? $pred_after->[0] : $pred_after;
@@ -966,7 +987,11 @@ unless ($locker->safe_lock($dataset_path
my $model_saved = 0;
eval {
- if (defined $self->{main}->{conf}->{neuralnetwork_dsn} && $self->{dbh}) {
+ if (!defined $locked_vocab_keys_ref || scalar(@$locked_vocab_keys_ref) != $locked_num_input) {
+ dbg("Skipping model vocab save: key count (" .
+ (defined $locked_vocab_keys_ref ? scalar(@$locked_vocab_keys_ref) : 'undef') .
+ ") != num_input ($locked_num_input)");
+ } elsif (defined $self->{main}->{conf}->{neuralnetwork_dsn} && $self->{dbh}) {
$self->_save_model_vocab_to_sql($locked_vocab_keys_ref)
or info("WARNING: model saved but vocab SQL write failed; " .
"model/vocab are now inconsistent and a full rebuild will " .