Association Rule Learning (Apriori Algorithm)
What you’ll learn
- the five rule metrics — support, confidence, lift, leverage and conviction — each derived and each computed by hand on the same baskets
- why confidence alone is dangerous, shown with a rule at 80% confidence and lift 1.23
- a rule with lift 0.31: buying chips makes bread less likely
- the downward-closure property, and the candidate counts it eliminates: 70 → 0 at level 4
- the full Apriori loop traced level by level on 20 baskets
- why the number of rules explodes, and how to control it
- FP-Growth, and when the level-wise scan stops being viable
Intuition
Every technique so far in this phase has grouped rows. Association rule learning groups columns — or rather, it finds columns that tend to be present together.
The classic setting is a supermarket. Each transaction is a set of items. You want statements like “customers who buy bread also buy butter” — and, much more importantly, you want to know which of those statements are worth acting on.
That second part is where almost all the difficulty lives. “Customers who buy anchovies also buy bread” is true 80% of the time. It is also useless, because 65% of all customers buy bread. The rule tells you nothing you did not already know from the shelf.
Distinguishing a real association from a popular consequent is the whole job, and it requires more than one number.
flowchart LR T["Transactions
(sets of items)"] --> F["Frequent itemsets
support >= min_support"] F --> R["Candidate rules
split each itemset into A -> B"] R --> M["Score each rule:
confidence, lift, leverage, conviction"] M --> K["Keep the rules that beat
independence, not just the popular ones"]
The data
Twenty baskets, small enough to count by hand:
| # | Items | # | Items | |
|---|---|---|---|---|
| 1 | bread, butter, milk | 11 | bread, butter, milk | |
| 2 | bread, butter | 12 | beer, chips | |
| 3 | bread, milk, jam | 13 | bread, milk | |
| 4 | butter, milk | 14 | butter, jam | |
| 5 | bread, butter, milk, jam | 15 | bread, butter, milk, cereal | |
| 6 | beer, chips | 16 | chips, salsa | |
| 7 | bread, jam | 17 | bread, butter | |
| 8 | beer, chips, salsa | 18 | milk, cereal, bread | |
| 9 | bread, butter, jam | 19 | beer, chips, bread | |
| 10 | milk, cereal | 20 | bread, butter, milk |
Item counts, which are the foundation of everything below:
| Item | Count | Support |
|---|---|---|
| bread | 13 | 0.65 |
| butter | 10 | 0.50 |
| milk | 10 | 0.50 |
| chips | 5 | 0.25 |
| jam | 5 | 0.25 |
| beer | 4 | 0.20 |
| cereal | 3 | 0.15 |
| salsa | 2 | 0.10 |
The math
Write for the set of transactions, , and for the number of transactions containing itemset .
Support — how often the itemset appears at all:
Confidence of the rule — the conditional probability:
Lift — confidence divided by the consequent’s base rate:
This is the ratio of the observed joint frequency to the frequency expected under independence. Read it as:
- — and appear together more often than chance. Positive association.
- — independent. The rule tells you nothing.
- — they appear together less often than chance. Negative association.
Lift is symmetric: . Confidence is not.
Leverage — the same comparison as a difference rather than a ratio:
Zero under independence. Because it is a difference, it weights by how many transactions are actually affected — useful when a huge lift comes from a handful of baskets.
Conviction — how much more often the rule would be wrong if and were independent:
It is 1 under independence and when confidence is 1 (the rule has no counterexamples). Unlike lift, conviction is directional.
| Metric | Range | Independent value | Direction-sensitive |
|---|---|---|---|
| Support | — | no | |
| Confidence | yes | ||
| Lift | 1 | no | |
| Leverage | 0 | no | |
| Conviction | 1 | yes |
Worked example by hand
bread ⇒ butter
Bread appears in 13 baskets, butter in 10, and both together in 8 (baskets 1, 2, 5, 9, 11, 15, 17, 20).
A mild positive association: butter is about 23% more likely in a bread basket than in a random one.
The same pair, reversed
The confidence jumped from 0.62 to 0.80 while the lift did not move at all. Confidence went up purely because bread is more common than butter — swapping the direction changed the denominator, not the relationship. Lift, being symmetric, is unfooled.
Conviction does change: against 1.3000 the other way. It is directional by design, and it says the butter ⇒ bread direction is the stronger prediction.
beer ⇒ chips
Beer appears in 4 baskets (6, 8, 12, 19), chips in 5 (6, 8, 12, 16, 19). Together: 4.
Every beer basket contains chips. Lift 4.0 means four times the chance expected by independence, and conviction is infinite because there is not a single counterexample.
chips ⇒ bread, the rule that is worse than nothing
Chips appears in 5 baskets, bread in 13, both together in 1 (basket 19).
Lift 0.31. Chips buyers are three times less likely to buy bread than a random customer. Negative leverage confirms it. That is a real, actionable finding — the two are substitutes, not complements — and it is invisible to anyone who only ranks by confidence.
The full table
| Rule | Support | Confidence | Lift | Leverage | Conviction |
|---|---|---|---|---|---|
| beer ⇒ chips | 0.20 | 1.0000 | 4.0000 | 0.1500 | |
| chips ⇒ beer | 0.20 | 0.8000 | 4.0000 | 0.1500 | 4.0000 |
| butter ⇒ bread | 0.40 | 0.8000 | 1.2308 | 0.0750 | 1.7500 |
| milk ⇒ bread | 0.40 | 0.8000 | 1.2308 | 0.0750 | 1.7500 |
| jam ⇒ bread | 0.20 | 0.8000 | 1.2308 | 0.0375 | 1.7500 |
| bread ⇒ butter | 0.40 | 0.6154 | 1.2308 | 0.0750 | 1.3000 |
| jam ⇒ milk | 0.10 | 0.4000 | 0.8000 | -0.0250 | 0.8333 |
| chips ⇒ bread | 0.05 | 0.2000 | 0.3077 | -0.1125 | 0.4375 |
Four rules sit at exactly 0.8000 confidence, and their lifts range from 4.00 down to 1.23. Ranking
by confidence would put chips ⇒ beerchips ⇒ beer — a genuinely strong rule — level with three
bread-is-popular truisms.
The Apriori algorithm
Naively, finding all frequent itemsets over items means checking subsets. With just 100 products that is more than candidates.
Apriori’s insight is the downward-closure property (also called the Apriori property):
Adding an item to an itemset can only reduce the number of baskets containing it. So:
If an itemset is infrequent, every superset of it is infrequent.
That contrapositive lets you prune whole branches without counting them. The algorithm becomes a level-wise scan:
- Count all 1-itemsets; keep those with support
min_supportmin_support. Call this . - Generate candidate -itemsets by joining pairs in that share items.
- Prune any candidate having a -subset that is not in — no counting needed.
- Scan the transactions once to count the survivors; keep the frequent ones as .
- Stop when is empty.
Traced on the twenty baskets
With min_support = 0.20min_support = 0.20:
| Level | All possible itemsets | Candidates Apriori generates | Frequent |
|---|---|---|---|
| 1-itemsets | 8 | 8 | 6 |
| 2-itemsets | 28 | 15 | 5 |
| 3-itemsets | 56 | 1 | 1 |
| 4-itemsets | 70 | 0 | 0 |
At level 1, cereal (0.15) and salsa (0.10) drop out. At level 2, Apriori only builds candidates
from the 6 survivors — 15 pairs rather than 28 — and 5 clear the threshold. At level 3 only one
candidate survives pruning, {bread, butter, milk}{bread, butter, milk} at support 0.25. At level 4 there is nothing
left to join, so the algorithm stops after examining 24 candidates instead of 162.
From itemsets to rules
Each frequent itemset of size yields candidate rules — every way of splitting it into
a non-empty antecedent and a non-empty consequent. For {bread, butter, milk}{bread, butter, milk} at support 0.25,
that is 6 rules:
| Rule | Confidence | Lift |
|---|---|---|
| bread, butter ⇒ milk | 0.25 / 0.40 = 0.6250 | 1.2500 |
| bread, milk ⇒ butter | 0.25 / 0.40 = 0.6250 | 1.2500 |
| butter, milk ⇒ bread | 0.25 / 0.30 = 0.8333 | 1.2821 |
| bread ⇒ butter, milk | 0.25 / 0.65 = 0.3846 | 1.2821 |
| butter ⇒ bread, milk | 0.25 / 0.50 = 0.5000 | 1.2500 |
| milk ⇒ bread, butter | 0.25 / 0.50 = 0.5000 | 1.2500 |
Note the support is 0.25 for all six — it is a property of the itemset, not of how you split it. Only the confidence and lift differ.
flowchart LR I["Frequent itemset
{bread, butter, milk}
support 0.25"] --> R1["bread, butter -> milk"] I --> R2["butter, milk -> bread"] I --> R3["bread -> butter, milk"] R1 --> S1["conf 0.625
lift 1.250"] R2 --> S2["conf 0.833
lift 1.282"] R3 --> S3["conf 0.385
lift 1.282"]
See it move
Item D has support 0.12, below the threshold. The moment it goes red, every itemset containing it — AD, BD, CD, ABD, ACD, BCD, ABCD, seven of the fifteen nodes — is pruned without a single count.
The next sketch is the metrics themselves: move the overlap between two item sets and watch confidence and lift disagree.
Click to swap the base rates. With , confidence can reach 0.8 while lift is barely above 1. Drop to 0.25 and the same overlap produces a far higher lift — because the same co-occurrence is now genuinely surprising.
In code
scikit-learn does not implement Apriori. The standard library is mlxtendmlxtend:
pip install mlxtendpip install mlxtendimport pandas as pd
from mlxtend.frequent_patterns import apriori, association_rules
from mlxtend.preprocessing import TransactionEncoder
baskets = [
["bread", "butter", "milk"], ["bread", "butter"], ["bread", "milk", "jam"],
["butter", "milk"], ["bread", "butter", "milk", "jam"], ["beer", "chips"],
["bread", "jam"], ["beer", "chips", "salsa"], ["bread", "butter", "jam"],
["milk", "cereal"], ["bread", "butter", "milk"], ["beer", "chips"],
["bread", "milk"], ["butter", "jam"], ["bread", "butter", "milk", "cereal"],
["chips", "salsa"], ["bread", "butter"], ["milk", "cereal", "bread"],
["beer", "chips", "bread"], ["bread", "butter", "milk"],
]
# One-hot: one row per basket, one boolean column per item.
te = TransactionEncoder()
df = pd.DataFrame(te.fit_transform(baskets), columns=te.columns_)
freq = apriori(df, min_support=0.20, use_colnames=True)
print(freq.sort_values("support", ascending=False))
rules = association_rules(freq, metric="lift", min_threshold=1.0)
cols = ["antecedents", "consequents", "support", "confidence", "lift", "leverage", "conviction"]
print(rules[cols].sort_values("lift", ascending=False).round(4))import pandas as pd
from mlxtend.frequent_patterns import apriori, association_rules
from mlxtend.preprocessing import TransactionEncoder
baskets = [
["bread", "butter", "milk"], ["bread", "butter"], ["bread", "milk", "jam"],
["butter", "milk"], ["bread", "butter", "milk", "jam"], ["beer", "chips"],
["bread", "jam"], ["beer", "chips", "salsa"], ["bread", "butter", "jam"],
["milk", "cereal"], ["bread", "butter", "milk"], ["beer", "chips"],
["bread", "milk"], ["butter", "jam"], ["bread", "butter", "milk", "cereal"],
["chips", "salsa"], ["bread", "butter"], ["milk", "cereal", "bread"],
["beer", "chips", "bread"], ["bread", "butter", "milk"],
]
# One-hot: one row per basket, one boolean column per item.
te = TransactionEncoder()
df = pd.DataFrame(te.fit_transform(baskets), columns=te.columns_)
freq = apriori(df, min_support=0.20, use_colnames=True)
print(freq.sort_values("support", ascending=False))
rules = association_rules(freq, metric="lift", min_threshold=1.0)
cols = ["antecedents", "consequents", "support", "confidence", "lift", "leverage", "conviction"]
print(rules[cols].sort_values("lift", ascending=False).round(4))Doing it without a dependency is about fifteen lines, and worth writing once:
from itertools import combinations
def support(itemset, baskets):
s = set(itemset)
return sum(1 for b in baskets if s <= set(b)) / len(baskets)
def apriori(baskets, min_support):
"""Level-wise search with downward-closure pruning."""
items = sorted({i for b in baskets for i in b})
frequent = {frozenset([i]) for i in items if support([i], baskets) >= min_support}
all_frequent, prev, k = dict(), frequent, 2
while prev:
for fs in prev:
all_frequent[fs] = support(fs, baskets)
candidates = set()
for a, b in combinations(prev, 2):
union = a | b
if len(union) != k:
continue
# PRUNE: every k-1 subset must already be frequent
if all(frozenset(s) in prev for s in combinations(union, k - 1)):
candidates.add(union)
prev = {c for c in candidates if support(c, baskets) >= min_support}
k += 1
return all_frequent
freq = apriori(baskets, 0.20)
for fs, s in sorted(freq.items(), key=lambda kv: (-kv[1], sorted(kv[0]))):
print(f"{s:.2f} {set(sorted(fs))}")from itertools import combinations
def support(itemset, baskets):
s = set(itemset)
return sum(1 for b in baskets if s <= set(b)) / len(baskets)
def apriori(baskets, min_support):
"""Level-wise search with downward-closure pruning."""
items = sorted({i for b in baskets for i in b})
frequent = {frozenset([i]) for i in items if support([i], baskets) >= min_support}
all_frequent, prev, k = dict(), frequent, 2
while prev:
for fs in prev:
all_frequent[fs] = support(fs, baskets)
candidates = set()
for a, b in combinations(prev, 2):
union = a | b
if len(union) != k:
continue
# PRUNE: every k-1 subset must already be frequent
if all(frozenset(s) in prev for s in combinations(union, k - 1)):
candidates.add(union)
prev = {c for c in candidates if support(c, baskets) >= min_support}
k += 1
return all_frequent
freq = apriori(baskets, 0.20)
for fs, s in sorted(freq.items(), key=lambda kv: (-kv[1], sorted(kv[0]))):
print(f"{s:.2f} {set(sorted(fs))}")Controlling the explosion
Rule counts grow ferociously as you loosen min_supportmin_support. Three defences:
Set min_supportmin_support from a count, not a fraction. “At least 50 baskets” is a statement you can
defend; “0.001” is not. Compute the fraction from the count you need.
Filter on lift and leverage. Lift alone promotes rules built on three transactions. Leverage
weights by how many baskets are actually affected, so requiring both lift > 1.2lift > 1.2 and
leverage > 0.01leverage > 0.01 removes the noise.
Restrict the consequent. If you only care about what drives one product, ask only for rules with that item on the right:
target = {"butter"}
useful = rules[
(rules["consequents"] == frozenset(target))
& (rules["lift"] > 1.2)
& (rules["leverage"] > 0.01)
].sort_values("lift", ascending=False)target = {"butter"}
useful = rules[
(rules["consequents"] == frozenset(target))
& (rules["lift"] > 1.2)
& (rules["leverage"] > 0.01)
].sort_values("lift", ascending=False)FP-Growth
Apriori scans the whole transaction database once per level. On millions of transactions with tens of thousands of items, that is the bottleneck.
FP-Growth builds a compressed prefix tree (the FP-tree) in two passes and then mines it recursively without generating candidates at all. It is typically an order of magnitude faster and gives identical results — the frequent itemsets are a property of the data, not of the algorithm.
from mlxtend.frequent_patterns import fpgrowth
freq_fp = fpgrowth(df, min_support=0.20, use_colnames=True)
# Same itemsets, same supports, different route.from mlxtend.frequent_patterns import fpgrowth
freq_fp = fpgrowth(df, min_support=0.20, use_colnames=True)
# Same itemsets, same supports, different route.Use Apriori to learn the concepts and for small data. Use FP-Growth in production.
APInot in scikit-learn; use mlxtend.frequent_patterns.apriori
Assumes
- Transactions are unordered sets — no quantity, no sequence, no time
- Items are categorical and the vocabulary is manageable
- Co-occurrence is what you care about; correlation is not causation
Cost
- train
O(2^m) worst case; downward closure makes it tractable in practice, with one database scan per level- predict
O(1) lookup against the rule table- memory
O(number of candidate itemsets), which is the usual failure point
supp(X) — fraction of transactions containing X; conf(A=>B) = supp(A and B)/supp(A); lift = conf / supp(B)
Hyperparameters that matter
min_supportdefault 0.5 in mlxtendThe main lever. Too high finds nothing; too low explodes combinatorially. Set it from a basket count.min_threshold (on lift)default 1.0Rules below 1 are negative associations — sometimes exactly what you want to see.max_lendefault NoneCaps itemset size. Setting 2 or 3 bounds the runtime hard.use_colnamesdefault FalseReturn item names rather than column indices. Always set True.
Reach for it when
- Market basket analysis, cross-sell and product placement
- Web log and clickstream co-occurrence
- Finding co-occurring symptoms, diagnoses or error codes
- Any question of the form 'what shows up together?'
Look elsewhere when
- Order or timing matters — use sequential pattern mining instead
- Features are continuous — you would have to bin them first, and the bins drive the answer
- You need causality; these are co-occurrence counts and nothing more
- The item vocabulary is enormous and min_support has to be tiny
Pitfalls
Ranking by confidence. Four rules on this page share confidence 0.8000 with lifts from 1.23 to 4.00. Confidence rewards popular consequents; sort by lift or leverage.
Ignoring lift below 1. chips ⇒ breadchips ⇒ bread at lift 0.31 is one of the most informative rules in the
set — the items are substitutes. Most tooling filters these out by default with
min_threshold=1.0min_threshold=1.0.
Reading causation into co-occurrence. Beer and chips co-occur; neither causes the other. Both are caused by the same occasion. Acting on the rule (place them together) can still be correct; explaining it as causation is not.
Setting min_supportmin_support too low. The candidate count grows super-exponentially. Below about 0.01
on a real retail dataset you will exhaust memory before the level-3 scan finishes.
Forgetting the base rates. Every metric on this page is relative to . An item in 90% of baskets can never produce an interesting rule, because confidence cannot exceed 1 and the lift ceiling is .
Trusting rules built on a handful of transactions. A rule with support 0.001 and lift 12 rests on maybe five baskets. Check the raw count, and use leverage, which is bounded by how much of the data is affected.
Treating the item vocabulary as fixed. “Milk” and “semi-skimmed milk 2L” are different items to the algorithm. Aggregating to a sensible product hierarchy before mining usually matters more than any parameter.
Compare
| Apriori | FP-Growth | ECLAT | |
|---|---|---|---|
| Strategy | level-wise candidate generation | prefix-tree, no candidates | vertical tid-lists, depth-first |
| Database scans | one per level | two | one |
| Speed | slowest | fastest | fast |
| Memory | candidate sets | the FP-tree | tid-list intersections |
| Results | identical | identical | identical |
| Easiest to explain | yes | no | no |
A rule has confidence 0.85 and lift 0.95. What does that mean?
Lift below 1 means the pair co-occurs less often than independence predicts. Confidence 0.85 only looks impressive until you notice the consequent's base rate must be about 0.89. This is exactly the trap that ranking by confidence walks into.
Show answer
B — The consequent is so common that seeing the antecedent actually makes it slightly LESS likely — Lift below 1 means the pair co-occurs less often than independence predicts. Confidence 0.85 only looks impressive until you notice the consequent's base rate must be about 0.89. This is exactly the trap that ranking by confidence walks into.
The itemset {A, B} has support 0.15, below your threshold of 0.20. What can you say about {A, B, C}?
Downward closure: adding an item can only shrink the set of transactions containing the itemset. supp({A,B,C}) <= supp({A,B}) = 0.15 < 0.20. That single fact is what makes Apriori feasible — on the page's data it removed 7 of 15 lattice nodes at once.
Show answer
B — Its support is at most 0.15, so it is also infrequent and can be pruned unexamined — Downward closure: adding an item can only shrink the set of transactions containing the itemset. supp({A,B,C}) <= supp({A,B}) = 0.15 < 0.20. That single fact is what makes Apriori feasible — on the page's data it removed 7 of 15 lattice nodes at once.
conf(bread => butter) is 0.6154 but conf(butter => bread) is 0.8000. Yet both have lift 1.2308. Why?
lift = supp(A and B) / (supp(A) supp(B)), which is unchanged when you swap A and B. Confidence uses only supp(A) in the denominator, so it rises whenever the consequent is more common than the antecedent.
Show answer
B — Confidence divides by supp(antecedent), which differs by direction; lift divides by both supports, making it symmetric — lift = supp(A and B) / (supp(A) supp(B)), which is unchanged when you swap A and B. Confidence uses only supp(A) in the denominator, so it rises whenever the consequent is more common than the antecedent.
Your retail dataset has 200,000 transactions and 8,000 products, and Apriori runs out of memory at level 3. What is the best first move?
Memory goes on candidate itemsets, which explode as min_support falls. FP-Growth avoids candidate generation entirely by compressing the transactions into a prefix tree, and returns identical results.
Show answer
B — Raise min_support and switch to FP-Growth, which needs two database scans instead of one per level — Memory goes on candidate itemsets, which explode as min_support falls. FP-Growth avoids candidate generation entirely by compressing the transactions into a prefix tree, and returns identical results.
Which metric would you use to demote a rule with lift 15 that appears in only 4 of 100,000 baskets?
Leverage is supp(A and B) - supp(A)supp(B). With supp(A and B) = 0.00004 it is essentially zero no matter how large the ratio is. Lift is scale-free and so rewards tiny, unreliable counts; leverage does not.
Show answer
B — Leverage, because it is a difference in supports and is therefore bounded by how much data is affected — Leverage is supp(A and B) - supp(A)supp(B). With supp(A and B) = 0.00004 it is essentially zero no matter how large the ratio is. Lift is scale-free and so rewards tiny, unreliable counts; leverage does not.
🧪 Try It Yourself
Exercise 1 – Support by counting
Exercise 2 – Confidence is not symmetric, lift is
Exercise 3 – Find the negative association
Exercise 4 – Downward closure in action
Exercise 5 – All five metrics at once
Recap
- Support counts how often; confidence is ; lift compares that against independence.
- Confidence is directional, lift is symmetric: bread ⇒ butter and butter ⇒ bread gave 0.6154 and 0.8000 confidence but the same 1.2308 lift.
- Four different rules in the data share confidence 0.8000 with lifts from 1.23 to 4.00. Never rank by confidence alone.
chips ⇒ breadchips ⇒ breadhas lift 0.3077 and leverage -0.1125 — a real negative association that alift > 1lift > 1filter would discard.- Leverage demotes high-lift rules resting on a handful of baskets; conviction is infinite when a rule has no counterexamples.
- Downward closure — an infrequent itemset has no frequent supersets — pruned the search from 162 possible itemsets to 24 ever counted.
- Apriori scans the database once per level; FP-Growth does it in two passes and returns identical results.
Exercise 6 – Confidence without lift is a trap
Next
Principal Component Analysis (PCA) — the second half of this phase. Instead of grouping rows or items, PCA reduces the number of columns, and it does it by finding the directions along which the data actually varies.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
