Association Rule Mining
Overview
The package smile.association provides efficient mining of frequent item sets via the FP-Growth algorithm and association rules with support, confidence, lift, and leverage metrics.
Association rule mining discovers meaningful co-occurrence patterns in large transaction databases. Let I = {i1, i2,…, in} be a set of binary attributes called items, and D = {t1, t2,…, tm} a set of transactions. An association rule is an implication X ⇒ Y where X, Y ⊆ I and X ∩ Y = ∅. X is the antecedent (LHS) and Y the consequent (RHS).
For example, the rule {onions, potatoes} ⇒ {burger} in supermarket data suggests customers buying onions and potatoes together are also likely to buy burger — useful for product placement and promotion strategy.
Rules are usually required to satisfy a user-specified minimum support and minimum confidence. Generation is split into two steps:
- First, minimum support finds all frequent item sets in the database (frequent item set mining).
- Second, those frequent item sets and the minimum confidence constraint are used to form rules.
| Class | Role |
|---|---|
FPTree | Compact transaction index (prefix tree + header table) |
FPGrowth | Frequent item set mining via FP-Growth |
ItemSet | Frequent item set with raw support count |
ARM | Association rule mining from the T-tree |
AssociationRule | Rule with antecedent, consequent, and metrics |
Typical workflow:
import smile.association.*;
import java.util.stream.Stream;
// transactions → FPTree.of(minSupport, ...)
// → FPGrowth.apply(tree) // Stream<ItemSet>
// → ARM.apply(confidence, tree) // Stream<AssociationRule>
FPTree tree = FPTree.of(0.3, transactions);
Stream<ItemSet> sets = FPGrowth.apply(tree);
Stream<AssociationRule> rules = ARM.apply(0.5, tree);
import smile.association._
// transactions → fptree / FPTree.of
// → fpgrowth(tree) // Stream[ItemSet]
// → arm(conf, tree) // Stream[AssociationRule]
val tree = FPTree.of(0.3, transactions)
val sets = fpgrowth(tree)
val rules = arm(0.5, tree)
import smile.association.*
// transactions → fptree / FPTree.of
// → fpgrowth(tree) // Stream<ItemSet>
// → arm(conf, tree) // Stream<AssociationRule>
val tree = FPTree.of(0.3, transactions)
val sets = fpgrowth(tree)
val rules = arm(0.5, tree)
Core Concepts
Finding all frequent item sets is hard because the search space is the power set of I (size 2n − 1). Efficient search relies on the downward-closure (anti-monotonicity) property of support: every subset of a frequent item set is frequent, and all supersets of an infrequent item set are infrequent.
An item set is maximal frequent if none of its immediate supersets is frequent. Maximal sets omit the support of subsets, which matters for rule mining. An item set is closed frequent if none of its immediate supersets has the same support — preferred when the goal is association rules.
Well-known algorithms include Apriori (breadth-first candidate generation), Eclat (depth-first set intersection), and FP-Growth. SMILE uses FP-Growth: an extended prefix tree (FP-tree) stores the database in compressed form, then a divide-and-conquer pattern-growth method avoids Apriori’s costly candidate generation and testing.
Data Format
Transactions are int[] arrays of non-negative item IDs. Prefer compact IDs in [0, n) where n is the number of distinct items.
int[][] transactions = {
{1, 3},
{2},
{4},
{2, 3, 4},
{2, 3},
{2, 3},
{1, 2, 3, 4},
{1, 3},
{1, 2, 3},
{1, 2, 3}
};
val transactions = Array(
Array(1, 3),
Array(2),
Array(4),
Array(2, 3, 4),
Array(2, 3),
Array(2, 3),
Array(1, 2, 3, 4),
Array(1, 3),
Array(1, 2, 3),
Array(1, 2, 3)
)
val transactions = arrayOf(
intArrayOf(1, 3),
intArrayOf(2),
intArrayOf(4),
intArrayOf(2, 3, 4),
intArrayOf(2, 3),
intArrayOf(2, 3),
intArrayOf(1, 2, 3, 4),
intArrayOf(1, 3),
intArrayOf(1, 2, 3),
intArrayOf(1, 2, 3)
)
Rules for encoding:
- Item IDs must be non-negative integers.
- Each row may have a different length.
- Duplicate items within one transaction are tolerated and collapsed: support is counted by presence per transaction, not multiplicity. Rows may be reordered after the call.
Build an FP-Tree
Class smile.association.FPTree is a compact transaction index: a prefix tree plus a header table used by FP-Growth and ARM.
In-Memory Construction
Use absolute frequency counts or a relative fraction of the database. The two forms produce identical trees when the percentage rounds to the same integer count.
import smile.association.FPTree;
int[][] transactions = {
{1, 3},
{2},
{4},
{2, 3, 4},
{2, 3},
{2, 3},
{1, 2, 3, 4},
{1, 3},
{1, 2, 3},
{1, 2, 3}
};
// Absolute minimum support (frequency count)
FPTree tree = FPTree.of(3, transactions); // minSupport = 3
// Relative minimum support (fraction of transactions)
FPTree tree2 = FPTree.of(0.3, transactions); // 30% of 10 = 3
System.out.println(tree.size()); // number of transactions
System.out.println(tree.minSupport()); // effective integer threshold
10
3
import smile.association._
import java.util.function.Supplier
import java.util.stream.Stream
val transactions = Array(
Array(1, 3),
Array(2),
Array(4),
Array(2, 3, 4),
Array(2, 3),
Array(2, 3),
Array(1, 2, 3, 4),
Array(1, 3),
Array(1, 2, 3),
Array(1, 2, 3)
)
val tree = FPTree.of(3, transactions)
// or via supplier helper:
val tree2 = fptree(3, () => Stream.of(transactions: _*))
println(tree.size)
println(tree.minSupport)
10
3
import smile.association.*
import java.util.function.Supplier
import java.util.stream.Stream
val transactions = arrayOf(
intArrayOf(1, 3),
intArrayOf(2),
intArrayOf(4),
intArrayOf(2, 3, 4),
intArrayOf(2, 3),
intArrayOf(2, 3),
intArrayOf(1, 2, 3, 4),
intArrayOf(1, 3),
intArrayOf(1, 2, 3),
intArrayOf(1, 2, 3)
)
val tree = FPTree.of(3, transactions)
// or via supplier helper:
val tree2 = fptree(3, Supplier { Stream.of(*transactions) })
println(tree.size())
println(tree.minSupport())
10
3
Streaming Construction
For large datasets that cannot fit in memory, pass a Supplier<Stream<int[]>>. The supplier is called twice — once for frequency counting and once for tree construction — so it must reopen the source on each call.
import smile.association.FPTree;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.function.Supplier;
import java.util.stream.Stream;
Supplier<Stream<int[]>> supplier = () -> {
try {
return Files.lines(Path.of("transactions.dat"))
.map(line -> Arrays.stream(line.split("\\s+"))
.mapToInt(Integer::parseInt)
.toArray());
} catch (Exception ex) {
throw new RuntimeException(ex);
}
};
FPTree tree = FPTree.of(1500, supplier); // absolute support
FPTree tree2 = FPTree.of(0.003, supplier); // relative (0.3%)
import smile.association._
import smile.util.Paths
val tree = fptree(1000, () => {
Paths.getTestDataLines("transaction/kosarak.dat").map { line =>
line.split(" ").map(_.toInt)
}
})
println(tree.size)
println(tree.minSupport)
import smile.association.*
import smile.util.Paths
import java.util.function.Supplier
import java.util.stream.Stream
val tree = fptree(1000, Supplier {
Paths.getTestDataLines("transaction/kosarak.dat").map { line ->
line.split(" ").map { it.toInt() }.toIntArray()
}
})
println(tree.size())
println(tree.minSupport())
Input Validation
FPTree.of enforces these preconditions, throwing IllegalArgumentException on violation:
| Variant | Condition checked |
|---|---|
of(int, …) | minSupport >= 1 |
of(double, …) | minSupport in (0, 1] |
| Both | stream must not be empty |
FPTree.of(0, transactions); // throws — minSupport must be >= 1
FPTree.of(0.0, transactions); // throws — percentage must be > 0
FPTree.of(1.1, transactions); // throws — percentage must be <= 1
Empty-tree handling: when minSupport is larger than the total transaction count, no items are frequent. FPGrowth.apply and ARM.apply both return empty streams without error — this is the correct and tested behavior.
Mine Frequent Item Sets (FP-Growth)
Class smile.association.FPGrowth returns a lazy sequential Stream<ItemSet>. ItemSet is an immutable record:
record ItemSet(int[] items, int support) { ... }
| Field | Type | Meaning |
|---|---|---|
items() | int[] | Item IDs in frequency-descending order |
support() | int | Raw transaction count |
ItemSet.equals / hashCode include both items and support. toString produces e.g. ItemSet([3, 2], support=6).
import smile.association.*;
import java.util.Arrays;
int[][] transactions = {
{1, 3},
{2},
{4},
{2, 3, 4},
{2, 3},
{2, 3},
{1, 2, 3, 4},
{1, 3},
{1, 2, 3},
{1, 2, 3}
};
FPTree tree = FPTree.of(3, transactions);
FPGrowth.apply(tree).forEach(set ->
System.out.printf("%s (support=%d)%n",
Arrays.toString(set.items()), set.support())
);
[4] (support=3)
[1] (support=5)
[2, 1] (support=3)
[3, 2, 1] (support=3)
[3, 1] (support=5)
[2] (support=7)
[3, 2] (support=6)
[3] (support=8)
import smile.association._
val transactions = Array(
Array(1, 3),
Array(2),
Array(4),
Array(2, 3, 4),
Array(2, 3),
Array(2, 3),
Array(1, 2, 3, 4),
Array(1, 3),
Array(1, 2, 3),
Array(1, 2, 3)
)
fpgrowth(3, transactions).forEach(println)
// or: val tree = FPTree.of(3, transactions); fpgrowth(tree)
[4] (support=3)
[1] (support=5)
[2, 1] (support=3)
[3, 2, 1] (support=3)
[3, 1] (support=5)
[2] (support=7)
[3, 2] (support=6)
[3] (support=8)
import smile.association.*
val transactions = arrayOf(
intArrayOf(1, 3),
intArrayOf(2),
intArrayOf(4),
intArrayOf(2, 3, 4),
intArrayOf(2, 3),
intArrayOf(2, 3),
intArrayOf(1, 2, 3, 4),
intArrayOf(1, 3),
intArrayOf(1, 2, 3),
intArrayOf(1, 2, 3)
)
fpgrowth(3, transactions).forEach { println(it) }
// or: val tree = FPTree.of(3, transactions); fpgrowth(tree)
[4] (support=3)
[1] (support=5)
[2, 1] (support=3)
[3, 2, 1] (support=3)
[3, 1] (support=5)
[2] (support=7)
[3, 2] (support=6)
[3] (support=8)
Mine Association Rules (ARM)
Class smile.association.ARM generates rules from the FP-tree’s total-support tree. AssociationRule is an immutable record holding antecedent, consequent, and four metrics.
record AssociationRule(int[] antecedent, int[] consequent,
double support, double confidence,
double lift, double leverage) { ... }
Confidence Threshold
import smile.association.*;
int[][] transactions = {
{1, 3},
{2},
{4},
{2, 3, 4},
{2, 3},
{2, 3},
{1, 2, 3, 4},
{1, 3},
{1, 2, 3},
{1, 2, 3}
};
FPTree tree = FPTree.of(3, transactions);
// All rules with confidence >= 0.5
ARM.apply(0.5, tree).forEach(System.out::println);
// Deterministic rules only
ARM.apply(1.0, tree);
// All generated rules
ARM.apply(0.0, tree);
// Filter for positive, meaningful correlation
ARM.apply(0.5, tree)
.filter(r -> r.lift() > 1.1 && r.leverage() > 0.01)
.forEach(System.out::println);
AssociationRule([3] => [2], support=60.0%, confidence=75.0%, lift=1.07, leverage=0.040)
…
import smile.association._
val transactions = Array(
Array(1, 3),
Array(2),
Array(4),
Array(2, 3, 4),
Array(2, 3),
Array(2, 3),
Array(1, 2, 3, 4),
Array(1, 3),
Array(1, 2, 3),
Array(1, 2, 3)
)
val tree = FPTree.of(3, transactions)
arm(0.5, tree).forEach(println)
// or one-shot: arm(3, 0.5, transactions)
AssociationRule([3] => [2], support=60.0%, confidence=75.0%, lift=1.07, leverage=0.040)
…
import smile.association.*
val transactions = arrayOf(
intArrayOf(1, 3),
intArrayOf(2),
intArrayOf(4),
intArrayOf(2, 3, 4),
intArrayOf(2, 3),
intArrayOf(2, 3),
intArrayOf(1, 2, 3, 4),
intArrayOf(1, 3),
intArrayOf(1, 2, 3),
intArrayOf(1, 2, 3)
)
val tree = FPTree.of(3, transactions)
arm(0.5, tree).forEach { println(it) }
// or one-shot: arm(3, 0.5, transactions)
AssociationRule([3] => [2], support=60.0%, confidence=75.0%, lift=1.07, leverage=0.040)
…
Input Validation
ARM.apply validates that confidence is in [0, 1]:
ARM.apply(-0.1, tree); // throws IllegalArgumentException
ARM.apply(1.1, tree); // throws IllegalArgumentException
Interpret Rule Metrics
Given rule X ⇒ Y over a database of N transactions:
| Metric | Formula | Interpretation |
|---|---|---|
support | count(X ∪ Y) / N | Fraction of transactions containing both X and Y |
confidence | count(X ∪ Y) / count(X) | Estimated P(Y | X) |
lift | support / (supp(X) · supp(Y)) | > 1 positive; = 1 independent; < 1 negative |
leverage | support − supp(X) · supp(Y) | Absolute gain over independence |
Example — rule {3} ⇒ {2} from the 10-transaction dataset:
| Quantity | Value |
|---|---|
count({3}) | 8 → supp({3}) = 0.8 |
count({2}) | 7 → supp({2}) = 0.7 |
count({3,2}) | 6 → support = 0.6 |
confidence | 6/8 = 0.75 |
lift | 0.6 / (0.8 × 0.7) ≈ 1.071 |
leverage | 0.6 − 0.8 × 0.7 = 0.04 |
Note on equals/hashCode: two AssociationRule objects are equal if they have the same antecedent, consequent, support, and confidence. lift and leverage are derived metrics and are intentionally excluded from equality comparisons, so floating-point drift in those fields does not break equality.
End-to-End Examples
In-Memory Transactions
import smile.association.*;
import java.util.Arrays;
int[][] transactions = {
{1, 3},
{2},
{4},
{2, 3, 4},
{2, 3},
{2, 3},
{1, 2, 3, 4},
{1, 3},
{1, 2, 3},
{1, 2, 3}
};
FPTree tree = FPTree.of(0.3, transactions);
System.out.println("Transactions: " + tree.size());
System.out.println("Min support: " + tree.minSupport());
System.out.println("\nFrequent item sets:");
FPGrowth.apply(tree).forEach(set ->
System.out.printf(" %s support=%d%n",
Arrays.toString(set.items()), set.support())
);
System.out.println("\nAssociation rules (conf >= 0.5):");
ARM.apply(0.5, tree).forEach(rule ->
System.out.printf(
" %s => %s supp=%.2f conf=%.2f lift=%.3f lev=%.3f%n",
Arrays.toString(rule.antecedent()),
Arrays.toString(rule.consequent()),
rule.support(), rule.confidence(),
rule.lift(), rule.leverage())
);
Transactions: 10
Min support: 3
Frequent item sets:
[4] support=3
[1] support=5
[1, 3] support=5
…
[3, 2] support=6
[3] support=8
Association rules (conf >= 0.5):
[3] => [2] supp=0.60 conf=0.75 lift=1.071 lev=0.040
[3] => [1] supp=0.50 conf=0.63 lift=1.250 lev=0.100
…
import smile.association._
val transactions = Array(
Array(1, 3),
Array(2),
Array(4),
Array(2, 3, 4),
Array(2, 3),
Array(2, 3),
Array(1, 2, 3, 4),
Array(1, 3),
Array(1, 2, 3),
Array(1, 2, 3)
)
val tree = FPTree.of(0.3, transactions)
println(s"Transactions: ${tree.size}")
println(s"Min support: ${tree.minSupport}")
println("Frequent item sets:")
fpgrowth(tree).forEach(println)
println("Association rules (conf >= 0.5):")
arm(0.5, tree).forEach(println)
Transactions: 10
Min support: 3
Frequent item sets:
[4] support=3
[1] support=5
[1, 3] support=5
…
[3, 2] support=6
[3] support=8
Association rules (conf >= 0.5):
[3] => [2] supp=0.60 conf=0.75 lift=1.071 lev=0.040
[3] => [1] supp=0.50 conf=0.63 lift=1.250 lev=0.100
…
import smile.association.*
val transactions = arrayOf(
intArrayOf(1, 3),
intArrayOf(2),
intArrayOf(4),
intArrayOf(2, 3, 4),
intArrayOf(2, 3),
intArrayOf(2, 3),
intArrayOf(1, 2, 3, 4),
intArrayOf(1, 3),
intArrayOf(1, 2, 3),
intArrayOf(1, 2, 3)
)
val tree = FPTree.of(0.3, transactions)
println("Transactions: ${tree.size()}")
println("Min support: ${tree.minSupport()}")
println("Frequent item sets:")
fpgrowth(tree).forEach { println(it) }
println("Association rules (conf >= 0.5):")
arm(0.5, tree).forEach { println(it) }
Transactions: 10
Min support: 3
Frequent item sets:
[4] support=3
[1] support=5
[1, 3] support=5
…
[3, 2] support=6
[3] support=8
Association rules (conf >= 0.5):
[3] => [2] supp=0.60 conf=0.75 lift=1.071 lev=0.040
[3] => [1] supp=0.50 conf=0.63 lift=1.250 lev=0.100
…
Supplier-Based Large File Mining
Use the Supplier<Stream<int[]>> API when the dataset does not fit in memory. The supplier is called twice internally.
import smile.association.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.function.Supplier;
import java.util.stream.Stream;
Supplier<Stream<int[]>> data = () -> {
try {
return Files.lines(Path.of("transactions.dat"))
.map(line -> Arrays.stream(line.split("\\s+"))
.mapToInt(Integer::parseInt)
.toArray());
} catch (Exception ex) {
throw new RuntimeException(ex);
}
};
FPTree tree = FPTree.of(0.003, data); // 0.3% of transactions
long nSets = FPGrowth.apply(tree).count();
long nRules = ARM.apply(0.5, tree).count();
System.out.printf("Frequent sets: %d, Rules: %d%n", nSets, nRules);
5634 (1000)
3805 (1001)
3376 (1001)
…
Frequent sets: …, Rules: …
import smile.association._
import smile.util.Paths
val tree = fptree(1000, () => {
Paths.getTestDataLines("transaction/kosarak.dat").map { line =>
line.split(" ").map(_.toInt)
}
})
fpgrowth(tree).limit(10).forEach(println)
val nRules = arm(0.5, tree).count()
println(s"Rules: $nRules")
5634 (1000)
3805 (1001)
3376 (1001)
…
Frequent sets: …, Rules: …
import smile.association.*
import smile.util.Paths
import java.util.function.Supplier
val tree = fptree(1000, Supplier {
Paths.getTestDataLines("transaction/kosarak.dat").map { line ->
line.split(" ").map { it.toInt() }.toIntArray()
}
})
fpgrowth(tree).limit(10).forEach { println(it) }
val nRules = arm(0.5, tree).count()
println("Rules: $nRules")
5634 (1000)
3805 (1001)
3376 (1001)
…
Frequent sets: …, Rules: …
Filter by Lift
Mining can produce thousands of rules; narrow them with stream operations and sort by lift:
import smile.association.*;
import java.util.Comparator;
int[][] transactions = {
{1, 3},
{2},
{4},
{2, 3, 4},
{2, 3},
{2, 3},
{1, 2, 3, 4},
{1, 3},
{1, 2, 3},
{1, 2, 3}
};
FPTree tree = FPTree.of(3, transactions);
ARM.apply(0.5, tree)
.filter(r -> r.lift() > 1.1)
.filter(r -> r.leverage() > 0.01)
.sorted(Comparator.comparingDouble(AssociationRule::lift).reversed())
.limit(20)
.forEach(System.out::println);
import smile.association._
import java.util.Comparator
val transactions = Array(
Array(1, 3),
Array(2),
Array(4),
Array(2, 3, 4),
Array(2, 3),
Array(2, 3),
Array(1, 2, 3, 4),
Array(1, 3),
Array(1, 2, 3),
Array(1, 2, 3)
)
val tree = FPTree.of(3, transactions)
arm(0.5, tree)
.filter(r => r.lift > 1.1 && r.leverage > 0.01)
.sorted(Comparator.comparingDouble[AssociationRule](_.lift).reversed)
.limit(20)
.forEach(println)
import smile.association.*
import java.util.Comparator
val transactions = arrayOf(
intArrayOf(1, 3),
intArrayOf(2),
intArrayOf(4),
intArrayOf(2, 3, 4),
intArrayOf(2, 3),
intArrayOf(2, 3),
intArrayOf(1, 2, 3, 4),
intArrayOf(1, 3),
intArrayOf(1, 2, 3),
intArrayOf(1, 2, 3)
)
val tree = FPTree.of(3, transactions)
arm(0.5, tree)
.filter { it.lift() > 1.1 && it.leverage() > 0.01 }
.sorted(Comparator.comparingDouble(AssociationRule::lift).reversed())
.limit(20)
.forEach { println(it) }
Validation and Edge Cases
Empty tree (minSupport too high)
If minSupport exceeds the total number of transactions, no item is frequent. Both FPGrowth.apply and ARM.apply return empty streams gracefully:
FPTree tree = FPTree.of(11, transactions); // only 10 transactions
assert FPGrowth.apply(tree).count() == 0;
assert ARM.apply(0.5, tree).count() == 0;
Duplicate items in one transaction
Items appearing more than once in a transaction are collapsed before indexing:
int[][] dup = {{1, 1, 2}, {1, 2}, {1, 1, 1, 2}};
FPTree tree = FPTree.of(2, dup);
// support of {1} = 3, {2} = 3, {1, 2} = 3 (not inflated)
Iterator contract
All three iterators (FPGrowth, TotalSupportTree, ARM) throw NoSuchElementException when next() is called after exhaustion, consistent with the Java Iterator contract.
AssociationRule equality
equals and hashCode use antecedent, consequent, support, and confidence. lift and leverage are derived values and are excluded, so two rules representing the same statistical relationship compare equal regardless of floating-point rounding in the derived metrics.
Performance Tips
| Tip | Rationale |
|---|---|
Use compact integer IDs in [0, N) | Avoids sparse array overhead in the header table |
| Use the supplier API for large files | The two-pass design avoids loading all data into memory |
Tune minSupport first | Support pruning dominates runtime; lower = exponentially more work |
| Stream and filter immediately | Avoid materializing millions of rules into a List |
| Percentage support for portability | 0.01 works for any database size; absolute counts are dataset-specific |
API Quick Reference
// ── FP-Tree construction ──────────────────────────────────────────────────────
FPTree.of(int minSupport, int[][] itemsets)
FPTree.of(double minSupport, int[][] itemsets) // minSupport in (0, 1]
FPTree.of(int minSupport, Supplier<Stream<int[]>> supplier)
FPTree.of(double minSupport, Supplier<Stream<int[]>> supplier)
int tree.size() // number of transactions
int tree.minSupport() // effective integer support threshold
// ── Frequent item sets ────────────────────────────────────────────────────────
Stream<ItemSet> FPGrowth.apply(FPTree tree)
// ItemSet (record)
int[] items() // item IDs
int support() // raw count
// ── Association rules ─────────────────────────────────────────────────────────
Stream<AssociationRule> ARM.apply(double confidence, FPTree tree)
// confidence must be in [0, 1]
// AssociationRule (record)
int[] antecedent() // LHS item IDs
int[] consequent() // RHS item IDs
double support() // P(X ∪ Y) — fraction
double confidence() // P(Y | X)
double lift() // correlation vs. independence (>1 = positive)
double leverage() // absolute gain over independence
// Scala / Kotlin helpers (package smile.association)
// fptree(minSupport, supplier)
// fpgrowth(minSupport, itemsets) / fpgrowth(tree)
// arm(minSupport, confidence, itemsets) / arm(confidence, tree)