Association rules (market basket)¶
AssociationMiner mines frequent itemsets and statistically sound association
rules from basket-shaped data. There is no predict — the rule and itemset
tables are the deliverable.
No extras to install — mlxtend is a core ml4 dependency.
1. Long-grain holdings¶
One row per (customer, product) holding:
import numpy as np
import pandas as pd
rng = np.random.default_rng(0)
rows = []
for c in range(1_000):
basket = set()
# Plant a rule: bread + butter → jam. bread and butter are deliberately
# NOT perfectly correlated — see the note below.
if rng.random() < 0.5:
basket.add("bread")
if rng.random() < 0.6:
basket.add("butter")
elif rng.random() < 0.2:
basket.add("butter")
if {"bread", "butter"} <= basket and rng.random() < 0.7:
basket.add("jam")
for item in ["milk", "eggs", "coffee", "tea", "sugar"]:
if rng.random() < 0.3:
basket.add(item)
for item in basket:
rows.append({"customer_id": c, "product": item})
df = pd.DataFrame(rows)
Why bread and butter are only partly correlated
If they always co-occurred, support({bread, butter}) == support({bread})
exactly, so confidence({bread, butter} → jam) would equal
confidence({bread} → jam) to the last bit. The two-item rule would then
add zero confidence over its one-item sub-rule, and
min_improvement would correctly flag it as redundant — it would never
appear in the ranked list. Breaking the correlation is what makes the
conjunction carry signal the parts do not, which is the whole point of a
multi-item antecedent.
2. Mine the rules¶
from parleml_ml4.association import AssociationMiner
from parleml_ml4.config.association import ASSOCIATION_PRESETS
cfg = ASSOCIATION_PRESETS["balanced"](
grain="long", entity_col="customer_id", target_key_col="product")
result = AssociationMiner(cfg, outputs_dir="runs/assoc").fit(df)
The balanced tier auto-tunes the support floor, ranks only rules that survive
the false-discovery gate, and prunes redundant longer rules — no min_support
to guess. It also ships single-item consequents by default
(max_consequent_len=1); set it to None for every antecedent/consequent split
mlxtend can generate.
3. Read the shipped rules¶
result.rules is the full ledger; the shipped (ranked, significant) rules are
those with a non-null rank:
ranked = result.rules[result.rules["rank"].notna()]
print(ranked[["antecedents", "consequents", "support", "confidence", "lift",
"q_value", "fwer_significant"]]
.sort_values("lift", ascending=False).head())
# The frequent itemsets the rules were built from, and per-item support:
print(result.itemsets.head())
print(result.item_stats.head())
# What the filters and the caps removed — the counts the tables cannot show:
print(result.rule_stats)
You should see {bread, butter} → {jam} near the top with lift well above 1.
q_value ranks evidence; fwer_significant licenses a claim
q_value is a Benjamini–Yekutieli adjustment computed across the rules
that survived the support, confidence and lift filters — not across every
rule the search considered. Those filters select on the same counts the
test measures, so q_value understates the true false-discovery rate,
most for rules sitting near the floors. Use it to rank evidence strength
within the list.
fwer_significant compares p_value × result.rule_stats.candidate_family_size
against significance_alpha — a Bonferroni bound over the whole search
space. It is conservative, but it holds under any dependence and any
selection, so a rule that passes it can be stated as a finding without
caveat. Neither replaces validating rules on held-out baskets.
3b. Validate on baskets that had no say (holdout_fraction)¶
Everything above is measured on the baskets that selected the rules. Set
holdout_fraction and the miner withholds a random slice before mining —
the lattice, the rules and the gates all come from the train side — then
re-measures every surviving rule on baskets that had no vote:
config = ASSOCIATION_PRESETS["balanced"](
grain="basket", items_col="items", holdout_fraction=0.25,
)
result = AssociationMiner(config).fit(df)
result.holdout.median_lift_ratio # median lift_holdout / lift over ranked rules
result.holdout.held_up_share # share of measurable rules back above min_lift
result.rules["lift_holdout"] # per rule; NaN when unmeasurable
This is the only statistic here that survives the selection critique, and it is
the one to quote. Note the trade it makes: support / confidence / lift
then describe the train baskets, and result.n_baskets_mined — not
result.n_baskets — is the denominator they are shares of. A rule whose
antecedent is too rare in the holdout scores NaN and is counted as
unmeasurable: neither confirmed nor refuted, never a confident number from
three baskets.
4. Opportunity audiences and artifacts¶
result.holdings (one row per (entity, held item)) backs each rule's
opportunity audience — entities holding the antecedents but not the
consequent. Persist and reload the full result:
result.save("assoc.joblib")
# Also written under runs/assoc/deploy/association/:
# association_result.joblib, rules.parquet, items.parquet,
# itemsets.parquet, holdings.parquet
Variations¶
- Transaction log? Use
grain="event"with atimestamp_col(orbasket_col) to window rows into per-entity baskets. - Pre-packed baskets? Use
grain="basket"withitems_col+item_delimiter. - Focus / exclude items, or change the rank metric? See the Association guide.
See the Association guide and the
AssociationMiner reference.
Next: Recommendation · Association guide