ServicesÀ proposNotesContact Me contacter →
EN FR
Note

Modèles d'attribution pondérés avec dbt

Implémenter l'attribution positionnelle et à décroissance temporelle dans dbt avec des poids configurables via les variables dbt — SQL des modèles, configuration du projet et tests d'intégrité des revenus

Planté
dbtbigquerydata modelinganalytics

Les modèles d’attribution positionnelle et à décroissance temporelle utilisent des poids qui doivent être configurables, et non codés en dur. Les variables dbt rendent cela propre : définissez les poids dans dbt_project.yml, référencez-les avec var() dans le SQL des modèles, et substituez-les au moment de l’exécution pour l’expérimentation. Cette note couvre l’implémentation spécifique à dbt par rapport aux concepts de base abordés dans Modèles d’attribution positionnelle et Modèle d’attribution à décroissance temporelle.

Structure du projet

Ajoutez les modèles positionnels et à décroissance temporelle aux côtés de vos marts d’attribution existants :

models/
├── intermediate/
│ └── int__touchpoints.sql
├── marts/attribution/
│ ├── mrt__attribution_first_touch.sql
│ ├── mrt__attribution_last_touch.sql
│ ├── mrt__attribution_linear.sql
│ ├── mrt__attribution_position_based.sql # Nouveau
│ └── mrt__attribution_time_decay.sql # Nouveau

Chaque modèle suit la convention de nommage des marts et lit depuis le même modèle de points de contact intermédiaire, maintenant la cohérence avec le reste de la suite d’attribution.

Modèle positionnel avec poids configurables

Utilisez var() pour rendre les poids du premier et du dernier contact configurables. Le poids intermédiaire découle du reste :

-- mrt__attribution_position_based.sql
{% set first_touch_weight = var('attribution_first_weight', 0.4) %}
{% set last_touch_weight = var('attribution_last_weight', 0.4) %}
{% set middle_weight = 1.0 - first_touch_weight - last_touch_weight %}
WITH touchpoints_positioned AS (
SELECT
user_id,
transaction_id,
channel,
source,
medium,
revenue,
touchpoint_timestamp,
ROW_NUMBER() OVER (
PARTITION BY user_id, transaction_id
ORDER BY touchpoint_timestamp ASC
) AS position,
COUNT(*) OVER (
PARTITION BY user_id, transaction_id
) AS total_touches
FROM {{ ref('int__touchpoints') }}
)
SELECT
user_id,
transaction_id,
channel,
source,
medium,
touchpoint_timestamp,
CASE
WHEN total_touches = 1 THEN 1.0
WHEN total_touches = 2 THEN 0.5
WHEN position = 1 THEN {{ first_touch_weight }}
WHEN position = total_touches THEN {{ last_touch_weight }}
ELSE {{ middle_weight }} / (total_touches - 2)
END * revenue AS attributed_revenue,
'position_based' AS attribution_model
FROM touchpoints_positioned

La ligne {% set middle_weight = 1.0 - first_touch_weight - last_touch_weight %} est le choix de conception clé. En dérivant le poids intermédiaire plutôt qu’en le configurant indépendamment, on garantit que les poids somment à 1,0. Si quelqu’un règle le premier et le dernier à 0,45 chacun, le poids intermédiaire devient automatiquement 0,10. Aucune logique de validation n’est nécessaire.

La gestion des cas limites (total_touches = 1, total_touches = 2) est critique. Sans elle, les conversions avec un seul point de contact ne reçoivent que 40 % de crédit, et les conversions à deux points de contact déclenchent une division par zéro pour le calcul du poids intermédiaire. Voir Modèles d’attribution positionnelle pour l’explication complète.

Modèle à décroissance temporelle avec demi-vie configurable

-- mrt__attribution_time_decay.sql
{% set half_life_days = var('attribution_half_life_days', 7) %}
WITH decay_weights AS (
SELECT
user_id,
transaction_id,
channel,
source,
medium,
revenue,
touchpoint_timestamp,
conversion_timestamp,
POW(
0.5,
TIMESTAMP_DIFF(conversion_timestamp, touchpoint_timestamp, HOUR) / ({{ half_life_days }} * 24.0)
) AS raw_weight
FROM {{ ref('int__touchpoints') }}
),
normalized AS (
SELECT
*,
SUM(raw_weight) OVER (
PARTITION BY user_id, transaction_id
) AS total_weight
FROM decay_weights
)
SELECT
user_id,
transaction_id,
channel,
source,
medium,
touchpoint_timestamp,
(raw_weight / total_weight) * revenue AS attributed_revenue,
'time_decay' AS attribution_model
FROM normalized

L’étape de normalisation (division par total_weight) garantit que les revenus attribués correspondent aux revenus réels par conversion. Cela rend la décroissance temporelle intrinsèquement auto-correctrice : quelle que soit la valeur de demi-vie, les calculs s’équilibrent toujours. C’est une propriété intéressante par rapport au modèle positionnel, où vous devez manuellement garantir que les poids somment correctement.

Configuration dans dbt_project.yml

Centralisez tous les paramètres d’attribution :

vars:
attribution_first_weight: 0.4
attribution_last_weight: 0.4
attribution_half_life_days: 7

Cela offre trois avantages :

  1. Source unique de vérité. Tout membre de l’équipe peut voir les poids actuels sans lire le SQL.
  2. Expérimentation facilitée. Substituez au moment de l’exécution : dbt run --vars '{"attribution_half_life_days": 14}' pour tester une demi-vie différente sans modifier le code.
  3. Piste d’audit. Les modifications de dbt_project.yml sont tracées dans Git, donc vous savez quand les poids ont changé et pourquoi.

Pour tester systématiquement différentes configurations de poids, exécutez le modèle avec plusieurs valeurs de paramètres et comparez les classements par canal. Si les classements sont stables sur une plage de demi-vies (disons de 5 à 14 jours), le paramètre exact importe moins. S’ils changent radicalement, investissez davantage de réflexion pour obtenir la bonne valeur — ou acceptez que la sensibilité elle-même soit informative.

Tests des modèles pondérés

Vos tests doivent vérifier que les poids somment correctement. La vérification d’intégrité principale : les revenus attribués par conversion doivent être égaux aux revenus réels. Ce test détecte les erreurs de calcul dans la logique de pondération :

-- tests/attribution_weights_sum_to_revenue.sql
WITH model_totals AS (
SELECT
transaction_id,
SUM(attributed_revenue) AS total_attributed
FROM {{ ref('mrt__attribution_position_based') }}
GROUP BY transaction_id
),
actual_revenue AS (
SELECT
transaction_id,
MAX(revenue) AS actual_revenue
FROM {{ ref('int__touchpoints') }}
GROUP BY transaction_id
)
SELECT
m.transaction_id,
m.total_attributed,
a.actual_revenue
FROM model_totals m
JOIN actual_revenue a
ON m.transaction_id = a.transaction_id
WHERE ABS(m.total_attributed - a.actual_revenue) > 0.01

Ce test retourne des lignes uniquement quand l’attribution ne correspond pas aux revenus réels. Zéro ligne signifie que le modèle est équilibré. Toute ligne identifie exactement quelles conversions présentent des erreurs de calcul, ce qui facilite le débogage.

Écrivez le même test pour le modèle à décroissance temporelle. L’étape de normalisation devrait garantir l’équilibre, mais l’arithmétique en virgule flottante peut introduire de minuscules erreurs — la tolérance de 0.01 gère cela. Pour une approche plus complète utilisant le modèle de comparaison, consultez les tests d’intégrité des revenus qui valident tous les modèles simultanément.

Intégration avec la couche de comparaison

Une fois ces modèles existants, ajoutez-les à l’union du modèle de comparaison. La colonne attribution_model dans la sortie de chaque modèle ('position_based' et 'time_decay') devient le discriminateur model_type dans la table de comparaison, apparaissant automatiquement dans les listes déroulantes des tableaux de bord sans aucune modification de la couche BI.

Le pattern de comparaison facilite l’ajout de ces modèles : créez le modèle, ajoutez un CTE à l’union de comparaison. Le choix des bons poids et de la bonne demi-vie requiert un jugement métier sur le fonctionnement de l’attribution marketing.