Skip to content

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 a timestamp_col (or basket_col) to window rows into per-entity baskets.
  • Pre-packed baskets? Use grain="basket" with items_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