stats_claw/algorithms/association/mod.rs
1//! Market-basket association-rule mining: Apriori frequent-itemset generation and
2//! the support / confidence / lift (plus leverage / conviction) rule metrics.
3//!
4//! Transactions are given as a one-hot boolean matrix `transactions`, one inner
5//! `Vec<bool>` per **transaction** (row), with `transactions[t][i] == true` meaning
6//! item `i` is present in transaction `t`; every row must share the same item
7//! count (the number of columns). This is exactly the boolean-DataFrame shape
8//! `mlxtend.frequent_patterns.apriori` consumes, so the numerics reproduce its
9//! conventions the equivalence suite checks:
10//!
11//! * **Apriori** enumerates every itemset whose **support** — the fraction of
12//! transactions containing all its items — is `≥ min_support`, growing
13//! `k`-itemsets from frequent `(k − 1)`-itemsets via the downward-closure
14//! pruning that names the algorithm, matching `apriori(df, min_support)`;
15//! * **association rules** split each frequent itemset of size `≥ 2` into every
16//! non-empty antecedent / consequent partition and report `support`,
17//! `confidence`, `lift`, `leverage`, and `conviction`, matching
18//! `association_rules(frequent, metric, min_threshold)`.
19//!
20//! Supports, confidences, and lifts are exact rational ratios of integer
21//! transaction counts, so the results agree with mlxtend to machine precision.
22//! Output is deterministic: frequent itemsets sort by ascending size then by
23//! ascending item indices, and rules sort by `(antecedent, consequent)` lexically.
24//!
25//! This base block backs the `AssociationAnalysis` computational method.
26
27mod rules;
28mod support;
29
30pub use rules::association_rules;
31
32use std::collections::BTreeSet;
33use support::count_to_f64;
34
35/// Errors that prevent association mining over a transaction matrix.
36///
37/// Returned (never panicked) so callers stay clear of the crate's `unwrap`/`panic`
38/// lint gate and can surface a clean diagnostic.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum AssociationError {
41 /// The transaction matrix had no rows, so no support can be measured.
42 EmptyInput,
43 /// The matrix had rows but no item columns, so there is nothing to mine.
44 NoItems,
45 /// The rows had differing item counts (a ragged matrix), so columns are
46 /// undefined.
47 RaggedRows,
48 /// The minimum support was not in the open-closed range `(0, 1]`, so the
49 /// frequent-set threshold would be ill-defined.
50 InvalidSupport,
51 /// The minimum metric threshold was not finite, so the rule filter would be
52 /// ill-defined.
53 InvalidThreshold,
54}
55
56impl std::fmt::Display for AssociationError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 Self::EmptyInput => write!(f, "transaction matrix has no rows"),
60 Self::NoItems => write!(f, "transaction matrix has no item columns"),
61 Self::RaggedRows => write!(f, "transaction matrix rows have differing lengths"),
62 Self::InvalidSupport => write!(f, "minimum support must lie in (0, 1]"),
63 Self::InvalidThreshold => write!(f, "minimum metric threshold must be finite"),
64 }
65 }
66}
67
68impl std::error::Error for AssociationError {}
69
70/// A frequent itemset and its support over the transaction matrix.
71///
72/// `items` is the set of item (column) indices in ascending order; `support` is the
73/// fraction of transactions that contain every item in the set, in `[0, 1]`.
74#[derive(Debug, Clone, PartialEq)]
75pub struct FrequentItemset {
76 /// The item (column) indices forming the set, in ascending order.
77 items: Vec<usize>,
78 /// The support: fraction of transactions containing all of `items`, in `[0, 1]`.
79 support: f64,
80}
81
82impl FrequentItemset {
83 /// The item indices forming this set, in ascending order.
84 #[must_use]
85 pub fn items(&self) -> &[usize] {
86 &self.items
87 }
88
89 /// The support of this set (fraction of transactions containing every item).
90 #[must_use]
91 pub const fn support(&self) -> f64 {
92 self.support
93 }
94}
95
96/// An association rule `antecedent ⇒ consequent` with its standard metrics.
97///
98/// Both sides are disjoint, non-empty sets of item indices in ascending order. The
99/// metrics follow the standard market-basket definitions (and mlxtend's):
100/// `support` is the joint support of `antecedent ∪ consequent`; `confidence` is
101/// `support(A ∪ C) / support(A)`; `lift` is `confidence / support(C)`; `leverage`
102/// is `support(A ∪ C) − support(A)·support(C)`; `conviction` is
103/// `(1 − support(C)) / (1 − confidence)` (`+∞` for a perfectly-confident rule).
104#[derive(Debug, Clone, PartialEq)]
105pub struct AssociationRule {
106 /// The antecedent ("if") item indices, ascending and disjoint from `consequent`.
107 antecedent: Vec<usize>,
108 /// The consequent ("then") item indices, ascending and disjoint from `antecedent`.
109 consequent: Vec<usize>,
110 /// Joint support of `antecedent ∪ consequent`, in `[0, 1]`.
111 support: f64,
112 /// `support(A ∪ C) / support(A)`, in `[0, 1]`.
113 confidence: f64,
114 /// `confidence / support(C)`; `> 1` indicates positive association.
115 lift: f64,
116 /// `support(A ∪ C) − support(A)·support(C)`, in `[-0.25, 0.25]`.
117 leverage: f64,
118 /// `(1 − support(C)) / (1 − confidence)`; `+∞` when `confidence == 1`.
119 conviction: f64,
120}
121
122impl AssociationRule {
123 /// The antecedent item indices, in ascending order.
124 #[must_use]
125 pub fn antecedent(&self) -> &[usize] {
126 &self.antecedent
127 }
128
129 /// The consequent item indices, in ascending order.
130 #[must_use]
131 pub fn consequent(&self) -> &[usize] {
132 &self.consequent
133 }
134
135 /// The joint support of the rule's item union, in `[0, 1]`.
136 #[must_use]
137 pub const fn support(&self) -> f64 {
138 self.support
139 }
140
141 /// The confidence `support(A ∪ C) / support(A)`, in `[0, 1]`.
142 #[must_use]
143 pub const fn confidence(&self) -> f64 {
144 self.confidence
145 }
146
147 /// The lift `confidence / support(C)`.
148 #[must_use]
149 pub const fn lift(&self) -> f64 {
150 self.lift
151 }
152
153 /// The leverage `support(A ∪ C) − support(A)·support(C)`.
154 #[must_use]
155 pub const fn leverage(&self) -> f64 {
156 self.leverage
157 }
158
159 /// The conviction `(1 − support(C)) / (1 − confidence)` (`+∞` if `confidence == 1`).
160 #[must_use]
161 pub const fn conviction(&self) -> f64 {
162 self.conviction
163 }
164}
165
166/// The metric `association_rules` filters generated rules by.
167///
168/// Mirrors mlxtend's `metric` argument: a rule is emitted only when the chosen
169/// metric is `≥ min_threshold`.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum RuleMetric {
172 /// Filter on the rule's confidence.
173 Confidence,
174 /// Filter on the rule's lift.
175 Lift,
176 /// Filter on the rule's joint support.
177 Support,
178 /// Filter on the rule's leverage.
179 Leverage,
180 /// Filter on the rule's conviction.
181 Conviction,
182}
183
184/// Validates a transaction matrix is non-empty, rectangular, returning the
185/// `(n_transactions, n_items)` shape.
186///
187/// Shared front-door check so both the miner and the rule generator reject the same
188/// degenerate inputs before any counting runs.
189///
190/// # Arguments
191///
192/// * `transactions` — the one-hot boolean matrix, one inner slice per transaction.
193///
194/// # Returns
195///
196/// `(n_transactions, n_items)` on success.
197///
198/// # Errors
199///
200/// Returns [`AssociationError::EmptyInput`], [`AssociationError::NoItems`], or
201/// [`AssociationError::RaggedRows`].
202fn validate_matrix(transactions: &[Vec<bool>]) -> Result<(usize, usize), AssociationError> {
203 let n_transactions = transactions.len();
204 if n_transactions == 0 {
205 return Err(AssociationError::EmptyInput);
206 }
207 let n_items = transactions.first().map_or(0, Vec::len);
208 if n_items == 0 {
209 return Err(AssociationError::NoItems);
210 }
211 if transactions.iter().any(|row| row.len() != n_items) {
212 return Err(AssociationError::RaggedRows);
213 }
214 Ok((n_transactions, n_items))
215}
216
217/// Counts how many transactions contain every item in `items`.
218///
219/// A transaction contains the itemset when each listed column is `true`; `items`
220/// must hold valid column indices (the miner only ever builds them from `0..n_items`).
221///
222/// # Arguments
223///
224/// * `transactions` — the validated boolean matrix.
225/// * `items` — the item (column) indices to test for joint presence.
226///
227/// # Returns
228///
229/// The number of transactions containing all of `items`.
230fn cover_count(transactions: &[Vec<bool>], items: &[usize]) -> usize {
231 transactions
232 .iter()
233 .filter(|row| items.iter().all(|&i| row.get(i).copied().unwrap_or(false)))
234 .count()
235}
236
237/// Enumerates every frequent itemset (support `≥ min_support`) via Apriori.
238///
239/// Starts from the frequent single items, then repeatedly joins frequent
240/// `(k − 1)`-itemsets that share their first `k − 2` items into candidate
241/// `k`-itemsets, pruning any candidate with an infrequent `(k − 1)`-subset
242/// (the downward-closure property), and keeps those candidates whose support
243/// clears the threshold. This reproduces `mlxtend.frequent_patterns.apriori`'s
244/// output set. Itemsets are returned sorted by ascending size, then by ascending
245/// item indices, for deterministic downstream rule generation.
246///
247/// # Arguments
248///
249/// * `transactions` — the one-hot boolean matrix, one inner `Vec<bool>` per
250/// transaction; must be non-empty and rectangular.
251/// * `min_support` — the minimum support an itemset must reach to be frequent; must
252/// lie in `(0, 1]`.
253///
254/// # Returns
255///
256/// Every frequent itemset with its support, deterministically ordered.
257///
258/// # Errors
259///
260/// Returns [`AssociationError::EmptyInput`], [`AssociationError::NoItems`],
261/// [`AssociationError::RaggedRows`], or [`AssociationError::InvalidSupport`].
262///
263/// # Examples
264///
265/// ```
266/// use stats_claw::algorithms::association::apriori;
267///
268/// // Items 0 and 1 co-occur in 3 of 4 transactions (support 0.75).
269/// let t = vec![
270/// vec![true, true, false],
271/// vec![true, true, true],
272/// vec![true, true, false],
273/// vec![false, false, true],
274/// ];
275/// let frequent = apriori(&t, 0.5)?;
276/// // The pair {0, 1} is frequent at support 0.75.
277/// let pair = frequent.iter().find(|s| s.items() == [0, 1]).expect("pair present");
278/// assert!((pair.support() - 0.75).abs() < 1e-12, "support was {}", pair.support());
279/// # Ok::<(), stats_claw::algorithms::association::AssociationError>(())
280/// ```
281pub fn apriori(
282 transactions: &[Vec<bool>],
283 min_support: f64,
284) -> Result<Vec<FrequentItemset>, AssociationError> {
285 let (n_transactions, n_items) = validate_matrix(transactions)?;
286 if !(min_support > 0.0 && min_support <= 1.0) {
287 return Err(AssociationError::InvalidSupport);
288 }
289 let n = count_to_f64(n_transactions);
290 // The count threshold: support >= min_support <=> count >= min_support * n.
291 // Comparing counts (integers) avoids float drift in the frequency test.
292 let support_of = |count: usize| count_to_f64(count) / n;
293
294 let mut all: Vec<FrequentItemset> = Vec::new();
295
296 // Level 1: frequent single items, in ascending index order.
297 let mut current: Vec<Vec<usize>> = Vec::new();
298 for item in 0..n_items {
299 let count = cover_count(transactions, &[item]);
300 let support = support_of(count);
301 if support >= min_support {
302 current.push(vec![item]);
303 all.push(FrequentItemset {
304 items: vec![item],
305 support,
306 });
307 }
308 }
309
310 // Levels 2..: join + prune until no candidates survive.
311 while !current.is_empty() {
312 let candidates = join_and_prune(¤t);
313 let mut next: Vec<Vec<usize>> = Vec::new();
314 for cand in candidates {
315 let count = cover_count(transactions, &cand);
316 let support = support_of(count);
317 if support >= min_support {
318 next.push(cand.clone());
319 all.push(FrequentItemset {
320 items: cand,
321 support,
322 });
323 }
324 }
325 current = next;
326 }
327
328 sort_itemsets(&mut all);
329 Ok(all)
330}
331
332/// Joins frequent `(k − 1)`-itemsets sharing their first `k − 2` items into sorted
333/// `k`-candidates, dropping any whose `(k − 1)`-subsets are not all frequent.
334///
335/// The classic Apriori-gen step: two frequent sets `[a₀..a_{k-2}, x]` and
336/// `[a₀..a_{k-2}, y]` with `x < y` join to `[a₀..a_{k-2}, x, y]`; the candidate
337/// survives only when every one of its `k − 1`-element subsets is itself frequent
338/// (downward closure), which prunes most candidates before any support count.
339///
340/// # Arguments
341///
342/// * `frequent` — the frequent `(k − 1)`-itemsets, each ascending; assumed sorted
343/// lexicographically by [`apriori`]'s level ordering.
344///
345/// # Returns
346///
347/// The surviving `k`-candidate itemsets, each ascending, de-duplicated.
348fn join_and_prune(frequent: &[Vec<usize>]) -> Vec<Vec<usize>> {
349 let frequent_set: BTreeSet<&Vec<usize>> = frequent.iter().collect();
350 let mut candidates: BTreeSet<Vec<usize>> = BTreeSet::new();
351 for (ai, a) in frequent.iter().enumerate() {
352 for b in frequent.iter().skip(ai + 1) {
353 // Join only when the prefixes (all but the last item) match and the last
354 // items differ in ascending order.
355 let k = a.len();
356 if a.get(..k - 1) != b.get(..k - 1) {
357 continue;
358 }
359 let (Some(&last_a), Some(&last_b)) = (a.last(), b.last()) else {
360 continue;
361 };
362 if last_a >= last_b {
363 continue;
364 }
365 let mut candidate = a.clone();
366 candidate.push(last_b);
367 if all_subsets_frequent(&candidate, &frequent_set) {
368 candidates.insert(candidate);
369 }
370 }
371 }
372 candidates.into_iter().collect()
373}
374
375/// Returns whether every `(k − 1)`-element subset of `candidate` is frequent.
376///
377/// The downward-closure prune: a `k`-itemset can only be frequent if all of its
378/// immediate subsets are, so any candidate with a missing subset is discarded
379/// before its support is counted.
380///
381/// # Arguments
382///
383/// * `candidate` — the ascending `k`-itemset to test.
384/// * `frequent` — the set of frequent `(k − 1)`-itemsets, by reference.
385///
386/// # Returns
387///
388/// `true` iff dropping each single item in turn leaves a frequent subset.
389fn all_subsets_frequent(candidate: &[usize], frequent: &BTreeSet<&Vec<usize>>) -> bool {
390 (0..candidate.len()).all(|drop| {
391 let subset: Vec<usize> = candidate
392 .iter()
393 .enumerate()
394 .filter_map(|(idx, &item)| (idx != drop).then_some(item))
395 .collect();
396 frequent.contains(&subset)
397 })
398}
399
400/// Sorts frequent itemsets by ascending size, then by ascending item indices.
401///
402/// Gives a deterministic, reproducible ordering so the equivalence suite can sort
403/// both sides to the same canonical order before comparing.
404///
405/// # Arguments
406///
407/// * `itemsets` — the itemsets to order in place.
408fn sort_itemsets(itemsets: &mut [FrequentItemset]) {
409 itemsets.sort_by(|a, b| {
410 a.items
411 .len()
412 .cmp(&b.items.len())
413 .then(a.items.cmp(&b.items))
414 });
415}
416
417#[cfg(test)]
418#[path = "tests.rs"]
419mod tests;