#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct Tf(pub u32);
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct Df(pub u32);
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DocLen(pub u32);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Bm25Error {
InvalidK1,
InvalidB,
EmptyCorpus,
NoTokens,
}
impl std::fmt::Display for Bm25Error {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = match self {
Self::InvalidK1 => "bm25 k1 must be a finite value at or above zero",
Self::InvalidB => "bm25 b must be a finite value in 0.0..=1.0",
Self::EmptyCorpus => "corpus statistics require at least one document",
Self::NoTokens => "corpus statistics require at least one analyzed token",
};
formatter.write_str(message)
}
}
impl std::error::Error for Bm25Error {}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Bm25Params {
pub k1: f64,
pub b: f64,
}
impl Bm25Params {
#[must_use]
pub const fn beir() -> Self {
Self { k1: 1.2, b: 0.75 }
}
#[must_use]
pub const fn anserini() -> Self {
Self { k1: 0.9, b: 0.4 }
}
pub fn new(k1: f64, b: f64) -> Result<Self, Bm25Error> {
if !k1.is_finite() || k1 < 0.0 {
return Err(Bm25Error::InvalidK1);
}
if !b.is_finite() || !(0.0..=1.0).contains(&b) {
return Err(Bm25Error::InvalidB);
}
Ok(Self { k1, b })
}
}
impl Default for Bm25Params {
fn default() -> Self {
Self::beir()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CorpusStats {
doc_count: u64,
total_tokens: u64,
}
impl CorpusStats {
pub const fn new(doc_count: u64, total_tokens: u64) -> Result<Self, Bm25Error> {
if doc_count == 0 {
return Err(Bm25Error::EmptyCorpus);
}
if total_tokens == 0 {
return Err(Bm25Error::NoTokens);
}
Ok(Self {
doc_count,
total_tokens,
})
}
#[must_use]
pub const fn document_count(&self) -> u64 {
self.doc_count
}
#[must_use]
pub const fn total_tokens(&self) -> u64 {
self.total_tokens
}
#[must_use]
pub fn average_document_length(&self) -> f64 {
self.total_tokens as f64 / self.doc_count as f64
}
}
#[must_use]
pub fn idf(df: Df, doc_count: u64) -> f64 {
let n = doc_count as f64;
let df = f64::from(df.0).min(n);
(1.0 + (n - df + 0.5) / (df + 0.5)).ln()
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TermScorer {
idf: f64,
average_document_length: f64,
params: Bm25Params,
}
impl TermScorer {
#[must_use]
pub fn new(df: Df, stats: &CorpusStats, params: Bm25Params) -> Self {
#[cfg(any(test, feature = "test-seams"))]
super::preparation_observer::scorer();
Self {
idf: idf(df, stats.doc_count),
average_document_length: stats.average_document_length(),
params,
}
}
#[must_use]
pub const fn idf(&self) -> f64 {
self.idf
}
#[must_use]
pub fn score(&self, tf: Tf, length: DocLen) -> f64 {
if tf.0 == 0 {
return 0.0;
}
let frequency = f64::from(tf.0);
let normalization = 1.0 - self.params.b
+ self.params.b * f64::from(length.0) / self.average_document_length;
let denominator = frequency + self.params.k1 * normalization;
if denominator <= 0.0 {
return 0.0;
}
self.idf * (frequency * (self.params.k1 + 1.0)) / denominator
}
#[must_use]
pub fn ceiling(&self) -> f64 {
self.idf * (self.params.k1 + 1.0)
}
}
#[must_use]
pub fn term_score(tf: Tf, df: Df, length: DocLen, stats: &CorpusStats, params: Bm25Params) -> f64 {
TermScorer::new(df, stats, params).score(tf, length)
}
#[must_use]
pub fn term_score_ceiling(df: Df, stats: &CorpusStats, params: Bm25Params) -> f64 {
TermScorer::new(df, stats, params).ceiling()
}
#[cfg(test)]
#[allow(
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
clippy::unwrap_used
)]
mod tests {
use super::*;
#[test]
fn newtypes_prevent_argument_transposition() {
let stats = CorpusStats::new(4, 20).expect("valid stats");
let score = term_score(Tf(3), Df(2), DocLen(5), &stats, Bm25Params::default());
assert!(score > 0.0);
}
#[test]
fn the_ceiling_bounds_every_reachable_score() {
let stats = CorpusStats::new(1_000, 50_000).expect("valid stats");
let params = Bm25Params::default();
for df in [1_u32, 2, 17, 500, 1_000] {
let ceiling = term_score_ceiling(Df(df), &stats, params);
for tf in [1_u32, 3, 40, 10_000, u32::MAX] {
for length in [1_u32, 5, 50, 5_000, u32::MAX] {
let score = term_score(Tf(tf), Df(df), DocLen(length), &stats, params);
assert!(
score <= ceiling,
"score {score} exceeded ceiling {ceiling} at df={df} tf={tf} len={length}"
);
}
}
}
}
#[test]
fn scores_are_never_negative() {
let stats = CorpusStats::new(10, 100).expect("valid stats");
for params in [Bm25Params::beir(), Bm25Params::anserini()] {
for df in 1..=10_u32 {
for tf in [0_u32, 1, 9] {
for length in [1_u32, 10, 1_000] {
let score = term_score(Tf(tf), Df(df), DocLen(length), &stats, params);
assert!(score >= 0.0, "negative score {score}");
assert!(score.is_finite(), "non-finite score {score}");
}
}
}
}
}
#[test]
fn df_above_the_document_count_is_clamped_rather_than_producing_a_negative_idf() {
let value = idf(Df(20), 10);
assert!(value >= 0.0, "clamping failed: {value}");
assert!((value - idf(Df(10), 10)).abs() < 1e-12);
}
#[test]
fn average_document_length_uses_analyzed_tokens() {
let stats = CorpusStats::new(3, 30).expect("valid stats");
assert!((stats.average_document_length() - 10.0).abs() < 1e-12);
assert_eq!(stats.document_count(), 3);
assert_eq!(stats.total_tokens(), 30);
}
#[test]
fn errors_render_useful_text() {
assert!(Bm25Error::InvalidK1.to_string().contains("k1"));
assert!(Bm25Error::InvalidB.to_string().contains("b"));
assert!(Bm25Error::EmptyCorpus.to_string().contains("document"));
assert!(Bm25Error::NoTokens.to_string().contains("token"));
}
fn hoisting_grid() -> Vec<(CorpusStats, Bm25Params, Tf, Df, DocLen)> {
let mut grid = Vec::new();
for (documents, tokens) in [(1_000_u64, 40_000_u64), (7, 13), (171_332, 9_876_543)] {
let stats = CorpusStats::new(documents, tokens).expect("valid stats");
for params in [
Bm25Params::default(),
Bm25Params::anserini(),
Bm25Params::new(0.0, 0.0).expect("valid parameters"),
Bm25Params::new(3.0, 1.0).expect("valid parameters"),
] {
for tf in [0_u32, 1, 3, 97, 65_535] {
for df in [1_u32, 2, 499, 170_000] {
for length in [1_u32, 4, 250, 100_000] {
grid.push((stats, params, Tf(tf), Df(df), DocLen(length)));
}
}
}
}
}
grid
}
#[test]
fn the_hoisted_scorer_reproduces_the_documented_expression_bit_for_bit() {
for (stats, params, tf, df, length) in hoisting_grid() {
let scorer = TermScorer::new(df, &stats, params);
let reference = if tf.0 == 0 {
0.0_f64
} else {
let frequency = f64::from(tf.0);
let normalization = 1.0 - params.b
+ params.b * f64::from(length.0) / stats.average_document_length();
let denominator = frequency + params.k1 * normalization;
if denominator <= 0.0 {
0.0
} else {
idf(df, stats.document_count()) * (frequency * (params.k1 + 1.0)) / denominator
}
};
assert_eq!(
scorer.score(tf, length).to_bits(),
reference.to_bits(),
"hoisted score diverged at tf={} df={} len={}",
tf.0,
df.0,
length.0
);
assert_eq!(
term_score(tf, df, length, &stats, params).to_bits(),
reference.to_bits(),
"the convenience form diverged from the hoisted one"
);
assert_eq!(
term_score_ceiling(df, &stats, params).to_bits(),
scorer.ceiling().to_bits()
);
}
}
#[test]
fn pre_multiplying_the_idf_factor_would_shift_bits() {
let mut divergences = 0_usize;
for (stats, params, tf, df, length) in hoisting_grid() {
if tf.0 == 0 {
continue;
}
let scorer = TermScorer::new(df, &stats, params);
let frequency = f64::from(tf.0);
let normalization =
1.0 - params.b + params.b * f64::from(length.0) / stats.average_document_length();
let denominator = frequency + params.k1 * normalization;
if denominator <= 0.0 {
continue;
}
let premultiplied = (scorer.idf() * (params.k1 + 1.0)) * frequency / denominator;
if premultiplied.to_bits() != scorer.score(tf, length).to_bits() {
divergences += 1;
}
}
assert!(
divergences > 0,
"pre-multiplication was expected to shift at least one score"
);
}
}