Association rules¶
AssociationMiner mines frequent itemsets and statistically sound
association rules (market-basket analysis) from basket-shaped data. Where the
recommender and LTR backends score
per entity, association rules describe the catalogue globally — "customers
who hold A and B also hold C" — as auditable, business-readable statements with
support / confidence / lift and a significance verdict attached. Same data, a
third lens.
In short
- Pick the input shape with
grain(long,eventorbasket) and start fromASSOCIATION_PRESETS["balanced"]. - The shipped rules are the rows of
result.ruleswith a non-nullrank. - The support floor is searched for you; every rule is tested for significance and pruned against its shorter sub-rules.
q_valueis not a calibrated false-discovery rate. To quote a rule, setholdout_fractionand readlift_holdout.- Read
lift_breadth_adjustedbesidelift: broad baskets inflate plain lift.
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",
target_group_col="category", # optional — display / network colouring only
)
result = AssociationMiner(cfg, outputs_dir="runs/assoc").fit(df)
ranked = result.rules[result.rules["rank"].notna()] # the shipped rules
ranked[["antecedents", "consequents", "support", "confidence", "lift"]].head()
No extra needed
Mining runs on mlxtend, which is a core ml4 dependency — nothing extra
to install. There is no predict: the deliverables are the rule and
itemset tables.
Input grains¶
AssociationConfig.grain selects the input shape:
One row per (entity, item) holding — product holdings, subscriptions. The
basket is all items held by an entity.
A transaction log (one row per purchase). Rows are grouped into baskets per
entity, optionally windowed by a timestamp
(basket_time_granularity, a pandas floor freq — e.g. "D" for one basket
per customer-day) or an explicit basket_col.
The quality stack¶
Raw frequent-itemset mining produces thousands of redundant, spurious rules.
The miner applies six defenses. All are on by default at the balanced tier
except the holdout (defense 5), which is opt-in because it changes what the
headline numbers mean:
- Auto-tuned support floor. Rather than guess
min_support, a bounded deterministic search targetstarget_itemset_countmulti-item frequent itemsets — singletons never yield a rule, so they do not count (the probe trail and the reason the search stopped are recorded on the result). In auto mode every itemset is backed by at least 5 baskets; pinningmin_supportskips the search and that floor, so a low pin will report itemsets backed by one or two baskets (the run warns when it does).target_itemset_countis a floor-pushing target, not a cap — the independent upper guard ismax_lattice_factor, which makes the search ascend if a probe overshoots. -
Statistical significance. Each rule gets a one-sided Fisher's exact test (is the confidence genuinely above the consequent's base rate?), corrected across rules with Benjamini–Yekutieli FDR at
significance_alpha(default0.05). Yekutieli rather than Hochberg because rules from an overlapping itemset lattice are dependent in both directions, which voids Hochberg's assumption. Insignificant rules are retained but flagged (significant=False,rank=NA) so they never ship silently whensignificance=True— with it off, they are ranked and shipped, and only the flag records the test outcome.q_valueis not a calibrated FDRIt is adjusted across the rules that survived the support, confidence and lift filters — not across the rules the search considered. Those filters select on the same counts the test measures, so
q_valueunderstates the true false-discovery rate, most for rules near the floors.fwer_significantis the assumption-free companion:p_value × candidate_family_size ≤ alpha, a Bonferroni bound over the whole search space that holds under any dependence. Neither replaces validating rules on held-out baskets, which is whatholdout_fraction(defense 5 below) provides. -
Interpretability (minimum-improvement) pruning. A rule
{A,B} → Cmust beat every proper sub-rule with the same consequent —A → C,B → C, and the empty antecedent{} → C(i.e. C's own base rate) — bymin_improvementconfidence, or it is flagged with the winning sub-rule's id inpruned_by(or"baseline"for the base-rate case). Checking only the immediate sub-antecedents would be strictly weaker: confidence is not monotone in the antecedent, so a 3-item rule can beat every 2-item parent while being far worse than a 1-item ancestor. With a fixed consequent a confidence gain is a proportional lift gain (lift = confidence / P(consequent)), so the longer rule ships only when its extra items genuinely add strength — in the style of the filter Meta describes for its production root-cause miner (Fast Dimensional Analysis, arXiv:1911.01225).min_improvement=0does not disable pruning — the comparison is a strict<, so a rule whose confidence is below its best sub-rule is still flagged. Only a negative margin would be a true off switch, and that is not permitted.Two further tests stop a passenger item from riding on a strong rule. With
significanceon, a longer rule must be productive: each extra item must significantly raise the consequent's rate. With or withoutsignificance, it must also beat its shorter rule at equal basket breadth bymin_superset_lift_gain(default0.10).Details: the productive-rule and equal-breadth tests
A fixed margin is not enough on its own. On a strong rule a passenger item clears it by sampling noise —
{diapers, yogurt} → baby_wipesat 0.63 againstdiapers → baby_wipesat 0.61 — and then outranks its own parent on lift. So, withsignificanceon, a longer rule must also be productive (Webb, Discovering significant patterns, 2007): for each antecedent item, among the baskets holding the rest of the antecedent, the consequent must be significantly more frequent with that item than without it (one-sided Fisher, Holm-adjusted across every such test atsignificance_alpha). A rule that fails is flagged against the sub-rule whose extra item had the weakest evidence. Holm (family-wise) rather than an FDR correction on purpose: the free riders a looser correction lets through are exactly the ones whose chance surplus puts them at the top of the ranking.Both tests are fooled by basket breadth — see defense 4 — so a third one asks the productive-rule question at equal breadth: for each antecedent item, among the baskets holding the rest of the antecedent and grouped by how many items they hold besides the consequent, the Mantel–Haenszel rate ratio of the consequent with the item against without it (Greenland & Robins 1985) must reach
1 + min_superset_lift_gain(default0.10). A rule that falls short is flagged against the shorter rule (pruned_by), which carries the finding;rule_stats.n_pruned_behind_sub_rulecounts them. When an item is never compared with its absence at equal breadth (every basket holding it is broader than every basket without it), breadth and the item cannot be told apart and the rule is kept. This gate runs with or withoutsignificance. -
Breadth-adjusted lift. Lift compares a rule with independence over all baskets. When baskets differ in breadth — a customer holding six products against one holding two, a weekly shop against a top-up — broad baskets hold more of everything, so every pair of items co-occurs above chance, lift rises with breadth alone, and a longer rule always out-lifts the pair inside it. On customer holdings that is most of the lift: on one live cross-sell run, fifteen triples at lift 3.4 had a lift of about 1.05 among customers holding the same number of products.
Every rule therefore carries
lift_breadth_adjusted(andlift_breadth_adjusted_holdouton a holdout run). Read values within a few percent of 1 as breadth, not association.Details: how the breadth adjustment is computed
Baskets are grouped by their rest score — the number of mined items they hold besides the rule's consequent — and the rule's co-occurrences are divided by the Mantel–Haenszel expected count,
Σ_j n_A,j · n_C,j / N_j(Mantel & Haenszel 1959). With one group it is exactly the lift. Under the pure-breadth (Rasch) model the rest score is a sufficient statistic for breadth, so the adjusted lift is 1 for any antecedent and any catalogue size; matching on such a score is the item-response test of association beyond a common trait (Holland & Thayer 1988; Rosenbaum 1984). Real breadth is not exactly Rasch, so read values within a few percent of 1 as breadth. The two nearby conditionings are biased: the basket size counts the consequent itself (true independence then reads as low as 0.67 on a seven-item catalogue), and the items outside both sides leave the antecedent to carry breadth (a pure breadth effect reads 1.2–1.4). Seeparleml_ml4.association.breadth.parleml_ml4.association.breadth.summarise_breadth(result.rules)gives the median share of the ranked rules' lift above 1 that breadth explains; past half, the run emits a warning saying so — the list then describes broad baskets more than pairs of items.BreadthStrata(onehot).strata(A, C)returns the per-group counts behind one rule (the evidence drawer shows them). -
Holdout validation (opt-in —
holdout_fraction). Everything above is measured on the baskets that chose the rules, so none of it can tell you whether a rule is an artifact of the mined sample. Setholdout_fraction=0.25and the miner withholds a random quarter of baskets before mining: the lattice, the rules and every gate come from the train side alone, and each surviving rule is then re-measured on baskets that had no say in its selection.Each rule gains
support_holdout/confidence_holdout/lift_holdoutand aholds_upflag (holdout lift back above the run's ownmin_lift— the bar the rule cleared to be shipped, not the weaker "better than independence"), and the result carries aHoldoutEvaluationwhosemedian_lift_ratiois the headline: the median oflift_holdout / liftover the ranked rules. Near 1.0 means the shipped list generalises; well below means the mining sample flattered it — which no amount of multiple-testing correction would have caught.A rule whose antecedent is too rare in the holdout is scored
NaN, not a confident number from three baskets; those are counted separately as unmeasurable and are neither confirmed nor refuted.It changes what the headline numbers mean
With a holdout,
support/confidence/liftdescribe the train baskets, not all of them. That is the correct trade — you cannot validate on data you mined — but it is why this is off by default rather than imposed on every run. The split is random over baskets, so it answers "is this rule an artifact of my sample?", not "will it hold next quarter?". -
Ubiquitous-item guard. Items present in more than
max_item_support(default0.95) of baskets — "everyone holds the base plan" — flood the lattice with junk and are auto-excluded pre-mining, with a warning recorded on the result.focus_itemsare exempt;max_item_support=1.0disables it.
Steering the output¶
exclude_items— removed before mining. Only the items leave; their baskets stay, so every other rule's support denominator is unaffected.focus_items+focus_side("any"/"antecedent"/"consequent") — keep only rules touching these items on the given side. Focus items also bypass the ubiquitous guard and dig the auto-support floor deeper for rare targets.exclude_itemsandfocus_itemsmay not overlap (a contradiction, raised at config time).max_itemset_len— the longest itemset mined (native mlxtendmax_len), capped at 10: it is the exponent of the whole run's cost.max_lattice_factor— the mined lattice may not exceedmax_lattice_factor × target_itemset_count. Checked on every probe including the first, and the search ascends when one overshoots. This, nottarget_itemset_count, is the upper guard.max_consequent_len(default1) /max_antecedent_len— how many items each side of a rule may hold. mlxtend enumerates every antecedent/consequent split of each frequent itemset (2^k − 2per k-itemset), so these bound the bulk of the candidates. Single-item consequents are the default because they are what makes a rule actionable ("add this one item"); setmax_consequent_len=Nonefor the unrestricted behaviour.min_confidence/min_lift— floors applied before ranking.rank_by("lift"(default) /"confidence"/"leverage"/"conviction"/"zhangs_metric") +max_rules— the final rank-and-cap.one_rule_per_itemset(defaultTrue) — each item set takes at most one ranked slot.A → BandB → Aare one co-occurrence read in two directions, and lift is symmetric, so without this every finding filled two slots of a short list (and every split of a larger set —{a,b} → c,{a,c} → b— one more). The best rule of the set byrank_byranks (ties go to the higher confidence, the more reliable reading); the other directions stay in the result, unranked, withpruned_bynaming that rule.Falseranks every direction.max_rulescaps each returned block separately, so the frame is bounded either way: the ranked rules; the runners-up — rules that passed every gate but ranked beyond the cap, kept unranked, best first, withpruned_by = "max_rules"so the rules a short list left out can still be read; and the flagged (redundant or insignificant) rows.result.rule_statsrecords what each cap and each filter removed —n_ranked_dropped_by_capis every rule beyond the cap,n_dropped_by_cap_returnedthe runners-up kept.
Budget tiers¶
Construct through ASSOCIATION_PRESETS[...] (or the association_fast /
association_balanced / association_thorough factories) and override only the
task columns:
fast |
balanced |
thorough |
|
|---|---|---|---|
target_itemset_count |
500 | 2 000 | 10 000 |
max_itemset_len |
3 | 4 | 5 |
max_rules |
200 | 500 | 2 000 |
min_improvement |
0.02 | 0.01 | 0.005 |
A tier trades breadth for speed, never evidence for speed: all three keep
significance on. The Fisher + FDR pass is a handful of vectorised calls that
run either way, so switching it off saves no time — it only ships unvetted
rules. association_balanced() is exactly AssociationConfig().
Result & artifacts¶
AssociationResult carries the full ledger, both browsable and publishable:
| Attribute | Meaning |
|---|---|
rules |
Full rule ledger — ranked, runner-up, insignificant, and pruned rows all present, distinguishable by rank / significant / pruned_by (a rule id it is redundant against, "baseline", or "max_rules" for a runner-up beyond the cap). |
itemsets |
All frequent itemsets the rules were built from. |
item_stats |
Per-item support + the exclusion ledger. Measured over all baskets, so it is paired with n_baskets, not n_baskets_mined. |
holdings |
One row per (entity, held item) over the mined vocabulary — the audience fact (or None for the basket grain). |
n_baskets |
The whole population. |
n_baskets_mined |
The denominator every rule support is a share of. Equal to n_baskets without a holdout; the train count with one. Anything turning a support back into a basket count must divide by this — using n_baskets overstates the evidence by 1 / (1 - holdout_fraction). |
holdout |
The out-of-sample evaluation (None when no holdout ran), including how many ranked rules held up, how many were unmeasurable, and how many the holdout refuted. |
rule_stats |
The generation funnel — what each filter and each cap removed; n_pruned_behind_sub_rule counts the rules the superset gate set aside. |
lift_inflation_factor |
How much retained empty baskets scale every reported lift. |
Save with result.save("assoc.joblib") and reload with AssociationResult.load(...).
With outputs_dir set, fit also writes the tables under deploy/association/.
Details: the artifact format and the run directory
save writes the joblib and then a JSON format stamp beside it
(assoc.stamp.json), and load checks that stamp before unpickling anything.
There is no compatibility layer: any field or column change bumps the major
version, and an artifact whose stamp is missing or from another version is
rejected with an actionable message rather than deserialised. Re-run the
experiment to get a loadable artifact.
With outputs_dir set, fit emits the standard events.ndjson lifecycle
(transactionize → mine_itemsets → generate_rules → persist, cancellable) and
writes deploy/association/ — association_result.joblib with its
association_result.stamp.json, rules.parquet, items.parquet,
itemsets.parquet, and holdings.parquet (only when an entity id was
resolvable).
Opportunity audiences (rule → targeting list)¶
Every rule points at an actionable audience: for {A,B} → C, the entities
holding all the antecedent items but not (all of) the consequent — the
opportunity the rule describes, minus the customers who already converted. This
is a pure set operation over the holdings fact (a NOT EXISTS anti-join). The
audience size is exact; a "yield reference" (size × the rule's confidence)
is a labelled historical co-hold line, not a campaign forecast — measuring
incremental effect is an uplift experiment's job.
Engine¶
Mining runs on mlxtend's H-Mine by default. engine="fpgrowth" is an
explicit expert escape hatch — nothing selects it automatically — and it is
23–806× slower on the matrices this pipeline produces (measured across
scripts/benchmark_association_engines.csv; the gap grows with basket count).
The recorded benchmark is on Association mining engines.
Details: why fpgrowth is slower, and where the engines differ
The cause is not sparsity and not FP-growth itself: mlxtend's
fpcommon.generate_itemsets, which fpgrowth calls and hmine does not,
re-densifies the frame once per itemset and loops over every row in Python —
null-value bookkeeping that is dead work on the null_values=False path this
pipeline always takes. The cost is therefore Θ(n_baskets × n_itemsets).
The two engines produce the same itemsets except exactly at a support
boundary: hmine admits on count ≥ ceil(min_support × n) while fpgrowth
filters on the fraction count / n ≥ min_support, and those disagree whenever
the float product rounds up across an integer. Treat them as interchangeable in
practice, not bit-identical at the threshold.
Every AssociationConfig field is on the
Configuration parameters page; the
API is on the AssociationMiner reference,
and there is a runnable walkthrough in the
market-basket example.
Next: Example: market basket · Recommendation