What it actually does
A transaction is a set of items — a basket, a session, a patient’s diagnosis codes — and the question is which items keep turning up together. Count how often every combination appears, keep the combinations that appear often enough (high support), and from each frequent combination read off rules of the form X ⇒ Y whose confidence — of the baskets containing X, the fraction that also contain Y — is high enough to act on. There are no coordinates, no vectors and no distances here: a basket is an unordered bag with no magnitude, so every comparison is an overlap of sets. The one structural fact that makes this tractable is that a combination can only be frequent if all of its sub-combinations are, which lets the search discard most of an astronomically large lattice without ever counting it. What the method cannot do is tell you why items co-occur, and the first number everyone reaches for, confidence, is misleading whenever the consequent is popular — which is why lift exists.
- Transactions are SETS: quantity, order and repeat purchases are discarded before mining, which is only acceptable when presence is what matters
- Items are atomic symbols with no internal structure — there are no coordinates, and similarity is nothing more than co-occurrence overlap
- Frequency is a proxy for importance, so a valuable but rare combination is invisible below minsup (the rare-item problem), however strongly the items are associated
- Transactions are exchangeable and the co-occurrence pattern is stable across the mined window, so the rules transfer to future baskets
- The item vocabulary and granularity are fixed and consistent: a SKU that changes ID or a variant split across versions silently dilutes support
- The thresholds are meaningful, which they are not by default — there is no statistical principle that fixes minsup or minconf, so they encode a business decision about how many rules can be acted on
Constrained program
Return every rule X ⇒ Y whose itemset appears in at least a fraction σ of the transactions and whose confidence is at least γ. Nothing is minimised or maximised: the thresholds define a feasible set and the algorithm lists all of it, exactly and deterministically, with no local optima and no randomness. That is why "constrained program" is the honest label and "loss" is not — a loss implies a scalar being driven down by fitting, a model with parameters that could be better or worse, and a generalization gap, and none of that exists here. The closest thing to an objective is the post-hoc ranking of the surviving rules by lift, supp(X ∪ Y) / (supp(X)·supp(Y)), which is the observed co-occurrence divided by what independence would predict. Confidence alone is the number to distrust: if Y sits in 80 percent of all baskets, then nearly any X yields a confidence near 0.8 by chance, and lift near 1 is what exposes that.
- the multiset of transactions; each t is a SET of items drawn from the item vocabulary, so quantity, order and repeats are all discarded before mining starts
- the fraction of transactions that contain every item of S — the only quantity ever counted, and everything else is a ratio of these
- minimum support (minsup): the frequency floor an itemset must clear. It is the single parameter that controls how much of the lattice is explored, and therefore the runtime
- minimum confidence (minconf): the floor on supp(X ∪ Y) / supp(X), the conditional frequency of Y given X. Asymmetric — it reads X ⇒ Y and says nothing about Y ⇒ X
- a rule: disjoint itemsets, antecedent and consequent. "Implies" is a statement about co-occurrence in the mined data, not about causation or about any individual basket
- the feasible set — every rule satisfying both thresholds. It is the OUTPUT, enumerated exhaustively; there is no objective value being optimized over it
How it is minimized
Level-wise breadth-first search pruned by downward closure (Apriori); candidate-free FP-tree growth as the alternative (FP-Growth)
- the frequent itemsets of size k — the survivors of level k, and the only inputs the next level is allowed to use
- candidate (k+1)-itemsets, built by joining two frequent k-itemsets that share k−1 items and then pruned: every k-subset must itself be frequent
- downward closure, also called the anti-monotone property: adding an item can only lower support. This is the entire reason the search is feasible
- the one database scan per level: count each surviving candidate and keep it if it clears the floor
The lattice of all itemsets over m items has 2^m nodes, which is not enumerable for any real catalogue, so the method cannot count everything. Downward closure rescues it: if a set is infrequent then every superset is too, so the moment a set fails its support test its entire upward cone is deleted from the search without being visited. Apriori exploits that breadth-first — frequent k-itemsets generate (k+1)-candidates, a candidate with any infrequent subset is discarded before it is counted, and the survivors are counted in one pass over the data. Its cost is the candidate set itself, which can be enormous at low support. FP-Growth removes it: two passes compress the database into a prefix tree (the FP-tree) in which transactions that share a frequent-item prefix share a path, and mining then recurses on each item’s conditional tree, growing patterns directly with no candidate ever generated. Eclat takes a third route, storing for each item the set of transaction ids that contain it, so the support of a union is the size of an intersection. All three return exactly the same set of frequent itemsets — they differ only in how they avoid counting what cannot qualify, which is why the choice is an engineering decision about memory and data shape, never a modelling one. Rule generation is then a cheap second stage: for each frequent itemset, every split into antecedent and consequent has its confidence read off two stored supports, and downward closure guarantees that both are already known.
minsup (σ)
The support floor, and the dial that controls everything: runtime, memory, and how many rules come out. Halving it can multiply the frequent itemsets by orders of magnitude, so it is chosen from what the downstream consumer can use rather than from a statistical rule (0.0001 to 0.05 on retail baskets; 0.01 to 0.2 on dense data such as survey or log features; often set as an absolute count so that every rule rests on at least a few dozen baskets)
minconf (γ)
The confidence floor on each rule. A weak filter on its own, because it is inflated by popular consequents — treat it as a first cut and rank or filter by lift (0.2 to 0.8)
min lift / conviction
The filter that actually separates signal from popularity. Lift above 1 means Y appears more often with X than independence predicts; conviction adds the direction, scoring how often the rule would be wrong if X and Y were independent (lift 1.2 to 3 as a floor; lift in the tens usually means a rare pair, so check the support behind it)
max itemset length
Caps the depth of the lattice. Most deployed recommenders use pairs and triples, and the cap is also what bounds worst-case behaviour on very wide baskets (2 to 4)
item granularity
SKU, brand, or category. Coarser levels raise every support at the cost of specificity, and a taxonomy lets you mine across levels — the single most effective lever against a long tail of items too rare to ever clear minsup
algorithm
Apriori, Eclat, or FP-Growth. Same output; FP-Growth wins on dense data with many frequent items, Apriori and Eclat are simpler and competitive when minsup is high and baskets are short
Terminates and is exact: every itemset meeting the support floor is returned, every one that does not is not, and the answer is independent of algorithm, order and initialization — there is nothing to converge to and no local optimum to get stuck in. The failure modes are therefore about cost and meaning, not optimization. Cost: the worst case is exponential in the number of items, and in practice the runtime is governed almost entirely by minsup — a threshold set a little too low produces millions of frequent itemsets, and a single long basket of 40 items contributes up to 2^40 subsets. Rule explosion: the number of rules is far larger than the number of itemsets, since every frequent itemset of size k splits into 2^k − 2 candidate rules, and most of them are redundant restatements. Meaning: with thousands of candidate rules tested against a threshold, some clear it by chance, and the output carries no p-value or correction by default, so spurious rules are expected rather than exceptional. Hold out transactions, or apply a multiple-testing correction, before treating any rule as a finding.
Worst case O(2^m) itemsets for m distinct items — a property of the output space, not of a bad implementation. Apriori makes one pass over the database per level, so up to L passes for the longest frequent itemset of length L, with candidate generation and the subset-membership prune dominating at low support. FP-Growth makes exactly two passes to build the tree, whose size is bounded by the total number of frequent-item occurrences and is usually far smaller thanks to prefix sharing, then recurses on conditional trees; memory is the tree, which is the binding constraint on dense data. Eclat stores m tid-bitsets of n/64 words each, and a support count is an AND plus a popcount. Rule generation costs O(F · 2^L) for F frequent itemsets of length up to L, which is why a cap on length matters even though mining itself is cheap.
How, where, and why it is used
Time-Series Forecasting
Not applicableA transaction is an unordered set with no timestamp semantics, so there is no series to extrapolate and no target to predict — the output is a set of co-occurrence statements, not a forecast. The ordered cousin, sequential pattern mining (GSP, PrefixSpan), keeps order and is a different algorithm; and any seasonality in the rules appears only if you mine separate windows and compare them by hand.
Anomaly Detection
AdaptedMine rules on a trusted-clean window, keep only the near-certain ones (confidence close to 1, high support), then score a new transaction by the rules it violates: the antecedent X is present but the consequent Y is absent. A transaction that breaks several strong rules, or one very strong rule, is the anomaly, and the broken rule is a human-readable explanation of why. It is an invariant-checking detector, not a density model, and it only catches violations of regularities that were frequent enough to be mined.
- Configuration and log invariant mining: "service A started ⇒ service B started", with a missing consequent flagged as a fault
- Claims and billing audits, where a procedure code that almost always travels with another is billed without it
- Intrusion and policy checks over sets of flags or permissions, where a combination that never co-occurs in normal traffic appears
The appeal is explainability: the alert is a sentence an analyst can read and dispute, which a density score or a forest’s path length is not. The limitation is just as specific. It detects only the absence of an expected item, never the presence of an unexpected one, so a rare novel combination that violates no mined rule is invisible; and it inherits every weakness of the mining, including that a rule mined from contaminated data is a rule that encodes the anomaly as normal. Use it for known-structure data where invariants are the thing you care about, and a density or isolation method where novelty is.
- Mine on a window confirmed clean, or the anomalies you are looking for become frequent and are learned as normal
- Keep only rules with confidence near 1 and ample support; a rule at 0.7 is violated by 30 percent of normal transactions
- Prefer closed or maximal itemsets and de-duplicate redundant rules, or one underlying violation is reported as dozens of alerts
- Weight each violated rule by its lift or conviction, so that violating a rule between rare items counts for more than violating one between common ones
Precision@k against confirmed incidents, with PR-AUC rather than ROC-AUC, and a per-rule false-positive rate on held-out clean data — a rule that fires on a few percent of normal baskets is a noise source regardless of its confidence on the training window. Compare against an isolation forest on the same data; if the rules add nothing, the invariants are not what is breaking.
- Reading a missing consequent as an anomaly when it is simply an out-of-stock or a not-yet-logged event
- Rules mined from a window containing the incident, which rewrites the incident as the rule
- No coverage of novel items at all: an item never seen in mining belongs to no rule and so can never violate one
Optimization
AdaptedMining is search, not optimization, so the honest fit is as an INPUT to an optimizer. The rules supply the affinity coefficients — which pairs of products are bought together, with what lift — and a separate model uses them: shelf-adjacency or planogram layout as an assignment problem with affinity weights, or bundle selection as a knapsack over candidate bundles scored by lift and margin. The decision, its constraints and its objective live in that second stage; this one produces its data.
- Retail store layout and shelf adjacency, where co-purchase affinity feeds a placement or assignment model
- Bundle and promotion design, where candidate bundles are the high-lift itemsets, ranked by margin
- Warehouse slotting, where items that ship together are placed near the pick path together
It is worth including only because the data it produces is the right shape for a combinatorial optimizer and expensive to get any other way; claiming more would be dishonest. The rules describe what people DID buy together under the existing layout and promotions, not what they would buy under a new one — a layout placing two items together changes the co-purchase rate it was based on. Treat the rules as a prior to be tested by experiment, and the optimizer as the entry that does the deciding.
- Mine at the granularity of the decision: category-level rules to place aisles, SKU-level rules to place facings
- Attach margin and shelf space to each candidate itemset before it reaches the optimizer; support alone optimizes for volume
- Restrict to pairs and triples, the sizes a layout can physically act on
Not the rule metrics: the layout or bundle has to be evaluated by incremental revenue or attach rate in a controlled test against the existing arrangement. A rule table that looks excellent and moves no sales has described the data and not the lever.
- Treating observed co-purchase as the effect of putting items together, which confounds layout with the existing layout
- Optimizing for support, which just rediscovers the best sellers and recommends putting milk next to bread
- Mining across stores with different assortments and layouts as though baskets were exchangeable
Recommendation & Ranking
Primary fitThe classic use: "frequently bought together". Mine rules over order baskets, index them by antecedent, and at request time take the items in the user’s current cart, look up rules whose antecedent is contained in it, and rank the consequents not already present by lift (or confidence for conservative lists). It is a rule-based item-to-item recommender that depends only on the current basket, so it needs no user history at all.
- Cart-page and product-page "frequently bought together" modules in e-commerce
- Grocery and retail cross-sell suggestions at checkout and in coupon targeting
- Next-best-offer and bundle suggestions where the unit is an order, not a user profile
- Content and feature co-usage ("customers who use A also enable B") in software products
Pick this over a learned recommender when the signal is the current basket, when cold-start users are common (it needs nothing but the cart), and when the recommendation has to be explained in one line to a merchandiser. It is also trivially cheap to serve. Choose collaborative filtering instead when you have rich per-user history, because the contrast is exact: association rules compute the same recommendations for every user holding the same basket, and so contain no personalization at all, whereas matrix factorization and item-based nearest neighbours tailor to the individual. The similarity here is set overlap — Jaccard, its asymmetric relatives confidence and lift, over the sets of baskets in which items occur — which is why no embedding, normalization or coordinate system is involved. Cosine on binary vectors is close kin to lift, and swapping to a Euclidean distance on basket indicator vectors would make large baskets look far from everything.
- Define the transaction deliberately: order, session, or customer-day each give different rules, and a mismatch with the serving context is the most common source of useless output
- Collapse quantity to presence, and drop non-product items such as bags, shipping and gift wrap before mining
- De-duplicate near-identical SKUs (colour, size variants) up to a parent item, or support is split across variants and nothing clears minsup
- Remove or down-weight blockbuster items, which otherwise appear as the consequent of every rule
Offline: hit-rate@k or recall@k by holding one item out of each held-out basket and checking whether it is in the top-k suggestions, against a top-popular baseline — which a rule set must beat, and often barely does on short lists. Online: attach rate and incremental revenue in an A/B test, since an offline hit on an item the shopper would have bought anyway is not value.
- High-confidence rules whose consequent is the most popular item, which any popularity baseline already recommends
- Recommending items complementary in the data because the layout or a promotion put them together
- Stale rules after a catalogue change, serving suggestions for discontinued or out-of-stock products
- Rule explosion handed straight to a merchandiser who cannot review tens of thousands of lines
Risk & Fraud
ViableMine class association rules from labelled cases: itemsets of categorical attributes and behavioural flags (new device, mismatched billing country, high-value first order) restricted to have the confirmed-fraud label as consequent. The surviving rules, ranked by lift against the base fraud rate, become candidate screening rules for a rule engine or an analyst queue, each one human-readable and auditable.
- Candidate rule discovery for payment-fraud rule engines, reviewed by an analyst before deployment
- Insurance and claims triage, where co-occurring attributes of confirmed-abuse cases form screening heuristics
- Compliance monitoring, where combinations of flags that preceded past violations are surfaced
It is viable rather than primary because a gradient-boosted tree or a logistic regression on the same attributes will usually beat a rule set at detection, and the case for rules is regulatory and operational: a rule is auditable, reviewable line by line, and can be shipped into an existing rule engine without a model-serving stack. The failure mode to expect is class imbalance — fraud is rare, so a useful rule has low absolute support and minsup must be set on the fraud-only subset, which makes spurious rules far likelier.
- Discretize continuous attributes into bands before mining, since the method sees only presence of items; the band edges are a modelling decision that changes the rules
- Mine on the fraud subset against the base population and rank by lift rather than confidence, since the consequent is the rare class
- Use a time-ordered split and only labels that have matured — chargebacks arrive weeks after the transaction
Precision and recall at the action threshold on a time-forward holdout, plus the rule’s false-positive volume in absolute terms, which is what an analyst queue feels. Compare against a tree ensemble on the same features; a rule is worth keeping when it is auditable and nearly as good, not when it is merely interpretable.
- Multiple-testing: thousands of candidate rules on a rare class yields rules that fit noise, so validate on later data
- Rules that encode how the previous system flagged cases, learning the old rule engine rather than the fraud
- Adversaries adapting to a published rule, which decays faster than a model’s opaque decision boundary
What it costs to run
Mining is the training. It is a batch job whose cost is set almost entirely by minsup and by basket length rather than by the row count: millions of short baskets mine in seconds to minutes, while a low support floor on long baskets can run out of memory in a candidate set or a conditional tree. Cap the itemset length, set support as an absolute count, and mine at a coarser item level first. Illustrative, not a measured benchmark.
A lookup: given a basket, find rules whose antecedent is a subset of it and rank their consequents. Precompute for each item the top-N consequents, or index rules by antecedent items, and serving is a few hash lookups — microseconds, with a model that is a table of a few hundred thousand rules. Pairs-only rules reduce to an item-to-item neighbour list, which is cheap to cache and trivial to explain.
Nightly to weekly for a retail catalogue, and on every assortment change for a fast-moving one, since the rule table is a snapshot of co-purchase and goes stale with seasons, promotions and new products. Mining is cheap enough that the constraint is the review process for the rules, not the compute.
- Track the support and confidence of the top rules on fresh transactions against their mined values — a rule whose measured confidence has dropped is no longer true
- Monitor the share of live baskets that match at least one rule (coverage), which falls when the catalogue changes faster than the rules
- Watch the number of rules per mining run; a sudden jump means a threshold, item-mapping or data change rather than new insight
- Measure attach rate or click-through for rule-driven suggestions against a popularity baseline, since rules can decay into recommending bestsellers
- Rule explosion: report only closed or maximal itemsets and de-duplicate redundant rules (a rule is redundant if a more general antecedent already attains the same confidence), or no human can review the output
- The definition of a transaction — order, session, customer-day — silently decides the rules, and the mining unit must match the serving unit
- A single very long basket (a B2B order of 200 lines) can blow up the lattice; cap basket length or sample before mining
- Item identifiers must be stable across mining and serving; remapping SKUs invalidates every stored rule without any error
- Popularity bias: the top rules are dominated by best sellers, so rank by lift or conviction and apply a minimum support for the consequent as well
- Spurious rules from multiple testing are normal; validate on a held-out period before promoting any rule, and never present a rule as a causal effect
Pros and cons, in context
Exact and exhaustive: every rule meeting the thresholds is returned
No initialization, no local optima and no randomness — the same data and thresholds always give the same rules, which makes the method easy to test and to audit. The guarantee is about the search; whether the rules are TRUE of future data is a separate matter the algorithm is silent on.
Output is a human-readable sentence, not a coefficient
A merchandiser, a compliance reviewer or a fraud analyst can read, dispute and veto each rule, and ship it into a rule engine without a model-serving stack. Decisive in regulated or review-heavy settings; irrelevant when only predictive accuracy is graded.
Needs no labels, no user history and no feature engineering beyond defining a transaction
A basket and an item list are the whole input, so it works on cold-start users, anonymous sessions and tabular categorical data alike. It stops being an advantage the moment per-user history is rich enough for collaborative filtering to personalize.
Downward closure makes an exponential space tractable in practice
Pruning whole upward cones the moment a subset fails means the work follows what is frequent rather than what is possible, and FP-Growth and Eclat reduce the constant factors further. It holds when minsup is set sensibly and baskets are short; it does not rescue a threshold set too low.
Rule explosion: the output is usually far too large to use
A frequent itemset of size k splits into 2^k − 2 candidate rules and most are redundant restatements of each other. Closed or maximal itemsets, a lift floor, a length cap and redundancy elimination are all required, and without them the table is a data dump rather than an answer.
Spurious rules from multiple testing, with no significance attached
Test tens of thousands of candidate rules against a threshold and some clear it by chance, yet the output carries no p-value or correction. Validate on a held-out period or apply a correction such as Bonferroni or a permutation test before treating any rule as real, especially on rare classes.
Popularity bias: confidence rewards popular consequents
If Y is in 80 percent of baskets then nearly any X implies it with confidence near 0.8. Lift (observed over independent expectation) and conviction correct for this; a table ranked by confidence is mostly a list of best sellers.
No personalization, and no signal below the support floor
Two users holding the same basket receive the same suggestions, whatever their history, and the long tail of rare items never clears minsup, so the catalogue it covers is the head. Matrix factorization and item-based nearest neighbours address the first; taxonomies or coarser item levels address the second, only partly.
Co-occurrence is not causation, and the rules are a snapshot
Items that sell together because a promotion or the store layout put them together will not keep selling together when moved, so a rule is evidence about the past and a poor basis for an intervention without an experiment.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""Association rules by Apriori - support counting and rule generation, literally.
A transaction is a SET of items. support(S) is the fraction of transactions that
contain S, and every other quantity here is a ratio of supports. The search is
level-wise: frequent k-itemsets generate (k+1)-candidates, a candidate with any
infrequent subset is discarded unseen (downward closure), and the survivors are
counted in one pass over the data.
"""
from itertools import combinations
def support(itemset, transactions):
"""Fraction of transactions that contain every item of itemset."""
containing = 0
for transaction in transactions:
if itemset <= transaction:
containing += 1
return containing / len(transactions)
def frequent_itemsets(transactions, min_support):
"""Return {frozenset: support} for every itemset with support >= min_support."""
items = set()
for transaction in transactions:
for item in transaction:
items.add(item)
frequent = {}
level = []
for item in sorted(items):
candidate = frozenset([item])
candidate_support = support(candidate, transactions)
if candidate_support >= min_support:
frequent[candidate] = candidate_support
level.append(candidate)
size = 1
while level:
# Join: two frequent k-itemsets whose union has k+1 items form a candidate.
candidates = set()
for left in level:
for right in level:
union = left | right
if len(union) == size + 1:
candidates.add(union)
next_level = []
for candidate in candidates:
# Prune by downward closure: every k-subset must already be frequent.
all_subsets_frequent = True
for item in candidate:
if (candidate - {item}) not in frequent:
all_subsets_frequent = False
break
if not all_subsets_frequent:
continue
candidate_support = support(candidate, transactions)
if candidate_support >= min_support:
frequent[candidate] = candidate_support
next_level.append(candidate)
level = next_level
size += 1
return frequent
def generate_rules(frequent, min_confidence):
"""Split each frequent itemset into every antecedent => consequent pair."""
rules = []
for itemset, itemset_support in frequent.items():
if len(itemset) < 2:
continue
members = sorted(itemset)
for size in range(1, len(members)):
for antecedent_items in combinations(members, size):
antecedent = frozenset(antecedent_items)
consequent = itemset - antecedent
# Downward closure guarantees both subsets were counted.
confidence = itemset_support / frequent[antecedent]
if confidence < min_confidence:
continue
lift = confidence / frequent[consequent]
if confidence == 1.0:
conviction = float("inf")
else:
conviction = (1.0 - frequent[consequent]) / (1.0 - confidence)
# Jaccard of the two transaction-id sets: the symmetric relative
# of confidence, normalized by the union rather than by X alone.
jaccard = itemset_support / (
frequent[antecedent] + frequent[consequent] - itemset_support
)
rules.append(
{
"antecedent": antecedent,
"consequent": consequent,
"support": itemset_support,
"confidence": confidence,
"lift": lift,
"conviction": conviction,
"jaccard": jaccard,
}
)
return rulesOne full scan of the database per candidate per level, in interpreter loops over Python sets; the candidate set, not the data, is what blows up at low support.
How this language actually wants it written.
"""Association rules - typed Apriori with prefix join, subset prune, immutable rules."""
from __future__ import annotations
import math
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from itertools import combinations
Itemset = tuple[int, ...] # sorted ascending, no duplicates
@dataclass(frozen=True)
class Rule:
antecedent: Itemset
consequent: Itemset
support: float
confidence: float
lift: float
conviction: float
jaccard: float
@dataclass(frozen=True)
class MiningResult:
counts: dict[Itemset, int]
n_transactions: int
rules: tuple[Rule, ...]
def _join(level: Sequence[Itemset]) -> list[Itemset]:
"""F_k x F_k join on a shared (k-1)-prefix.
Items are kept sorted and the level is lexicographically sorted, so every
candidate is generated exactly once, and the scan can stop at the first
tuple whose prefix differs rather than comparing all pairs.
"""
candidates: list[Itemset] = []
for index, left in enumerate(level):
for right in level[index + 1 :]:
if left[:-1] != right[:-1]:
break
candidates.append(left + (right[-1],))
return candidates
def _all_subsets_frequent(candidate: Itemset, previous: set[Itemset]) -> bool:
"""Downward closure: every k-subset of a (k+1)-candidate must be frequent."""
return all(
candidate[:skip] + candidate[skip + 1 :] in previous
for skip in range(len(candidate))
)
def frequent_itemsets(
transactions: Sequence[frozenset[int]],
min_support: float,
max_length: int = 4,
) -> dict[Itemset, int]:
"""Counts of every itemset with support >= min_support. Raises ValueError."""
if not transactions:
raise ValueError("need at least one transaction")
if not 0.0 < min_support <= 1.0:
raise ValueError(f"min_support must lie in (0, 1], got {min_support}")
if max_length < 1:
raise ValueError(f"max_length must be at least 1, got {max_length}")
min_count = math.ceil(min_support * len(transactions))
singles: dict[int, int] = {}
for transaction in transactions:
for item in transaction:
singles[item] = singles.get(item, 0) + 1
counts: dict[Itemset, int] = {
(item,): count for item, count in sorted(singles.items()) if count >= min_count
}
level: list[Itemset] = list(counts)
# Bounded by max_length: the lattice is exponential, so depth is a guard.
for _ in range(max_length - 1):
if not level:
break
previous = set(level)
next_level: list[Itemset] = []
for candidate in _join(level):
if not _all_subsets_frequent(candidate, previous):
continue
candidate_set = frozenset(candidate)
count = sum(1 for transaction in transactions if candidate_set <= transaction)
if count >= min_count:
counts[candidate] = count
next_level.append(candidate)
level = next_level
return counts
def generate_rules(
counts: dict[Itemset, int],
n_transactions: int,
min_confidence: float,
min_lift: float = 1.0,
) -> tuple[Rule, ...]:
"""Every rule clearing both floors, ranked by lift then confidence."""
if not 0.0 <= min_confidence <= 1.0:
raise ValueError(f"min_confidence must lie in [0, 1], got {min_confidence}")
rules: list[Rule] = []
for itemset, itemset_count in counts.items():
if len(itemset) < 2:
continue
for size in range(1, len(itemset)):
for antecedent in combinations(itemset, size):
consequent = tuple(item for item in itemset if item not in antecedent)
antecedent_count = counts.get(antecedent)
consequent_count = counts.get(consequent)
if antecedent_count is None or consequent_count is None:
continue # unreachable under downward closure; fail soft
confidence = itemset_count / antecedent_count
consequent_support = consequent_count / n_transactions
lift = confidence / consequent_support
if confidence < min_confidence or lift < min_lift:
continue
conviction = (
math.inf
if confidence >= 1.0
else (1.0 - consequent_support) / (1.0 - confidence)
)
rules.append(
Rule(
antecedent=antecedent,
consequent=consequent,
support=itemset_count / n_transactions,
confidence=confidence,
lift=lift,
conviction=conviction,
jaccard=itemset_count
/ (antecedent_count + consequent_count - itemset_count),
)
)
rules.sort(key=lambda rule: (rule.lift, rule.confidence), reverse=True)
return tuple(rules)
def mine(
baskets: Iterable[Iterable[int]],
min_support: float,
min_confidence: float,
max_length: int = 4,
) -> MiningResult:
transactions = [frozenset(basket) for basket in baskets]
counts = frequent_itemsets(transactions, min_support, max_length)
rules = generate_rules(counts, len(transactions), min_confidence)
return MiningResult(counts=counts, n_transactions=len(transactions), rules=rules)What changed: The structural change is the join. The work version compares every pair of frequent itemsets and deduplicates through a set; here itemsets are sorted tuples and the level is kept lexicographically sorted, so the classic F_k × F_k join on a shared prefix generates each candidate exactly once and stops scanning at the first prefix mismatch. The prune becomes a membership test against the previous level, depth is bounded by max_length because the lattice is exponential, and supports are kept as integer counts so thresholds compare exactly rather than through float division. Results are frozen dataclasses with lift, conviction and Jaccard attached, rules are ranked by lift rather than left in dict order, and every parameter is validated before any counting starts.
Still one scan of the transactions per candidate per level, but with each candidate generated once and the prune a single hash probe per subset.
Faster, and the justification for each change.
"""Association rules - vertical bitsets, depth-first Eclat, preallocated scratch.
Horizontal Apriori rescans every transaction for every candidate. Storing the
data VERTICALLY inverts that: item i keeps one bit per transaction saying
whether it is present, so the support of an itemset is the popcount of the AND
of its items' bitsets. No candidate is counted by scanning the data again.
Needs NumPy >= 2.0 for np.bitwise_count.
"""
from itertools import combinations
import numpy as np
from numpy.typing import NDArray
Bitsets = NDArray[np.uint64]
def pack_vertical(transactions: list[list[int]], n_items: int) -> Bitsets:
"""(n_items, words): row i is item i's transaction-id bitset."""
n = len(transactions)
lengths = np.fromiter((len(t) for t in transactions), dtype=np.int64, count=n)
rows = np.concatenate([np.asarray(t, dtype=np.int64) for t in transactions])
columns = np.repeat(np.arange(n, dtype=np.int64), lengths)
words = (n + 63) // 64
dense = np.zeros((n_items, words * 64), dtype=np.uint8)
dense[rows, columns] = 1 # scatter every (item, transaction) at once
packed = np.packbits(dense, axis=1, bitorder="little")
return np.ascontiguousarray(packed).view(np.uint64)
class EclatMiner:
"""Depth-first frequent-itemset mining over a vertical bitset index."""
def __init__(self, vertical: Bitsets, min_count: int, max_length: int) -> None:
if max_length < 1:
raise ValueError(f"max_length must be at least 1, got {max_length}")
self._vertical = np.ascontiguousarray(vertical, dtype=np.uint64)
self._min_count = min_count
self._max_length = max_length
# One scratch bitset per depth, allocated once: the search never
# allocates, and a child never overwrites its parent's buffer.
self._scratch = np.empty(
(max_length + 1, self._vertical.shape[1]), dtype=np.uint64
)
self.found: dict[tuple[int, ...], int] = {}
def mine(self) -> dict[tuple[int, ...], int]:
counts = np.bitwise_count(self._vertical).sum(axis=1)
frequent = [int(item) for item in np.flatnonzero(counts >= self._min_count)]
for position, item in enumerate(frequent):
self.found[(item,)] = int(counts[item])
np.copyto(self._scratch[1], self._vertical[item])
self._extend((item,), 1, frequent[position + 1 :])
return self.found
def _extend(self, prefix: tuple[int, ...], depth: int, tail: list[int]) -> None:
if depth >= self._max_length or not tail:
return
parent = self._scratch[depth]
child = self._scratch[depth + 1]
# Pass 1: which tail items survive? Each AND writes into the SAME child
# buffer and only the count is kept, so no per-item array is retained.
survivors: list[int] = []
for item in tail:
np.bitwise_and(parent, self._vertical[item], out=child)
if int(np.bitwise_count(child).sum()) >= self._min_count:
survivors.append(item)
# Pass 2: recompute each survivor's bitset and recurse. Siblings share
# the buffer, so the intersection is redone rather than stored.
for position, item in enumerate(survivors):
np.bitwise_and(parent, self._vertical[item], out=child)
extended = prefix + (item,)
self.found[extended] = int(np.bitwise_count(child).sum())
self._extend(extended, depth + 1, survivors[position + 1 :])
def generate_rules(
counts: dict[tuple[int, ...], int],
n_transactions: int,
min_confidence: float,
min_lift: float = 1.0,
) -> list[tuple[tuple[int, ...], tuple[int, ...], float, float]]:
"""(antecedent, consequent, confidence, lift); output-bound, so left in Python."""
rules = []
for itemset, itemset_count in counts.items():
for size in range(1, len(itemset)):
for antecedent in combinations(itemset, size):
consequent = tuple(item for item in itemset if item not in antecedent)
confidence = itemset_count / counts[antecedent]
lift = confidence * n_transactions / counts[consequent]
if confidence >= min_confidence and lift >= min_lift:
rules.append((antecedent, consequent, confidence, lift))
rules.sort(key=lambda rule: rule[3], reverse=True)
return rulesWhat changed: The data layout changes, and with it the whole cost model. Horizontal Apriori scans every transaction for every candidate; here each item stores one bit per transaction, so the support of an itemset is a popcount over the AND of its items’ bitsets and the database is never rescanned. Building the index replaces the per-transaction Python loop with one scatter and a packbits. The search becomes depth-first (Eclat) so a prefix’s intersection is reused by every extension of it, with one scratch bitset per depth allocated up front and written in place. Siblings share a buffer, so a survivor’s intersection is recomputed rather than stored — one extra AND to keep memory at depth times words instead of siblings times words. Rule generation is output-bound, so it stays in plain Python.
Eliminate Python-level loops over samples
Support counting is no longer a loop over transactions: 64 transactions are tested per word by an AND, and building the index is one fancy-indexed scatter plus packbits instead of a per-transaction loop.
Trade-off: The dense uint8 staging array costs n_items times n bytes before packing, which is the memory ceiling on a wide catalogue — pack in chunks or build from a sparse form there.
Ensure contiguous memory layout and a single dtype
The index is one C-contiguous uint64 matrix, so each item’s bitset is a contiguous row and every AND and popcount streams sequential memory with no dtype conversion.
Trade-off: A bitset index is inefficient on very sparse items, where a sorted list of transaction ids is far smaller than n/64 words — hybrid representations exist for exactly that long tail.
Pre-allocate output arrays and use in-place operations
One scratch bitset per depth is allocated up front and every intersection is written with out=, so the recursion performs no allocation at all, however many itemsets it visits.
Trade-off: Depth-indexed buffers are shared by siblings, so survivors’ intersections are recomputed rather than cached, and the miner holds mutable state — it is not reentrant or thread-safe.
Each support count is n/64 word ANDs plus popcounts and no database scan; recursion allocates nothing. Illustrative, not a measured benchmark.