# Design — Bundles fonctionnels (4-itemsets) & règles d'association
Date : 2026-05-20
Auteur : Pierre Samson + Claude
Périmètre : Panel cross-family-baskets (FR), section « Top cross-world SKU pairs & their bundle expansion (C / D) »
---
1. Contexte & objectif
La section actuelle « Top cross-world SKU pairs & their bundle expansion (C/D) » part
de la table pré-calculée product_co_occurrence (paires globales, contrainte
world(A) ≠ world(B)), puis cherche pour chaque paire les 2 SKU compagnons (C, D)
les plus fréquents dans les mêmes commandes.
Deux limites motivent la refonte :
1. Pas de vrai grain à 4 produits. A,B viennent d'une table de paires ; C,D sont bricolés par-dessus. On veut de vrais itemsets de 4 produits minés depuis les paniers réels, avec une métrique conditionnelle propre. 2. Du bruit de « réassort total ». Certains paniers sont des réassorts en gros contenant des produits sans lien fonctionnel (l'exemple cutter + coton). Ils gonflent artificiellement les co-occurrences. Il faut les exclure.
Question métier cible : *« si un client achète {a, b}, va-t-il forcément acheter {c, d} parce que les produits sont complémentaires ? »* Exemples fournis :
- lame de cutter + porte-cutter + carton + scotch (poste d'emballage)
- encre + feuille + imprimante + poubelle (poste d'impression)
C'est de la règle d'association de panier (market-basket) : confiance + lift sur des itemsets jusqu'à 4 produits, calculés sur des paniers nettoyés.
---
2. Décisions verrouillées
| Décision | Choix |
|---|---|
| Grain | SKU exact, sans contrainte cross-world |
| Lecture fonctionnelle | section/univers de chaque produit affichée à côté du SKU |
| Filtre anti-réassort | jeter panier si taille > 20 SKU OU univers ≥ 5 |
| min_support (défaut) | 25 paniers (absolu) |
| min_confidence (défaut) | 0,30 |
| min_lift (défaut) | 1,0 |
| Métrique conditionnelle | règle d'association confiance = P(conséquent \| antécédent), + lift |
| Algorithme | Apriori étagé, pur-Python (pas de nouvelle dépendance ; mlxtend absent) |
| Exécution | offline / snapshot (trop lourd pour le panel live) |
---
3. Faits mesurés (calibration — fenêtre 90 j, FR)
Scripts de mesure livrés en amont :
scripts/measure_basket_size_distribution.py, scripts/measure_basket_world_spread.py.
Taille des paniers :
- 550 787 commandes ; 438 007 multi-lignes (≥2 SKU, 79,5 %).
- Médiane 4 SKU ; moyenne 5,85 ; p95 = 17 ; p99 = 30 ; max 475.
- 54 % des commandes ≤ 4 SKU → les bundles de 4 reflètent la réalité d'achat majoritaire.
Étalement d'univers :
- 33 % des paniers = 1 univers ; 39 % = 2 ; 19 % = 3 ; 7 % = 4 ; 2 % ≥ 5.
- ⚠️ Un filtre
univers ≥ 3jetterait 28 % des paniers et détruirait les bundles cibles
≥ 5.
Assortiment actif : seulement 31 471 SKU distincts apparaissent dans les paniers multi-lignes sur 90 j (pas 1,68M). ⇒ minage SKU-grain faisable ; support absolu pertinent.
Effet du filtre anti-réassort retenu (taille>20 OU univers≥5) : jette 5,4 % des
paniers, préserve 100 % de la zone fonctionnelle 1-4 univers.
---
4. Méthode
4.1 Nettoyage des paniers
Entrée : liste de paniers {order_number, skus: list[str]} + map prod_world: dict[sku, world].
Pour chaque panier, calculer taille = len(set(skus)) et
n_univers = len({prod_world.get(s) for s in skus} - {None}).
Exclure si taille > max_basket_size OU n_univers >= max_worlds.
Renvoyer les paniers conservés (comme list[frozenset[str]]) + des stats de rejet.
4.2 Minage des itemsets fréquents (Apriori étagé)
`
support(X) = nombre de paniers conservés contenant TOUS les SKU de X
`
1. L1 — compter chaque SKU sur les paniers conservés ; garder keep1 = {sku : count ≥ min_support}.
2. Réduction — réduire chaque panier à set(panier) ∩ keep1, trié.
3. L2/L3/L4 — pour chaque panier réduit, énumérer combinations(items, r) pour
r ∈ {2,3,4} et incrémenter un compteur par taille. Conserver les itemsets de
support ≥ min_support.
- Note perf : la réduction L1 fait tomber la combinatoire (paniers ≤20, items
r-itemset que si tous ses (r-1)-sous-ensembles sont fréquents). Optionnel.
Sortie : support: dict[frozenset[str], int] pour les tailles 1 à 4.
4.3 Génération des règles
Pour chaque itemset fréquent S (taille 2 à 4) et chaque découpe non vide
antécédent A ⊂ S, conséquent = S \ A :
`
confiance = support(S) / support(A)
lift = confiance / (support(conséquent) / n_paniers_conservés)
`
Garder les règles avec confiance ≥ min_confidence ET lift ≥ min_lift.
4.4 Trois niveaux de sortie (validés)
- Primaire — Bundles de 4 : chaque 4-itemset fréquent, classé par support. Pour
{a,b} → {c,d} (celle de confiance max parmi
les 3 découpes 2+2), avec confiance + lift.
- Secondaire — Règles 3→1 :
{a,b,c} → d, filtrées (conf ≥ min_conf, lift ≥ min_lift),
- Tertiaire — Règles 2→1 :
a → b, idem.
---
5. Architecture & isolation
5.1 Nouveau module core/basket_bundles.py (fonctions pures, testables)
`python
def clean_baskets(
baskets: list[dict], # [{"skus": [...]}, ...]
prod_world: dict[str, str],
max_basket_size: int = 20,
max_worlds: int = 5,
) -> tuple[list[frozenset[str]], dict]:
"""Renvoie (paniers_conservés, stats_rejet)."""
def mine_frequent_itemsets( baskets: list[frozenset[str]], min_support: int = 25, max_len: int = 4, ) -> dict[frozenset[str], int]: """Supports des itemsets de taille 1..max_len ≥ min_support."""
def generate_rules( support: dict[frozenset[str], int], n_baskets: int, min_confidence: float = 0.30, min_lift: float = 1.0, ) -> list[dict]: """Règles {antecedent, consequent, support, confidence, lift}."""
def build_outputs(
support, rules, prod_meta,
) -> dict:
"""Assemble bundles_4 (+ best 2->2), rules_3to1, rules_2to1, enrichis
avec nom + section + world par SKU."""
`
prod_meta: dict[sku, {name, section, world}] — résolu via ecom_products × section_taxonomy
(postgres), même requête batchée que l'existant.
5.2 Intégration scripts/refresh_cross_family_baskets.py
Nouvelle Phase 5 après le calcul existant : appeler clean_baskets →
mine_frequent_itemsets → generate_rules → build_outputs, sur les order_baskets
déjà pullés (réutilise prod_world). Ajouter au dict de retour les clés
bundles_4, rules_3to1, rules_2to1, et les paramètres utilisés.
Nouveaux arguments CLI : --min-support (25), --min-confidence (0.30),
--min-lift (1.0), --max-basket-size (20), --max-worlds (5).
Nouvelles feuilles XLSX :
bundles_4: sku_a..d, name_a..d, world_a..d, support, best rule (antécédent,
bundle_rules: type (3→1 / 2→1), antécédent (SKU+nom), conséquent (SKU+nom),
5.3 Intégration panel dashboard/templates/_cross_family_baskets.html + app.py
- Remplacer le bloc titré *« Top cross-world SKU pairs & their bundle expansion (C / D) »* par les 3 nouvelles tables (primaire visible, secondaire/tertiaire repliables ou en dessous).
- Réutiliser les pastilles
.pill.{world}existantes pour la lecture section/univers. - Source des données : les bundles viennent du dernier snapshot (sortie du refresh),
min_support,min_confidencealignés sur le stylemin_co.- À confirmer au moment du plan (lecture du mécanisme de Reports dans
app.py) :
exports/cross_family_baskets_FR_*.json). Le live recompute des bundles est hors périmètre (coût).---
6. Paramètres & défauts
| Paramètre | Défaut | Rôle | |---|---|---| |
days| 90 | fenêtre | |channel| ALL | ALL / WEB | |min_support| 25 | plancher support absolu (paniers) | |min_confidence| 0.30 | plancher confiance des règles | |min_lift| 1.0 | plancher lift des règles | |max_basket_size| 20 | filtre anti-réassort (taille) | |max_worlds| 5 | filtre anti-réassort (univers) |---
7. Plan de test (TDD)
tests/test_basket_bundles.pysur jeu synthétique :- clean_baskets : un panier 21-SKU rejeté ; un panier 5-univers rejeté ; un panier
- mine_frequent_itemsets : supports exacts sur paniers connus ; respect du
min_support; pas d'itemset >max_len.- generate_rules : confiance et lift calculées à la main sur 2-3 cas ; filtrage
min_confidence/min_lift.- build_outputs : la meilleure règle 2→2 d'un bundle est bien celle de confiance max ;
---
8. Hors périmètre / risques
- Hors périmètre : recalcul live des bundles dans le panel ; GB ; grain
- Risque perf : si l'énumération L4 est trop lente sur 90 j, activer l'élagage
- Risque sémantique :
min_support=25etmin_confidence=0.30sont des points de
- Lien existant : ne pas casser
product_co_occurrenceni les autres sections du
- Réutiliser les pastilles