⚡ Swarm Architecture

Design — Bundles fonctionnels (4-itemsets) & règles d'association

# 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 ≥ 3 jetterait 28 % des paniers et détruirait les bundles cibles
(encre+feuille+imprimante+poubelle = 3 univers). Le seuil d'univers doit rester généreux → ≥ 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
fréquents seulement). Si trop lent, activer l'élagage anti-monotone (ne compter un 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
chacun : les 4 produits (SKU + nom + pastille section/univers), le support (nb paniers), et sa meilleure règle 2→2 {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),
triées par 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,
conséquent, confiance, lift).
  • bundle_rules : type (3→1 / 2→1), antécédent (SKU+nom), conséquent (SKU+nom),
support, confiance, lift.

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),
pas d'un recalcul live. Nouveaux query-params réglables min_support, min_confidence alignés sur le style min_co.
  • À confirmer au moment du plan (lecture du mécanisme de Reports dans app.py) :
branchement exact « snapshot → panel » (enregistrement Report vs lecture directe du dernier fichier 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.py sur jeu synthétique :

  • clean_baskets : un panier 21-SKU rejeté ; un panier 5-univers rejeté ; un panier
4-univers / 6-SKU conservé.
  • 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 ;
enrichissement (name/section/world) présent.

---

8. Hors périmètre / risques

  • Hors périmètre : recalcul live des bundles dans le panel ; GB ; grain
section/sous-famille (on reste SKU exact) ; persistance des règles en base.
  • Risque perf : si l'énumération L4 est trop lente sur 90 j, activer l'élagage
anti-monotone (déjà prévu §4.2). Mesurer le runtime de la Phase 5 et le documenter.
  • Risque sémantique : min_support=25 et min_confidence=0.30 sont des points de
départ calibrés mais à éprouver sur les premiers résultats réels ; tous réglables.
  • Lien existant : ne pas casser product_co_occurrence ni les autres sections du
panel — seule la section paires/C-D est remplacée.