4note: these testdata paths are not in the repo, you should gather some of your
7./mox junk train testdata/train/ham testdata/train/spam
8./mox junk train -sent-dir testdata/sent testdata/train/ham testdata/train/spam
9./mox junk check 'testdata/check/ham/mail1'
10./mox junk test testdata/check/ham testdata/check/spam
11./mox junk analyze testdata/train/ham testdata/train/spam
12./mox junk analyze -top-words 10 -train-ratio 0.5 -spam-threshold 0.85 -max-power 0.01 -sent-dir testdata/sent testdata/train/ham testdata/train/spam
13./mox junk play -top-words 10 -train-ratio 0.5 -spam-threshold 0.85 -max-power 0.01 -sent-dir testdata/sent testdata/train/ham testdata/train/spam
27 "github.com/mjl-/mox/junk"
28 "github.com/mjl-/mox/message"
29 "github.com/mjl-/mox/mlog"
30 "github.com/mjl-/mox/mox-"
39 databasePath, bloomfilterPath string
43func (a junkArgs) SetLogLevel() {
44 mox.Conf.Log[""] = mlog.LevelInfo
46 mox.Conf.Log[""] = mlog.LevelDebug
48 mlog.SetConfig(mox.Conf.Log)
51func junkFlags(fs *flag.FlagSet) (a junkArgs) {
52 fs.BoolVar(&a.params.Onegrams, "one-grams", false, "use 1-grams, i.e. single words, for scoring")
53 fs.BoolVar(&a.params.Twograms, "two-grams", true, "use 2-grams, i.e. word pairs, for scoring")
54 fs.BoolVar(&a.params.Threegrams, "three-grams", false, "use 3-grams, i.e. word triplets, for scoring")
55 fs.Float64Var(&a.params.MaxPower, "max-power", 0.05, "maximum word power, e.g. min 0.05/max 0.95")
56 fs.Float64Var(&a.params.IgnoreWords, "ignore-words", 0.1, "ignore words with ham/spaminess within this distance from 0.5")
57 fs.IntVar(&a.params.TopWords, "top-words", 10, "number of top spam and number of top ham words from email to use")
58 fs.IntVar(&a.params.RareWords, "rare-words", 1, "words are rare if encountered this number during training, and skipped for scoring")
59 fs.BoolVar(&a.debug, "debug", false, "print debug logging when calculating spam probability")
61 fs.Float64Var(&a.spamThreshold, "spam-threshold", 0.95, "probability where message is seen as spam")
62 fs.Float64Var(&a.trainRatio, "train-ratio", 0.5, "part of data to use for training versus analyzing (for analyze only)")
63 fs.StringVar(&a.sentDir, "sent-dir", "", "directory with sent mails, for training")
64 fs.BoolVar(&a.seed, "seed", false, "seed prng before analysis")
65 fs.StringVar(&a.databasePath, "dbpath", "filter.db", "database file for ham/spam words")
66 fs.StringVar(&a.bloomfilterPath, "bloompath", "filter.bloom", "bloom filter for ignoring unique strings")
71func listDir(dir string) (l []string) {
72 files, err := os.ReadDir(dir)
73 xcheckf(err, "listing directory %q", dir)
74 for _, f := range files {
75 l = append(l, f.Name())
80func must(f *junk.Filter, err error) *junk.Filter {
81 xcheckf(err, "filter")
85func cmdJunkTrain(c *cmd) {
87 c.params = "hamdir spamdir"
88 c.help = "Train a junk filter with messages from hamdir and spamdir."
89 a := junkFlags(c.flag)
96 f := must(junk.NewFilter(context.Background(), c.log, a.params, a.databasePath, a.bloomfilterPath))
98 if err := f.Close(); err != nil {
99 log.Printf("closing junk filter: %v", err)
103 hamFiles := listDir(args[0])
104 spamFiles := listDir(args[1])
105 var sentFiles []string
107 sentFiles = listDir(a.sentDir)
110 err := f.TrainDirs(args[0], a.sentDir, args[1], hamFiles, sentFiles, spamFiles)
111 xcheckf(err, "train")
114func cmdJunkCheck(c *cmd) {
116 c.params = "mailfile"
117 c.help = "Check an email message against a junk filter, printing the probability of spam on a scale from 0 to 1."
118 a := junkFlags(c.flag)
125 f := must(junk.OpenFilter(context.Background(), c.log, a.params, a.databasePath, a.bloomfilterPath, false))
127 if err := f.Close(); err != nil {
128 log.Printf("closing junk filter: %v", err)
132 result, err := f.ClassifyMessagePath(context.Background(), args[0])
133 xcheckf(err, "testing mail")
136 if !result.Significant {
137 sig = "not significant"
139 fmt.Printf("%.6f, %s\n", result.Probability, sig)
142func cmdJunkTest(c *cmd) {
144 c.params = "hamdir spamdir"
145 c.help = "Check a directory with hams and one with spams against the junk filter, and report the success ratio."
146 a := junkFlags(c.flag)
153 f := must(junk.OpenFilter(context.Background(), c.log, a.params, a.databasePath, a.bloomfilterPath, false))
155 if err := f.Close(); err != nil {
156 log.Printf("closing junk filter: %v", err)
160 testDir := func(dir string, ham bool) (int, int) {
162 files, err := os.ReadDir(dir)
163 xcheckf(err, "readdir %q", dir)
164 for _, fi := range files {
165 path := filepath.Join(dir, fi.Name())
166 result, err := f.ClassifyMessagePath(context.Background(), path)
168 log.Printf("classify message %q: %s", path, err)
171 if ham && result.Probability < a.spamThreshold || !ham && result.Probability > a.spamThreshold {
176 if ham && result.Probability > a.spamThreshold {
177 fmt.Printf("ham %q: %.4f\n", path, result.Probability)
179 if !ham && result.Probability < a.spamThreshold {
180 fmt.Printf("spam %q: %.4f\n", path, result.Probability)
186 nhamok, nhambad := testDir(args[0], true)
187 nspamok, nspambad := testDir(args[1], false)
188 fmt.Printf("total ham, ok %d, bad %d\n", nhamok, nhambad)
189 fmt.Printf("total spam, ok %d, bad %d\n", nspamok, nspambad)
190 fmt.Printf("specifity (true negatives, hams identified): %.6f\n", float64(nhamok)/(float64(nhamok+nhambad)))
191 fmt.Printf("sensitivity (true positives, spams identified): %.6f\n", float64(nspamok)/(float64(nspamok+nspambad)))
192 fmt.Printf("accuracy: %.6f\n", float64(nhamok+nspamok)/float64(nhamok+nhambad+nspamok+nspambad))
195func cmdJunkAnalyze(c *cmd) {
197 c.params = "hamdir spamdir"
198 c.help = `Analyze a directory with ham messages and one with spam messages.
200A part of the messages is used for training, and remaining for testing. The
201messages are shuffled, with optional random seed.`
202 a := junkFlags(c.flag)
209 f := must(junk.NewFilter(context.Background(), c.log, a.params, a.databasePath, a.bloomfilterPath))
211 if err := f.Close(); err != nil {
212 log.Printf("closing junk filter: %v", err)
218 hamFiles := listDir(hamDir)
219 spamFiles := listDir(spamDir)
223 seed = time.Now().UnixMilli()
225 // Still at math/rand (v1 instead of v2) for potential comparison to earlier test results.
226 rand := mathrand.New(mathrand.NewSource(seed))
228 shuffle := func(l []string) {
231 n := rand.Intn(count)
232 l[i], l[n] = l[n], l[i]
239 ntrainham := int(a.trainRatio * float64(len(hamFiles)))
240 ntrainspam := int(a.trainRatio * float64(len(spamFiles)))
242 trainHam := hamFiles[:ntrainham]
243 trainSpam := spamFiles[:ntrainspam]
244 testHam := hamFiles[ntrainham:]
245 testSpam := spamFiles[ntrainspam:]
247 var trainSent []string
249 trainSent = listDir(a.sentDir)
252 err := f.TrainDirs(hamDir, a.sentDir, spamDir, trainHam, trainSent, trainSpam)
253 xcheckf(err, "train")
255 testDir := func(dir string, files []string, ham bool) (ok, bad, malformed int) {
256 for _, name := range files {
257 path := filepath.Join(dir, name)
258 result, err := f.ClassifyMessagePath(context.Background(), path)
260 // log.Infof("%s: %s", path, err)
264 if ham && result.Probability < a.spamThreshold || !ham && result.Probability > a.spamThreshold {
269 if ham && result.Probability > a.spamThreshold {
270 fmt.Printf("ham %q: %.4f\n", path, result.Probability)
272 if !ham && result.Probability < a.spamThreshold {
273 fmt.Printf("spam %q: %.4f\n", path, result.Probability)
279 nhamok, nhambad, nmalformedham := testDir(args[0], testHam, true)
280 nspamok, nspambad, nmalformedspam := testDir(args[1], testSpam, false)
281 fmt.Printf("training done, nham %d, nsent %d, nspam %d\n", ntrainham, len(trainSent), ntrainspam)
282 fmt.Printf("total ham, ok %d, bad %d, malformed %d\n", nhamok, nhambad, nmalformedham)
283 fmt.Printf("total spam, ok %d, bad %d, malformed %d\n", nspamok, nspambad, nmalformedspam)
284 fmt.Printf("specifity (true negatives, hams identified): %.6f\n", float64(nhamok)/(float64(nhamok+nhambad)))
285 fmt.Printf("sensitivity (true positives, spams identified): %.6f\n", float64(nspamok)/(float64(nspamok+nspambad)))
286 fmt.Printf("accuracy: %.6f\n", float64(nhamok+nspamok)/float64(nhamok+nhambad+nspamok+nspambad))
289func cmdJunkPlay(c *cmd) {
291 c.params = "hamdir spamdir"
292 c.help = "Play messages from ham and spam directory according to their time of arrival and report on junk filter performance."
293 a := junkFlags(c.flag)
300 f := must(junk.NewFilter(context.Background(), c.log, a.params, a.databasePath, a.bloomfilterPath))
302 if err := f.Close(); err != nil {
303 log.Printf("closing junk filter: %v", err)
307 // We'll go through all emails to find their dates.
315 var nbad, nnodate, nham, nspam, nsent int
317 scanDir := func(dir string, ham, sent bool) {
318 for _, name := range listDir(dir) {
319 path := filepath.Join(dir, name)
320 mf, err := os.Open(path)
321 xcheckf(err, "open %q", path)
323 xcheckf(err, "stat %q", path)
324 p, err := message.EnsurePart(c.log.Logger, false, mf, fi.Size())
327 if err := mf.Close(); err != nil {
328 log.Printf("closing message file: %v", err)
332 if p.Envelope.Date.IsZero() {
334 if err := mf.Close(); err != nil {
335 log.Printf("closing message file: %v", err)
339 if err := mf.Close(); err != nil {
340 log.Printf("closing message file: %v", err)
342 msgs = append(msgs, msg{dir, name, ham, sent, p.Envelope.Date})
355 scanDir(hamDir, true, false)
356 scanDir(spamDir, false, false)
358 scanDir(a.sentDir, true, true)
361 // Sort the messages, earliest first.
362 sort.Slice(msgs, func(i, j int) bool {
363 return msgs[i].t.Before(msgs[j].t)
366 // Play all messages as if they are coming in. We predict their spaminess, check if
367 // we are right. And we train the system with the result.
368 var nhamok, nhambad, nspamok, nspambad int
370 play := func(msg msg) {
371 var words map[string]struct{}
372 path := filepath.Join(msg.dir, msg.filename)
374 result, err := f.ClassifyMessagePath(context.Background(), path)
380 if result.Probability < a.spamThreshold {
386 if result.Probability > a.spamThreshold {
393 mf, err := os.Open(path)
394 xcheckf(err, "open %q", path)
396 if err := mf.Close(); err != nil {
397 log.Printf("closing message file: %v", err)
401 xcheckf(err, "stat %q", path)
402 p, err := message.EnsurePart(c.log.Logger, false, mf, fi.Size())
404 log.Printf("bad sent message %q: %s", path, err)
408 words, err = f.ParseMessage(p)
410 log.Printf("bad sent message %q: %s", path, err)
415 if err := f.Train(context.Background(), msg.ham, words); err != nil {
416 log.Printf("train: %s", err)
420 for _, m := range msgs {
425 xcheckf(err, "saving filter")
427 fmt.Printf("completed, nham %d, nsent %d, nspam %d, nbad %d, nwithoutdate %d\n", nham, nsent, nspam, nbad, nnodate)
428 fmt.Printf("total ham, ok %d, bad %d\n", nhamok, nhambad)
429 fmt.Printf("total spam, ok %d, bad %d\n", nspamok, nspambad)
430 fmt.Printf("specifity (true negatives, hams identified): %.6f\n", float64(nhamok)/(float64(nhamok+nhambad)))
431 fmt.Printf("sensitivity (true positives, spams identified): %.6f\n", float64(nspamok)/(float64(nspamok+nspambad)))
432 fmt.Printf("accuracy: %.6f\n", float64(nhamok+nspamok)/float64(nhamok+nhambad+nspamok+nspambad))