Step_06_Building_Promo_Analysis - Analyse des marges et cannibalisation¶
Vue d'ensemble¶
Cette étape calcule les marges promotionnelles et les effets de cannibalisation en trois phases distinctes : calcul des marges (main_margin_computation
), valorisation des effets baseline (main_valuation_computation
), et calcul de la cannibalisation (main_cannibalization_computation
). C'est l'étape qui transforme les volumes en valeur financière.
Objectif principal¶
- Calculer les marges promotionnelles ajustées par produit
- Valoriser les effets de volume (baseline, uplift, halo, pantry loading)
- Calculer la cannibalisation intra-catégorie
- Préparer les données pour l'analyse finale des promotions
Position dans la pipeline¶
- Étape précédente : Step_05_Building_Baseline
- Étape suivante : Step_07_Building_Products_Classification et Substep_601
- Appels distincts :
main_margin_computation
: Calcul des marges unitairesmain_valuation_computation
: Multiplication volumes × margesmain_cannibalization_computation
: Répartition cannibalisation
Architecture technique¶
Flux de données¶
Phase 1 : Calcul des marges
───────────────────────────
Entrée
├── step_2_03_sell_in_base (données transactionnelles)
└── step_4_01_model_with_effects_model_data_baseline
Traitement
├── Galbani
│ ├── Adjusted_gross_margin = GM + (Supply_chain_gap × Volumes)
│ └── Promo_margin = Moyenne pondérée par EAN/Year
│
└── Parmalat
└── Récupération marges pré-calculées (Step 3)
Sortie → step_5_01_model_with_margin_kpi
Phase 2 : Valorisation baseline
──────────────────────────────
Entrée
└── step_5_01_model_with_margin_kpi
Traitement
├── Baseline_GM_excl_promo_adjustment = Baseline_Volume × Promo_margin
└── Colonnes conservées pour compatibilité
Sortie → step_5_02_model_margins
Phase 3 : Cannibalisation
─────────────────────────
Entrée
└── step_5_02_model_margins
Traitement
├── Agrégation famille (New_category × Retailer × Year × Week)
├── Ratio_Cannibalizing = Uplift_i / ΣUplift_famille
├── Cannibalizing_Volume = Ratio × Family_Cannib_Volume
└── Cannibalizing_GM = Volume × Ponderated_Family_GM
Sortie → step_5_03_model_cannibalization_effects
Concepts clés¶
1. Marges ajustées (Galbani)¶
- Adjusted_gross_margin : Marge brute + écart supply chain
- Promo_margin : Moyenne pondérée par volumes sur l'année
- Agrégation : EAN × EAN_desc × Year
2. Marges pré-calculées (Parmalat)¶
- Utilisation
Promo_margin_per_unit
de Step 3 - Méthodologie : réallocation contribution marginale
- Agrégation : EAN × Year (sans EAN_desc)
3. Valorisation des effets¶
Multiplication simple : Volume × Marge unitaire
- Baseline_GM_excl_promo_adjustment
- Plus tard : Uplift_GM
, Halo_GM
, Pantry_Loading_GM
, Cannib_GM
4. Cannibalisation¶
- Famille : produits de même catégorie
- Ratio : part de l'uplift du produit dans l'uplift famille
- Répartition : proportionnelle à la contribution uplift
Implémentation détaillée¶
1. Phase 1 : Calcul des marges¶
Galbani : compute_margin_italy_galbani()
¶
# Calcul marge ajustée
df_sell_in['Adjusted_gross_margin'] = (
df_sell_in['Gross_margin'] +
(df_sell_in['Supply_chain_gap'] * df_sell_in['Volumes'])
)
# Moyenne pondérée par produit/année
def compute_weighted_average(data):
weighted_margin_sum = np.sum(data['Adjusted_gross_margin'])
total_volumes = np.sum(data['Volumes'])
if total_volumes > 0:
return weighted_margin_sum / total_volumes
else:
return np.nan
weighted_promo_margin = df_sell_in.groupby(['EAN', 'EAN_desc', 'Year']).apply(
compute_weighted_average
).reset_index(name='Promo_margin')
Parmalat : compute_margin_italy_parmalat()
¶
# Récupération marges existantes
if 'Promo_margin_per_unit' not in df_sell_in.columns:
log_message("WARNING: Promo_margin_per_unit not found")
# Fallback calculation
df_sell_in['Promo_margin_total'] = (
df_sell_in['Product_margin'] +
df_sell_in['Supply_chain_gap'] +
df_sell_in['Fixed_industrial_costs']
)
df_sell_in['Promo_margin_per_unit'] = (
df_sell_in['Promo_margin_total'] / df_sell_in['Volumes']
)
# Agrégation sans EAN_desc
weighted_promo_margin = df_sell_in.groupby(['EAN', 'Year']).apply(
aggregate_weighted_margin
).reset_index(name='Promo_margin')
Default : compute_margin_default()
¶
Pour autres business units :
# Somme tous les coûts promo
promo_columns = [
'On_invoice_promo_costs_*',
'Promo_funding_off_invoice_*',
'Other_off_invoice_promo_costs',
'Other_promo_costs'
]
df['Total_Promo_Costs'] = df[promo_columns].sum(axis=1)
# Marge hors promo
df['Gross_Margin_excluding_all_promo_costs'] = (
df['Gross_margin'] - df['Total_Promo_Costs']
)
df['Gross_Margin_excluding_all_promo_costs_per_unit'] = (
df['Gross_Margin_excluding_all_promo_costs'] / df['Volumes']
)
2. Phase 2 : Valorisation baseline¶
Identique pour tous : `compute_valuation_baseline_effects_*()``
# Simple multiplication
df['Baseline_GM_excl_promo_adjustment'] = (
df['Baseline_Volume'] * df['Promo_margin']
)
Note : Les autres valorisations (Uplift_GM, etc.) sont calculées plus tard ou dans d'autres étapes.
3. Phase 3 : Cannibalisation¶
Galbani : compute_cannibalization_italy_galbani()
¶
Spécificités :
- Famille = New_category
- Ratio basé sur Uplift_Volume
uniquement
- Pas de Halo dans le calcul
# Marge cannibalisée originale
df['Cannib_GM'] = df['Cannib_Volume'] * df['Promo_margin']
# Agrégation famille
family_aggregates = df.groupby(['New_category', 'Retailer_name', 'Year', 'Week']).agg({
'Uplift_Volume': 'sum', # → Family_Uplift_Volume
'Cannib_Volume': 'sum', # → Family_Cannib_Volume
'Cannib_GM': 'sum' # → Total_Family_Cannib_GM
})
# Marge pondérée famille
family['Ponderated_Family_Cannib_GM'] = (
family['Total_Family_Cannib_GM'] / family['Family_Cannib_Volume']
)
# Répartition
df['Ratio_Cannibalizing'] = safe_divide(
df['Uplift_Volume'],
df['Family_Uplift_Volume']
)
df['Cannibalizing_Volume'] = df['Ratio_Cannibalizing'] * df['Family_Cannib_Volume']
df['Cannibalizing_GM'] = (
df['Ratio_Cannibalizing'] *
df['Family_Cannib_Volume'] *
df['Ponderated_Family_Cannib_GM']
)
Parmalat : compute_cannibalization_italy_parmalat()
¶
Restriction importante :
# Cannibalisation uniquement pour le lait UHT
CANNIBALIZATION_CATEGORIES = ['UHT normal milk', 'UHT lactose free milk']
Traitement : - Catégories UHT : calcul normal - Autres catégories : tous metrics = 0
Default : compute_cannibalization_default()
¶
- Famille =
Category
- Ratio inclut
Halo_Volume
:(Uplift + Halo) / (Family_Uplift + Family_Halo)
- Applicable à tous produits
Gestion des erreurs¶
Division par zéro¶
Fonction safe_divide()
systématique :
def safe_divide(numerator, denominator, default_value=0):
safe_denominator = denominator.replace({0: np.nan})
safe_numerator = numerator.replace({np.inf: np.nan, -np.inf: np.nan})
result = safe_numerator.div(safe_denominator).fillna(default_value)
return result
Colonnes manquantes¶
# Parmalat margin fallback
if 'Promo_margin_per_unit' not in df_sell_in.columns:
log_message("WARNING: Promo_margin_per_unit not found")
# Calcul alternatif
Volumes nuls¶
# Protection moyenne pondérée
if total_volumes > 0:
return weighted_margin_sum / total_volumes
else:
return np.nan
Performance et optimisation¶
1. Agrégations efficaces¶
- GroupBy avec fonctions natives (
sum
,mean
) - Apply uniquement pour calculs complexes
- Éviter les boucles sur DataFrame
2. Gestion mémoire¶
# Remplacement explicite pour éviter warning
pd.set_option('future.no_silent_downcasting', True)
family_aggregates['Family_Cannib_Volume'] = (
family_aggregates['Family_Cannib_Volume'].replace(0, np.nan)
)
3. Réduction requêtes¶
- Une seule lecture par table source
- Agrégations pandas plutôt que SQL
- Sauvegarde unique en fin de traitement
Points d'attention maintenance¶
1. Cohérence agrégations¶
- Galbani : toujours EAN × EAN_desc
- Parmalat : toujours EAN seul
- Default : adapter selon structure
2. Colonnes marges¶
Noms différents selon business unit :
- Galbani : Gross_margin
, Supply_chain_gap
- Parmalat : Product_margin
, Fixed_industrial_costs
- Default : liste exhaustive coûts promo
3. Catégories cannibalisation¶
- Galbani : toutes
New_category
- Parmalat : seulement lait UHT
- Default : toutes
Category
4. Ordre d'exécution¶
main_margin_computation
main_valuation_computation
main_cannibalization_computation
Important : Respecter l'ordre, chaque phase dépend de la précédente
Troubleshooting¶
Problème : Marges négatives ou extrêmes¶
Diagnostic :
SELECT EAN, Year, Promo_margin,
COUNT(*) as occurrences
FROM step_5_01_model_with_margin_kpi
WHERE Promo_margin < -10 OR Promo_margin > 100
GROUP BY EAN, Year, Promo_margin;
Causes : - Volumes très faibles → division instable - Données financières incohérentes - Écarts supply chain anormaux
Problème : Cannibalisation = 0 partout¶
Vérifications :
1. Cannib_Volume
dans données source ?
2. Catégories correctes (Parmalat : UHT uniquement) ?
3. Family aggregates calculés ?
-- Vérifier volumes cannibalisation source
SELECT New_category,
SUM(CASE WHEN Cannib_Volume < 0 THEN 1 ELSE 0 END) as negative_cannib,
COUNT(*) as total_rows
FROM step_5_02_model_margins
GROUP BY New_category;
Problème : Ratio_Cannibalizing > 1¶
Symptôme : Un produit cannibalisé plus que l'uplift famille
Cause : Incohérence agrégation famille
Solution :
Exemples d'utilisation¶
Ajout nouveau business unit¶
def compute_margin_new_country(engine):
# Adapter colonnes selon structure
df_sell_in = pd.read_sql("SELECT * FROM sell_in_base", engine)
# Calcul marge spécifique
df_sell_in['Specific_margin'] = ...
# Agrégation produit
margin_by_product = df_sell_in.groupby(['EAN', 'Year']).agg({
'Specific_margin': 'sum',
'Volumes': 'sum'
})
# Merge et sauvegarde
...
Modification catégories cannibalisation¶
# Dans compute_cannibalization_*
CANNIBALIZATION_CATEGORIES = [
'Category_A',
'Category_B',
'New_Category_C' # Ajout
]
Debug marges par retailer¶
# Ajouter avant agrégation finale
debug_margins = df_sell_in.groupby(['Retailer_name', 'EAN', 'Year']).agg({
'Adjusted_gross_margin': 'sum',
'Volumes': 'sum',
'Promo_margin': lambda x: (x * df_sell_in.loc[x.index, 'Volumes']).sum() / df_sell_in.loc[x.index, 'Volumes'].sum()
})
debug_margins.to_csv('debug_margins_by_retailer.csv')