Spam filter
A spam filter that learns from your own messages: naive Bayes trained on what users mark, kept in a file, scoring comments and forms from 0 to 1.
Version 1.0.0 · 5 functions · 0 crates
Functions
What use spam_filter.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use spam_train, spam_untrain from spam_filter (Quillo 0.6).
spam_train(model_path: str, text: str, is_spam: bool) → anyor an errorLearns from one message: is_spam true for spam, false for a good one ("ham"). The model is kept in the JSON file model_path (made if missing). Gives { spam, ham }, the messages learnt.
spam_untrain(model_path: str, text: str, was_spam: bool) → anyor an errorTakes back a message learnt with the wrong label (call it before spam_train with the right one). Gives { spam, ham }.
spam_score(model_path: str, text: str) → floator an errorHow likely a message is spam, from 0 to 1 (0.5 until the model has learnt at least one spam and one good message).
spam_check(model_path: str, text: str, threshold: float) → anyor an errorThe verdict for a message: { spam, score, words }, spam when the score is at least threshold (0.9 is careful), words the five that weighed most (positive: spammy).
spam_stats(model_path: str) → anyor an errorWhat the model knows: { spam, ham, words }, the messages of each kind it learnt and the distinct words seen.
Get it
$ quillo install spam_filter # spam_filter.qwer into this folder$ quillo install spam_filter --example # and its example, spam_filter.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/spam_filter.qwer$ curl -O https://quillolang.org/plugins/files/spam_filter.qweThen, in your program, all of it or only what you call:
use spam_filter.qweruse spam_train, spam_untrain from spam_filter // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
Example
// Comments on a blog, held back when they look like spam; the "spam" and "not spam" buttons// of the moderation page teach the filter.//// quillo run spam_filter.qwe then open http://localhost:8080/comment?text=Great%20post,%20thanksuse "spam_filter.qwer"let MODEL = "data/spam-model.json"// A first lesson, the first time the program runs.if spam_stats(MODEL)?.ham == 0 { for t in ["Thanks for the clear write-up, the part on caching helped", "I tried this on my project and it worked, merci", "Could you explain how the router picks a route?", "Nice post. A typo in the second paragraph: 'teh'"] { spam_train(MODEL, t, false)? } for t in ["CHEAP PILLS ONLINE best price click https://pills.example now", "Earn $5000 a week from home, click here https://money.example", "Best casino bonus, free spins, click https://casino.example", "Buy followers cheap, best price, limited offer"] { spam_train(MODEL, t, true)? }}route GET /comment ? { text: str } { let v = spam_check(MODEL, text, 0.9)? { held: v.spam, score: v.score, because: v.words | map((w) => w.word) }}route GET /mark ? { text: str, spam: bool } = spam_train(MODEL, text, spam)?$ quillo run spam_filter.qwe$ curl 'localhost:8080/comment?text=Click%20here%20for%20the%20best%20price%20https://pills.example'$ curl 'localhost:8080/comment?text=Thanks,%20the%20caching%20part%20helped%20on%20my%20project'Source
//! A spam filter that learns from your own messages: naive Bayes trained on what your users mark as spam or not, kept in a file, scoring comments, contact forms and sign-ups from 0 to 1.//! version: 1.0.0//!//! [dependencies]use serde_json::{json, Value};use std::collections::{HashMap, HashSet};use std::sync::{Mutex, OnceLock};const MAX_TEXT: usize = 200_000;const MAX_WORDS: usize = 500_000;#[derive(Default, Clone)]struct Model { spam: i64, ham: i64, words: HashMap<String, (i64, i64)>,}fn models() -> std::sync::MutexGuard<'static, HashMap<String, Model>> { static M: OnceLock<Mutex<HashMap<String, Model>>> = OnceLock::new(); M.get_or_init(|| Mutex::new(HashMap::new())).lock().unwrap_or_else(|e| e.into_inner())}fn load(path: &str) -> Result<Model, String> { let Ok(text) = std::fs::read_to_string(path) else { return Ok(Model::default()) }; let v: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {}", path, e))?; let mut m = Model { spam: v["spam"].as_i64().unwrap_or(0), ham: v["ham"].as_i64().unwrap_or(0), words: HashMap::new() }; if let Some(ws) = v["words"].as_object() { for (w, c) in ws { m.words.insert(w.clone(), (c[0].as_i64().unwrap_or(0), c[1].as_i64().unwrap_or(0))); } } Ok(m)}fn save(path: &str, m: &Model) -> Result<(), String> { if let Some(dir) = std::path::Path::new(path).parent() { if !dir.as_os_str().is_empty() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; } } let words: serde_json::Map<String, Value> = m.words.iter().map(|(w, (s, h))| (w.clone(), json!([s, h]))).collect(); let tmp = format!("{}.tmp", path); std::fs::write(&tmp, json!({ "spam": m.spam, "ham": m.ham, "words": words }).to_string()).map_err(|e| e.to_string())?; std::fs::rename(&tmp, path).map_err(|e| e.to_string())}/// The distinct words of a message, lower case, with links and amounts kept as signals of their own.fn tokens(text: &str) -> HashSet<String> { let text: String = text.chars().take(MAX_TEXT).collect(); let mut out = HashSet::new(); for raw in text.split_whitespace() { let low = raw.to_lowercase(); if low.starts_with("http://") || low.starts_with("https://") || low.starts_with("www.") { out.insert("__link__".to_string()); if let Some(host) = low.split("//").nth(1).unwrap_or(&low).split('/').next() { out.insert(format!("__host:{}", host.trim_start_matches("www."))); } continue; } if low.contains('$') || low.contains('€') || low.contains('£') { out.insert("__money__".to_string()); } for w in low.split(|c: char| !c.is_alphanumeric() && c != '\'') { let w = w.trim_matches('\''); let n = w.chars().count(); if (2..=30).contains(&n) && !w.chars().all(|c| c.is_ascii_digit()) { out.insert(w.to_string()); } } } if text.chars().filter(|c| c.is_alphabetic()).count() > 20 && text.chars().filter(|c| c.is_uppercase()).count() * 2 > text.chars().filter(|c| c.is_alphabetic()).count() { out.insert("__shouting__".to_string()); } out}fn with_model<T>(path: &str, f: impl FnOnce(&mut Model) -> Result<T, String>) -> Result<T, String> { let mut ms = models(); if !ms.contains_key(path) { let m = load(path)?; ms.insert(path.to_string(), m); } f(ms.get_mut(path).expect("just inserted"))}/// The log-odds of each word, and the message's probability of being spam.fn score(m: &Model, text: &str) -> (f64, Vec<(String, f64)>) { if m.spam == 0 || m.ham == 0 { return (0.5, vec![]); } let (s, h) = (m.spam as f64, m.ham as f64); let mut logit = (s / h).ln(); let mut why = Vec::new(); for t in tokens(text) { let (ws, wh) = m.words.get(&t).copied().unwrap_or((0, 0)); if ws + wh == 0 { continue; } // Bernoulli naive Bayes with add-one smoothing: does the word appear in spam / in ham messages? let ps = (ws as f64 + 1.0) / (s + 2.0); let ph = (wh as f64 + 1.0) / (h + 2.0); let w = (ps / ph).ln(); logit += w; why.push((t, w)); } why.sort_by(|a, b| b.1.abs().partial_cmp(&a.1.abs()).unwrap_or(std::cmp::Ordering::Equal)); (1.0 / (1.0 + (-logit.clamp(-700.0, 700.0)).exp()), why)}/// Learns from one message: `is_spam` true for spam, false for a good one ("ham"). The model is kept in the JSON file `model_path` (made if missing). Gives `{ spam, ham }`, the messages learnt.pub fn spam_train(model_path: &str, text: &str, is_spam: bool) -> Result<Value, String> { with_model(model_path, |m| { for t in tokens(text) { if m.words.len() >= MAX_WORDS && !m.words.contains_key(&t) { continue; } let e = m.words.entry(t).or_insert((0, 0)); if is_spam { e.0 += 1 } else { e.1 += 1 } } if is_spam { m.spam += 1 } else { m.ham += 1 } save(model_path, m)?; Ok(json!({ "spam": m.spam, "ham": m.ham })) })}/// Takes back a message learnt with the wrong label (call it before `spam_train` with the right one). Gives `{ spam, ham }`.pub fn spam_untrain(model_path: &str, text: &str, was_spam: bool) -> Result<Value, String> { with_model(model_path, |m| { for t in tokens(text) { if let Some(e) = m.words.get_mut(&t) { if was_spam { e.0 = (e.0 - 1).max(0) } else { e.1 = (e.1 - 1).max(0) } if *e == (0, 0) { m.words.remove(&t); } } } if was_spam { m.spam = (m.spam - 1).max(0) } else { m.ham = (m.ham - 1).max(0) } save(model_path, m)?; Ok(json!({ "spam": m.spam, "ham": m.ham })) })}/// How likely a message is spam, from 0 to 1 (0.5 until the model has learnt at least one spam and one good message).pub fn spam_score(model_path: &str, text: &str) -> Result<f64, String> { with_model(model_path, |m| Ok((score(m, text).0 * 1e4).round() / 1e4))}/// The verdict for a message: `{ spam, score, words }`, `spam` when the score is at least `threshold` (0.9 is careful), `words` the five that weighed most (positive: spammy).pub fn spam_check(model_path: &str, text: &str, threshold: f64) -> Result<Value, String> { with_model(model_path, |m| { let (p, why) = score(m, text); let words: Vec<Value> = why.iter().take(5).map(|(w, x)| json!({ "word": w, "weight": (x * 100.0).round() / 100.0 })).collect(); Ok(json!({ "spam": p >= threshold, "score": (p * 1e4).round() / 1e4, "words": words })) })}/// What the model knows: `{ spam, ham, words }`, the messages of each kind it learnt and the distinct words seen.pub fn spam_stats(model_path: &str) -> Result<Value, String> { with_model(model_path, |m| Ok(json!({ "spam": m.spam, "ham": m.ham, "words": m.words.len() })))}Related
profanity.qwer · 1.0.0Offensive words found and masked in comments, usernames and chat, with l33t spellings and spaced-out letters caught, and a severity per kind for moderation queues.8 views · 8 downloadsCase case.qwer · 1.0.0Text in snake_case, camelCase, PascalCase, kebab-case, CONSTANT_CASE, Title Case and Sentence case: column names as API keys and labels.7 views · 8 downloadsHumanize humanize.qwer · 1.0.0Durations, file sizes and relative times as people write them and back: 2h 30m, 1.5 GB, 2 GiB, 3 days ago, in 2 hours.7 views · 11 downloadsInflection inflect.qwer · 1.0.0English words in the right form for counts and labels: plurals and singulars, 3 items, ordinals such as 1st and 22nd, and a or an by how a word sounds.6 views · 7 downloadsJapanese kana kana.qwer · 1.0.0Japanese kana: romaji typed as hiragana or katakana, kana written in Hepburn romaji, hiragana and katakana each to the other, half-width katakana from bank and till systems made full-width, and which scripts a text uses.6 views · 10 downloadsSpellcheck spellcheck.qwer · 1.0.0Spelling checked against a word list you load, in any language: unknown words found with their places, suggestions ranked by closeness and frequency (SymSpell's method), text corrected for "did you mean", and your own words added.7 views · 8 downloadsThread 0 posts
On Spam filter: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.