1use 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
47const LABEL: &str = "SIOUL_LABEL_";
50const SPAM_LABEL: &str = "SIOUL_LABEL_SPAM";
51const HAM_LABEL: &str = "SIOUL_LABEL_HAM";
52
53const FOLD_TOLERANCE: f64 = 1e-4;
55
56fn 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
69struct LearnFile(PathBuf);
71
72impl Drop for LearnFile {
73 fn drop(&mut self) {
74 let _ = std::fs::remove_file(&self.0);
75 }
76}
77
78fn 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#[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
121fn 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
132fn 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#[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
170fn 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
211fn 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
228fn 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#[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 let exact = word_ngrams <= 1;
253
254 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 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 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 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.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 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 #[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 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 let mut unseen = words.clone();
438 unseen[1].push("neverseenword".into());
439 assert!(checked_fold(&table, &model, &ids, &unseen, &messages).unwrap() < FOLD_TOLERANCE);
440 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}