Skip to main content

sioul_learn/
supervised.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! fastText's classifier, the filter's model (`train::Model::Supervised`,
5//! the default; docs/spam-filter.md, "The model, and how it folds"): each
6//! message one line, its words, then its header facts as words
7//! (`features::header_words`), two labels, softmax. Word pairs only when
8//! asked (`word_ngrams` above 1), in a trial: the table reads none.
9//!
10//! It folds per row: with two labels, softmax's P(spam) is the sigmoid of
11//! `f = (w_spam − w_ham) · h`, where `h` is the mean of every input row the
12//! line reads (each known word's own, each of its character n-grams'
13//! buckets, the end of the line's), so `f` is the mean of the rows' scalars
14//! `(w_spam − w_ham) · row` (`row_scalars`). The table keeps each known
15//! word's rows' sum, each bucket's scalar and the end of the line's
16//! (`table::Kind::Rows`); it must give fastText's own prediction within
17//! 1e-4 on the test messages, or nothing is exported.
18//!
19//! Calibrated by Platt on scores of mail the model never learned from: in
20//! each account's and each outside source's mail of each label, the newest
21//! fifth of the messages learned from, scored by a model of the rest (as
22//! the test is split: `train::newest_fifths`), Platt fitted on your own
23//! mail's part, the mail the filter will judge. In time order alone, that
24//! newest fifth would be your own mail only, scored by a model of outside
25//! material only, which has no header facts: its probabilities came out
26//! far too sure of spam.
27//!
28//! After the tokens (`train::train_with`'s first steps, shared): the same
29//! parts (`train::parts`); calibration, then the model of the oldest 80 %;
30//! its table, checked; the numbers and the comparison with the table in
31//! place, as the centroid's; kept, calibrated and learned again from every
32//! message, the newest month included, folded, checked and written, the
33//! model kept as the language model's file (private, never shared).
34
35use crate::detail::{self, Detail, Tested};
36use crate::eval;
37use crate::labels::Label;
38use crate::platt::{self, Platt};
39use crate::spamcore::{self as spam, N, Table};
40use crate::train::{self, Message, Options, Refit, Split, Summary};
41use crate::{Cancel, Dirs, LearnError, Progress, Stage, io_error, now};
42use fasttext::matrix::Matrix;
43use std::collections::BTreeMap;
44use std::io::{BufWriter, Write};
45use std::path::{Path, PathBuf};
46
47/// fastText's label prefix here: capitals, which no word of a message has
48/// (the tokenizer lowercases them), so that no sender's text reads as a label.
49const LABEL: &str = "SIOUL_LABEL_";
50const SPAM_LABEL: &str = "SIOUL_LABEL_SPAM";
51const HAM_LABEL: &str = "SIOUL_LABEL_HAM";
52
53/// The largest difference allowed between the table's probability and fastText's.
54const FOLD_TOLERANCE: f64 = 1e-4;
55
56/// A message as the classifier reads it: its words, then its header facts'
57/// words; outside material, which has no headers, its words alone.
58fn text_of(words: &[String], message: &Message) -> String {
59    let mut text = words.join(" ");
60    if message.known {
61        for (word, _) in spam::header_words(&message.features) {
62            text.push(' ');
63            text.push_str(&word);
64        }
65    }
66    text
67}
68
69/// The file a model learns from, removed whatever happens.
70struct LearnFile(PathBuf);
71
72impl Drop for LearnFile {
73    fn drop(&mut self) {
74        let _ = std::fs::remove_file(&self.0);
75    }
76}
77
78/// Writes the learning file of some messages, one line each, shuffled (the same seed, the same order).
79fn write_learning(path: &Path, ids: &[usize], text: &dyn Fn(usize) -> String, messages: &[Message], seed: u64) -> Result<LearnFile, LearnError> {
80    let file = std::fs::File::create(path).map_err(|e| io_error(path, e))?;
81    let guard = LearnFile(path.to_path_buf());
82    let mut out = BufWriter::new(file);
83    let mut order: Vec<usize> = ids.to_vec();
84    let mut rng = crate::Rng::new(seed);
85    for i in (1..order.len()).rev() {
86        order.swap(i, rng.below(i + 1));
87    }
88    for &i in &order {
89        let label = if messages[i].label == Label::Spam { SPAM_LABEL } else { HAM_LABEL };
90        writeln!(out, "{label} {}", text(i)).map_err(|e| io_error(path, e))?;
91    }
92    out.flush().map_err(|e| io_error(path, e))?;
93    Ok(guard)
94}
95
96/// fastText's classifier learned from the messages `ids`.
97#[allow(clippy::too_many_arguments)]
98fn learn(path: &Path, ids: &[usize], text: &dyn Fn(usize) -> String, messages: &[Message], options: &Options, word_ngrams: u32, lr: f64, cancel: &Cancel) -> Result<fasttext::FastText, LearnError> {
99    let _file = write_learning(path, ids, text, messages, options.seed)?;
100    let args = fasttext::args::Args {
101        input: path.to_path_buf(),
102        model: fasttext::args::ModelName::Supervised,
103        loss: fasttext::args::LossName::Softmax,
104        dim: options.dim as i32,
105        epoch: options.epochs as i32,
106        lr,
107        word_ngrams: word_ngrams.max(1) as i32,
108        min_count: options.min_count.max(1) as i32,
109        bucket: options.bucket as i32,
110        minn: options.minn as i32,
111        maxn: options.maxn as i32,
112        thread: options.threads.max(1) as i32,
113        seed: options.seed as i32,
114        label: LABEL.to_string(),
115        verbose: 0,
116        ..fasttext::args::Args::default()
117    };
118    fasttext::FastText::train_with_abort(args, cancel.flag()).map_err(|e| LearnError::Language(e.to_string()))
119}
120
121/// Each input row's scalar in a model of two labels: `(w_spam − w_ham) · row`.
122fn row_scalars(model: &fasttext::FastText) -> Result<Vec<f32>, LearnError> {
123    let (labels, _) = model.get_labels();
124    let index = |name: &str| labels.iter().position(|l| l == name).ok_or_else(|| LearnError::Language(format!("no label {name}: both kinds are needed")));
125    let (spam, ham) = (index(SPAM_LABEL)?, index(HAM_LABEL)?);
126    let output = model.output_matrix();
127    let direction: Vec<f32> = output.row(spam as i64).iter().zip(output.row(ham as i64)).map(|(s, h)| s - h).collect();
128    let input = model.input_matrix();
129    Ok((0..input.rows()).map(|r| input.row(r).iter().zip(&direction).map(|(a, b)| a * b).sum()).collect())
130}
131
132/// A text's decision value, as `predict` reads it: its rows (words, their
133/// n-grams, word pairs), the end of the line's appended, their scalars' mean.
134fn decision(model: &fasttext::FastText, scalars: &[f32], text: &str) -> f64 {
135    let (mut rows, mut labels) = (Vec::new(), Vec::new());
136    model.dict().get_line_from_str(text, &mut rows, &mut labels);
137    if let Some(eos) = model.dict().get_id(fasttext::dictionary::EOS) {
138        rows.push(eos);
139    }
140    if rows.is_empty() {
141        return 0.0;
142    }
143    rows.iter().map(|&r| f64::from(scalars[r as usize])).sum::<f64>() / rows.len() as f64
144}
145
146/// Platt's sigmoid for a model of `ids`: in each account's and each outside
147/// source's mail of each label, the newest fifth of `ids` scored by a model
148/// of the rest; fitted on your own mail's part (all of it when your own
149/// lacks a kind). With how many scores it was fitted on, and the AUC of all.
150#[allow(clippy::too_many_arguments)]
151fn calibrate(path: &Path, ids: &[usize], text: &dyn Fn(usize) -> String, messages: &[Message], sources: &[String], options: &Options, word_ngrams: u32, lr: f64, cancel: &Cancel) -> Result<(Platt, u64, Option<f64>), LearnError> {
152    let group = |i: usize| (messages[i].source.map_or(messages[i].account.as_str(), |s| sources[s].as_str()), messages[i].label == Label::Spam);
153    let (older, newer) = train::newest_fifths(ids, group);
154    let both = |ids: &[usize]| ids.iter().any(|&i| messages[i].label == Label::Spam) && ids.iter().any(|&i| messages[i].label == Label::Ham);
155    if !both(&older) || newer.is_empty() {
156        return Ok((Platt { a: -1.0, b: 0.0 }, 0, None));
157    }
158    let model = learn(path, &older, text, messages, options, word_ngrams, lr, cancel)?;
159    let scalars = row_scalars(&model)?;
160    let scored: Vec<(usize, f64)> = newer.iter().map(|&i| (i, decision(&model, &scalars, &text(i)))).collect();
161    let all: Vec<(f64, bool)> = scored.iter().map(|&(i, f)| (f, messages[i].label == Label::Spam)).collect();
162    let own: Vec<(f64, bool)> = scored.iter().filter(|(i, _)| messages[*i].source.is_none()).map(|&(i, f)| (f, messages[i].label == Label::Spam)).collect();
163    let (sigmoid, count) = match platt::fit(&own) {
164        Some(sigmoid) => (sigmoid, own.len()),
165        None => (platt::fit(&all).unwrap_or(Platt { a: -1.0, b: 0.0 }), all.len()),
166    };
167    Ok((sigmoid, count as u64, eval::auc(&all)))
168}
169
170/// The model folded into the classifier's table (`table::Kind::Rows`): each
171/// known word's rows' sum (its own and its n-grams', as fastText lists them),
172/// each bucket's scalar, the end of the line's as the bias.
173fn fold(model: &fasttext::FastText, scalars: &[f32], sigmoid: Platt, options: &Options, split: &Split) -> Table {
174    let dict = model.dict();
175    let nwords = dict.nwords();
176    let eos = fasttext::dictionary::EOS;
177    let words: Vec<(u64, f32)> = (0..nwords)
178        .filter(|&id| dict.get_word(id) != eos)
179        .map(|id| (spam::word_hash(dict.get_word(id)), dict.get_subwords(id).iter().map(|&r| f64::from(scalars[r as usize])).sum::<f64>() as f32))
180        .collect();
181    let first = nwords as usize;
182    let buckets: Vec<f32> = (0..options.bucket as usize).map(|b| scalars.get(first + b).copied().unwrap_or(0.0)).collect();
183    Table {
184        tokenizer: spam::TOKENIZER,
185        features: spam::FEATURES,
186        dim: options.dim,
187        minn: options.minn,
188        maxn: options.maxn,
189        bucket: options.bucket,
190        words,
191        buckets,
192        weights: Vec::new(),
193        means: Vec::new(),
194        bias: dict.get_id(eos).map_or(0.0, |id| scalars[id as usize]),
195        text_mean: 0.0,
196        platt_a: sigmoid.a as f32,
197        platt_b: sigmoid.b as f32,
198        meta: spam::Meta {
199            trained_at: now(),
200            ham: split.train_ham,
201            spam: split.train_spam,
202            test_ham: split.test_ham,
203            test_spam: split.test_spam,
204            metrics: BTreeMap::new(),
205            device: sioul_sync::lease::host_name(),
206            lexicon: Some(options.lexicon.clone()),
207        },
208    }
209}
210
211/// The largest difference between the table's probability, Platt aside, and
212/// fastText's own prediction, on messages `ids` (500 at most); over 1e-4,
213/// the fold is broken and nothing is exported.
214fn checked_fold(table: &Table, model: &fasttext::FastText, ids: &[usize], words: &[Vec<String>], messages: &[Message]) -> Result<f64, LearnError> {
215    let mut error = 0f64;
216    for &i in ids.iter().take(500) {
217        let features = if messages[i].known { messages[i].features } else { [f32::NAN; N] };
218        let ours = 1.0 / (1.0 + (-f64::from(table.score(&words[i], &features).f)).exp());
219        let theirs = model.predict(&text_of(&words[i], &messages[i]), 2, 0.0).into_iter().find(|p| p.label == SPAM_LABEL).map_or(0.0, |p| f64::from(p.prob));
220        error = error.max((ours - theirs).abs());
221    }
222    if error > FOLD_TOLERANCE {
223        return Err(LearnError::Fold(format!("the table differs from fastText's prediction by {error:e}")));
224    }
225    Ok(error)
226}
227
228/// What the classifier learned of each header fact: its word's rows' sum in
229/// the table (toward spam above 0), by the fact's name ("dkim_signed",
230/// "links_0"…); a fact never seen often enough, none.
231fn header_weights(table: &Table) -> BTreeMap<String, f64> {
232    spam::all_header_words()
233        .into_iter()
234        .filter_map(|(word, _)| {
235            let at = table.words.binary_search_by_key(&spam::word_hash(&word), |(h, _)| *h).ok()?;
236            Some((word.trim_start_matches("H:").to_ascii_lowercase(), f64::from(table.words[at].1)))
237        })
238        .collect()
239}
240
241/// The training, from the messages read (`train::train_with`, which hands
242/// them here for this model): see the module.
243#[allow(clippy::too_many_arguments)]
244pub(crate) fn train(dirs: &Dirs, read: train::Read, options: &Options, word_ngrams: u32, lr: f64, errors: usize, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Result<(Summary, Detail), LearnError> {
245    let train::Read { messages, words, sources, mut outside, labels, started } = read;
246    let check = || if cancel.cancelled() { Err(LearnError::Cancelled) } else { Ok(()) };
247    let train::Parts { order, train: train_ids, test: test_ids, held } = train::parts(&messages, sources.len());
248    let text = |i: usize| text_of(&words[i], &messages[i]);
249    std::fs::create_dir_all(&dirs.cache).map_err(|e| io_error(&dirs.cache, e))?;
250    let path = dirs.cache.join(format!("classifier-{}.txt", std::process::id()));
251    // The table reads no word pairs: with them, a trial scored by fastText itself.
252    let exact = word_ngrams <= 1;
253
254    // Calibration, then the model of the oldest 80 %.
255    progress(&Progress { stage: Stage::Language, done: 0, total: 0, detail: String::new() });
256    let (sigmoid, calibration_scores, calibration_auc) = calibrate(&path, &train_ids, &text, &messages, &sources, options, word_ngrams, lr, cancel)?;
257    check()?;
258    let model = learn(&path, &train_ids, &text, &messages, options, word_ngrams, lr, cancel)?;
259    check()?;
260    let scalars = row_scalars(&model)?;
261    progress(&Progress { stage: Stage::Language, done: 1, total: 1, detail: String::new() });
262
263    // Its table as every device will read it (its bytes, read back), checked; its numbers.
264    progress(&Progress { stage: Stage::Evaluation, done: 0, total: test_ids.len() as u64, detail: String::new() });
265    let (split, accounts) = train::counted(&messages, &train_ids, &test_ids);
266    let table = Table::from_bytes(&fold(&model, &scalars, sigmoid, options, &split).to_bytes()).map_err(LearnError::Fold)?;
267    let fold_error = if exact { checked_fold(&table, &model, &test_ids, &words, &messages)? } else { 0.0 };
268    let lacking = [f32::NAN; N];
269    let p = |i: usize| -> f64 {
270        if exact {
271            f64::from(table.score(&words[i], if messages[i].known { &messages[i].features } else { &lacking }).p)
272        } else {
273            sigmoid.probability(decision(&model, &scalars, &text(i)))
274        }
275    };
276    let new_scored: Vec<(f64, bool)> = test_ids.iter().map(|&i| (p(i), messages[i].label == Label::Spam)).collect();
277    let test = eval::evaluate(&new_scored, options.threshold_spam, options.threshold_unsure);
278    let settled: Vec<(f64, bool)> = test_ids.iter().zip(&new_scored).filter(|&(&i, _)| !messages[i].unsettled).map(|(_, &s)| s).collect();
279    let test_settled = Some(eval::evaluate(&settled, options.threshold_spam, options.threshold_unsure));
280    let mut tested: Vec<Tested> = test_ids.iter().zip(&new_scored).map(|(&i, &(p, _))| train::tested_of(&messages[i], p, &sources)).collect();
281    for (s, name) in sources.iter().enumerate() {
282        let scored: Vec<(f64, bool)> = held[s].iter().map(|&i| (p(i), messages[i].label == Label::Spam)).collect();
283        tested.extend(held[s].iter().zip(&scored).map(|(&i, &(p, _))| train::tested_of(&messages[i], p, &sources)));
284        train::held_out(outside.entry(name.clone()).or_default(), &scored, options);
285    }
286    let detail = detail::of(dirs, &tested, options.threshold_spam, options.threshold_unsure, errors)?;
287    drop(tested);
288    progress(&Progress { stage: Stage::Evaluation, done: test_ids.len() as u64, total: test_ids.len() as u64, detail: String::new() });
289
290    // The table in place, on the test messages it never learned from.
291    let test_words: Vec<Vec<String>> = test_ids.iter().map(|&i| words[i].clone()).collect();
292    let (current, compared, replaced, reason) = train::against_current(dirs, &test_ids, &test_words, &messages, &new_scored, options)?;
293    check()?;
294
295    // Kept: calibrated and learned again from every message, the newest month included, and written.
296    // A trial stops here: nothing of the filter changes.
297    let replaced = replaced && options.replace && exact;
298    let mut table_bytes = table.to_bytes().len() as u64;
299    let refit = if replaced {
300        let step = |done: u64, progress: &mut dyn FnMut(&Progress)| progress(&Progress { stage: Stage::Export, done, total: 3, detail: String::new() });
301        step(0, progress);
302        let (sigmoid, calibration_scores, _) = calibrate(&path, &order, &text, &messages, &sources, options, word_ngrams, lr, cancel)?;
303        check()?;
304        step(1, progress);
305        let model = learn(&path, &order, &text, &messages, options, word_ngrams, lr, cancel)?;
306        check()?;
307        step(2, progress);
308        let scalars = row_scalars(&model)?;
309        let ham_of = |ids: &[usize]| ids.iter().filter(|&&i| messages[i].label == Label::Ham).count() as u64;
310        let all = Split { train_ham: ham_of(&order), train_spam: order.len() as u64 - ham_of(&order), ..split };
311        let mut written = Table::from_bytes(&fold(&model, &scalars, sigmoid, options, &all).to_bytes()).map_err(LearnError::Fold)?;
312        let refit_error = checked_fold(&written, &model, &test_ids, &words, &messages)?;
313        written.meta.metrics = train::metrics(&test);
314        check()?;
315        // Written whole or not at all; the table it replaces is kept as table.prev.bin.
316        written.write(&dirs.table()).map_err(|e| io_error(&dirs.table(), e))?;
317        table_bytes = std::fs::metadata(dirs.table()).map_or(table_bytes, |m| m.len());
318        let fresh = dirs.language().with_extension("bin.new");
319        model.save_model(&fresh).map_err(|e| io_error(&fresh, e))?;
320        std::fs::rename(&fresh, dirs.language()).map_err(|e| io_error(&dirs.language(), e))?;
321        step(3, progress);
322        Some(Refit { ham: all.train_ham, spam: all.train_spam, platt_a: sigmoid.a, platt_b: sigmoid.b, calibration_scores, fold_error: refit_error })
323    } else {
324        progress(&Progress { stage: Stage::Export, done: 1, total: 1, detail: String::new() });
325        None
326    };
327    let summary = Summary {
328        trained_at: now(),
329        seconds: started.elapsed().as_secs_f64(),
330        tokenizer: spam::TOKENIZER,
331        features: spam::FEATURES,
332        replaced,
333        reason: reason.to_string(),
334        threshold_spam: options.threshold_spam,
335        threshold_unsure: options.threshold_unsure,
336        labels,
337        split,
338        accounts,
339        model: train::ModelSummary {
340            kind: "classifier".to_string(),
341            word_ngrams: word_ngrams.max(1),
342            lr,
343            dim: options.dim,
344            vocabulary: u64::try_from(model.dict().nwords()).unwrap_or(0).saturating_sub(1),
345            bucket: options.bucket,
346            minn: options.minn,
347            maxn: options.maxn,
348            epochs: options.epochs,
349            min_count: options.min_count.max(1),
350            threads: options.threads,
351            costs: Vec::new(),
352            c: 0.0,
353            validation_auc: calibration_auc.map(|a| ("calibration".to_string(), a)).into_iter().collect(),
354            ham_weight: 0.0,
355            platt_a: sigmoid.a,
356            platt_b: sigmoid.b,
357            calibration_scores,
358            fold_error,
359            table_bytes,
360            header_weights: header_weights(&table),
361        },
362        test,
363        test_settled,
364        current,
365        compared,
366        refit,
367        outside,
368        trial: !options.replace,
369    };
370    // What the settings say of the last training: never a trial's.
371    if options.replace {
372        let text = toml::to_string(&summary).map_err(|e| io_error(&dirs.trained(), e))?;
373        crate::corpus::write_whole(&dirs.trained(), text.as_bytes())?;
374    }
375    Ok((summary, detail))
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    /// The table's word sums and buckets are fastText's own rows, and a
383    /// message scores on the phone what fastText predicts: words it knows,
384    /// words it never saw, header facts, outside material without them.
385    #[test]
386    fn the_table_is_fasttexts_prediction() {
387        let root = std::env::temp_dir().join(format!("sioul-learn-classifier-{}", std::process::id()));
388        let _ = std::fs::remove_dir_all(&root);
389        std::fs::create_dir_all(&root).unwrap();
390        let mut rng = crate::Rng::new(3);
391        let ham = ["meeting", "agenda", "invoice", "attached", "report", "thanks", "tomorrow", "project"];
392        let spam = ["lottery", "winner", "prize", "claim", "bitcoin", "urgent", "verify", "account"];
393        let line = |rng: &mut crate::Rng, pool: &[&str]| (0..12).map(|_| pool[rng.below(pool.len())].to_string()).collect::<Vec<String>>();
394        let by_sender = spam::NAMES.iter().position(|n| *n == "dkim_signed_by_sender").unwrap();
395        let mut messages = Vec::new();
396        let mut words = Vec::new();
397        for k in 0..240usize {
398            let is_spam = k % 3 == 0;
399            let mut features = [0.0f32; N];
400            features[by_sender] = if is_spam { 0.0 } else { 1.0 };
401            let known = k % 5 != 0;
402            messages.push(Message {
403                label: if is_spam { Label::Spam } else { Label::Ham },
404                date: k as i64,
405                account: "home".into(),
406                place: None,
407                evidence: None,
408                source: (!known).then_some(0),
409                ordinal: 0,
410                unsettled: false,
411                known,
412                features,
413                offset: 0,
414                length: 0,
415                words: 12,
416            });
417            words.push(line(&mut rng, if is_spam { &spam } else { &ham }));
418        }
419        let options = Options { dim: 8, epochs: 5, min_count: 2, bucket: 997, threads: 1, seed: 5, ..Options::default() };
420        let text = |i: usize| text_of(&words[i], &messages[i]);
421        let ids: Vec<usize> = (0..messages.len()).collect();
422        let path = root.join("learn.txt");
423        let model = learn(&path, &ids, &text, &messages, &options, 1, 0.1, &Cancel::new()).unwrap();
424        assert!(!path.exists(), "the learning file is gone");
425        let scalars = row_scalars(&model).unwrap();
426        let table = Table::from_bytes(&fold(&model, &scalars, Platt { a: -1.0, b: 0.0 }, &options, &Split::default()).to_bytes()).unwrap();
427        assert_eq!(table.kind(), sioul_core::spam::table::Kind::Rows);
428        // Each known word: one row of its own and its n-grams', as the phone counts them.
429        let dict = model.dict();
430        for id in 0..dict.nwords() {
431            let word = dict.get_word(id);
432            if word != fasttext::dictionary::EOS {
433                assert_eq!(dict.get_subwords(id).len(), 1 + sioul_core::spam::table::ngram_buckets(word, options.minn, options.maxn, options.bucket).len(), "{word}");
434            }
435        }
436        // fastText's prediction, message by message, a word it never saw too.
437        let mut unseen = words.clone();
438        unseen[1].push("neverseenword".into());
439        assert!(checked_fold(&table, &model, &ids, &unseen, &messages).unwrap() < FOLD_TOLERANCE);
440        // A header fact is a word it learned: DKIM by the sender, toward ham.
441        let weights = header_weights(&table);
442        assert!(weights.get("dkim_signed_by_sender").is_some_and(|w| *w < 0.0), "{weights:?}");
443        let _ = std::fs::remove_dir_all(root);
444    }
445}