use std::collections::HashMap;
use super::ngram::{fnv1a_mix, fnv1a_separate, fnv1a_words, SENTENCE_START};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PersonalNgramOptions {
pub lambda: f64,
pub confidence_k: f64,
pub max_confidence: f64,
pub trigram_discount: f64,
pub max_transitions: usize,
}
impl Default for PersonalNgramOptions {
fn default() -> Self {
Self {
lambda: 0.8,
confidence_k: 8.0,
max_confidence: 0.5,
trigram_discount: 0.75,
max_transitions: 200_000,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PersonalTransition {
pub earlier: String,
pub previous: String,
pub word: String,
pub times: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct PersonalContext {
pub confidence: f64,
pub previous_key: u64,
pub triple_key: u64,
pub context_total: u64,
pub triple_total: u64,
pub followers: u64,
}
#[derive(Debug, Clone, Default)]
struct CountTable(HashMap<u64, u32>);
impl CountTable {
fn get(&self, key: u64) -> u32 {
self.0.get(&key).copied().unwrap_or(0)
}
fn add(&mut self, key: u64, delta: u32) -> u32 {
let count = self.0.entry(key).or_insert(0);
let before = *count;
*count = before.saturating_add(delta);
before
}
fn subtract(&mut self, key: u64, delta: u32) -> u32 {
let Some(count) = self.0.get_mut(&key) else {
return 0;
};
let removed = (*count).min(delta);
*count -= removed;
if *count == 0 {
self.0.remove(&key);
}
removed
}
fn len(&self) -> usize {
self.0.len()
}
#[cfg(test)]
fn clear(&mut self) {
self.0.clear();
}
}
fn key_of(word: &str) -> u64 {
fnv1a_words(&[word])
}
fn key_of_pair(previous: &str, word: &str) -> u64 {
fnv1a_words(&[previous, word])
}
fn key_of_triple(earlier: &str, previous: &str, word: &str) -> u64 {
fnv1a_words(&[earlier, previous, word])
}
fn or_start(word: &str) -> &str {
if word.is_empty() {
SENTENCE_START
} else {
word
}
}
#[derive(Debug, Clone, Default)]
pub struct PersonalNgram {
options: PersonalNgramOptions,
pairs: CountTable,
context_totals: CountTable,
word_counts: CountTable,
triples: CountTable,
triple_totals: CountTable,
triple_followers: CountTable,
total: u64,
}
impl PersonalNgram {
pub fn new(options: PersonalNgramOptions) -> Self {
Self {
options,
..Self::default()
}
}
pub fn options(&self) -> &PersonalNgramOptions {
&self.options
}
pub fn add(&mut self, transition: &PersonalTransition) {
if transition.word.is_empty() || transition.times == 0 {
return;
}
let previous = or_start(&transition.previous);
self.add_pair(previous, &transition.word, transition.times);
if previous != SENTENCE_START {
self.add_triple(
or_start(&transition.earlier),
previous,
&transition.word,
transition.times,
);
}
}
pub fn add_pair(&mut self, previous: &str, word: &str, count: u32) {
if count == 0 {
return;
}
self.pairs.add(key_of_pair(previous, word), count);
self.context_totals.add(key_of(previous), count);
self.word_counts.add(key_of(word), count);
self.total += u64::from(count);
}
pub fn add_triple(&mut self, earlier: &str, previous: &str, word: &str, count: u32) {
if count == 0 {
return;
}
let context = key_of_pair(earlier, previous);
if self
.triples
.add(key_of_triple(earlier, previous, word), count)
== 0
{
self.triple_followers.add(context, 1);
}
self.triple_totals.add(context, count);
}
#[cfg(test)]
pub fn remove(&mut self, transition: &PersonalTransition) {
if transition.word.is_empty() || transition.times == 0 {
return;
}
let previous = or_start(&transition.previous);
self.remove_pair(previous, &transition.word, transition.times);
if previous != SENTENCE_START {
self.remove_triple(
or_start(&transition.earlier),
previous,
&transition.word,
transition.times,
);
}
}
pub fn remove_pair(&mut self, previous: &str, word: &str, count: u32) {
let removed = self.pairs.subtract(key_of_pair(previous, word), count);
if removed == 0 {
return;
}
self.context_totals.subtract(key_of(previous), removed);
self.word_counts.subtract(key_of(word), removed);
self.total -= self.total.min(u64::from(removed));
}
pub fn remove_triple(&mut self, earlier: &str, previous: &str, word: &str, count: u32) {
let context = key_of_pair(earlier, previous);
let key = key_of_triple(earlier, previous, word);
let removed = self.triples.subtract(key, count);
if removed == 0 {
return;
}
if self.triples.get(key) == 0 {
self.triple_followers.subtract(context, 1);
}
self.triple_totals.subtract(context, removed);
}
#[cfg(test)]
pub fn clear(&mut self) {
self.pairs.clear();
self.context_totals.clear();
self.word_counts.clear();
self.triples.clear();
self.triple_totals.clear();
self.triple_followers.clear();
self.total = 0;
}
pub fn is_empty(&self) -> bool {
self.total == 0
}
pub fn word_count(&self, word: &str) -> u32 {
if word.is_empty() {
return 0;
}
self.word_counts.get(key_of(word))
}
#[cfg(test)]
pub fn total(&self) -> u64 {
self.total
}
pub fn entries(&self) -> usize {
self.pairs.len() + self.triples.len()
}
pub fn context(&self, earlier: Option<&str>, previous: Option<&str>) -> PersonalContext {
let mut result = PersonalContext::default();
if self.total == 0 {
return result;
}
let previous = previous.unwrap_or(SENTENCE_START);
let previous_hash = key_of(previous);
result.context_total = u64::from(self.context_totals.get(previous_hash));
if result.context_total == 0 {
return result;
}
let seen = result.context_total as f64;
result.confidence =
(seen / (seen + self.options.confidence_k)).min(self.options.max_confidence);
result.previous_key = fnv1a_separate(previous_hash);
if previous != SENTENCE_START {
let triple_context = key_of_pair(earlier.unwrap_or(SENTENCE_START), previous);
result.triple_total = u64::from(self.triple_totals.get(triple_context));
if result.triple_total > 0 {
result.followers = u64::from(self.triple_followers.get(triple_context));
result.triple_key = fnv1a_separate(triple_context);
}
}
result
}
pub fn probability(&self, context: &PersonalContext, word: &str) -> f64 {
if context.context_total == 0 || word.is_empty() {
return 0.0;
}
let bigram = self.bigram_estimate(
context.previous_key,
context.context_total,
key_of(word),
word,
);
if context.triple_total == 0 {
return bigram;
}
let total = context.triple_total as f64;
let seen = f64::from(self.triples.get(fnv1a_mix(context.triple_key, word)));
let discount = self.options.trigram_discount;
(seen - discount).max(0.0) / total + discount * context.followers as f64 / total * bigram
}
#[cfg(test)]
pub fn confidence(&self, previous: &str) -> f64 {
self.context(None, Some(previous)).confidence
}
#[cfg(test)]
pub fn bigram_probability(&self, previous: &str, word: &str) -> f64 {
let previous_hash = key_of(previous);
self.bigram_estimate(
fnv1a_separate(previous_hash),
u64::from(self.context_totals.get(previous_hash)),
key_of(word),
word,
)
}
#[cfg(test)]
pub fn trigram_probability(&self, earlier: &str, previous: &str, word: &str) -> f64 {
self.probability(&self.context(Some(earlier), Some(previous)), word)
}
fn bigram_estimate(
&self,
pair_state: u64,
context_total: u64,
word_key: u64,
word: &str,
) -> f64 {
let mut probability = 0.0;
if context_total > 0 {
probability += self.options.lambda
* f64::from(self.pairs.get(fnv1a_mix(pair_state, word)))
/ context_total as f64;
}
if self.total > 0 {
probability += (1.0 - self.options.lambda) * f64::from(self.word_counts.get(word_key))
/ self.total as f64;
}
probability
}
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
fn near(left: f64, right: f64) -> bool {
(left - right).abs() < 1e-9
}
pub(crate) fn transition(
earlier: &str,
previous: &str,
word: &str,
times: u32,
) -> PersonalTransition {
PersonalTransition {
earlier: earlier.to_owned(),
previous: previous.to_owned(),
word: word.to_owned(),
times,
}
}
pub(crate) fn record_run(model: &mut PersonalNgram, words: &[&str], times: u32) {
let mut earlier = "";
let mut previous = "";
for word in words {
model.add(&transition(earlier, previous, word, times));
earlier = previous;
previous = word;
}
}
#[test]
fn model_math() {
let mut model = PersonalNgram::default();
assert!(model.is_empty() && model.confidence(SENTENCE_START) == 0.0);
record_run(&mut model, &["我", "想"], 2);
assert_eq!(model.total(), 4);
assert_eq!(model.entries(), 3);
assert!(near(model.confidence("我"), 0.2));
assert!(near(model.bigram_probability("我", "想"), 0.9));
assert!(near(
model.trigram_probability(SENTENCE_START, "我", "想"),
0.625 + 0.375 * 0.9
));
assert!(near(model.trigram_probability("别", "我", "想"), 0.9));
assert!(near(
model.trigram_probability(SENTENCE_START, "我", "他"),
0.375 * model.bigram_probability("我", "他")
));
let mut capped = PersonalNgram::default();
for _ in 0..100 {
capped.add(&transition("", "甲", "乙", 1));
}
assert!(
near(capped.confidence("甲"), 0.5),
"confidence is capped at max_confidence"
);
let mut opening = PersonalNgram::default();
opening.add(&transition("", "", "甲", 3));
assert_eq!(opening.entries(), 1);
assert!(near(
opening.trigram_probability("任意", SENTENCE_START, "甲"),
opening.bigram_probability(SENTENCE_START, "甲")
));
}
#[test]
fn key_domains_are_separate() {
let mut model = PersonalNgram::default();
model.add_pair("甲", "乙", 1);
assert_eq!(model.confidence("乙"), 0.0);
assert_eq!(model.bigram_probability("丙", "甲"), 0.0);
let mut pairs = PersonalNgram::default();
pairs.add_pair(SENTENCE_START, "甲", 1);
pairs.add_pair("甲", "乙", 5);
assert_eq!(
pairs.context(Some(SENTENCE_START), Some("乙")).triple_total,
0
);
pairs.add_triple("甲", "乙", "丙", 1);
pairs.add_pair("乙", "丙", 1);
assert!(near(
pairs.bigram_probability("甲", "乙"),
0.8 + 0.2 * 5.0 / 7.0
));
}
#[test]
fn empty_context_knows_nothing() {
let mut model = PersonalNgram::default();
assert_eq!(model.context(None, None), PersonalContext::default());
record_run(&mut model, &["我", "想"], 1);
let unseen = model.context(None, Some("他"));
assert_eq!(unseen.confidence, 0.0);
assert_eq!(model.probability(&unseen, "想"), 0.0);
let start = model.context(None, None);
assert!(start.confidence > 0.0);
assert_eq!(
start.triple_total, 0,
"the chain start has no trigram context"
);
assert_eq!(model.probability(&start, ""), 0.0);
}
#[test]
fn remove_takes_back_add() {
let mut model = PersonalNgram::default();
record_run(&mut model, &["我", "想", "去"], 2);
let entries = model.entries();
model.add(&transition("我", "想", "区", 1));
assert_eq!(model.context(Some("我"), Some("想")).followers, 2);
model.remove(&transition("我", "想", "区", 1));
assert_eq!(model.entries(), entries);
assert_eq!(model.context(Some("我"), Some("想")).followers, 1);
assert_eq!(model.total(), 6);
model.remove(&transition("我", "想", "去", 5));
assert_eq!(model.total(), 4);
assert_eq!(model.context(Some("我"), Some("想")).triple_total, 0);
assert_eq!(model.bigram_probability("想", "去"), 0.0);
model.clear();
assert!(model.is_empty());
assert_eq!(model.entries(), 0);
}
#[test]
fn counts_saturate() {
let mut model = PersonalNgram::default();
model.add_pair("甲", "乙", u32::MAX);
model.add_pair("甲", "乙", 5);
assert_eq!(
model.context(None, Some("甲")).context_total,
u64::from(u32::MAX)
);
assert_eq!(model.total(), u64::from(u32::MAX) + 5);
}
}