Attribuzione Multi-Touch nel SaaS B2B: Guida Completa a Markov, Shapley e Calcolo del CAC (Parte 2)

Cerca:

Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors
Oltre il Last Click - Calcola il vero CAC con Markov & Shapley

Parte 2 della guida. Se non hai ancora letto la prima parte, ti consiglio di iniziare da qui:

👉Attribuzione Multi-Touch nel SaaS B2B: Catene di Markov, Shapley Value e calcolo del CAC

C’è sempre un momento, di solito verso la fine del trimestre, in cui il budget marketing rischia di essere tagliato nel posto sbagliato. Accade quasi sempre quando qualcuno in riunione proietta la slide delle conversioni basata sul Last Click.

Se nella prima parte di questa guida abbiamo costruito l’infrastruttura matematica per evitare questa trappola, ora è il momento di far girare i dati.

Abbiamo preso 1.200 percorsi utente di un tipico funnel B2B SaaS e li abbiamo processati con sei modelli di attribuzione diversi, dalle euristiche tradizionali alle Catene di Markov e allo Shapley Value. I risultati sul reale Costo di Acquisizione Cliente (CAC) raccontano una storia molto diversa da quella a cui siamo abituati: vi mostreranno esattamente perché alcune campagne sembrano fallire mentre, in realtà, stanno tenendo in piedi l’intero ecosistema.

Pubblicità

Il dataset simulato

Per passare dalla teoria alla pratica, abbiamo generato un log di eventi che riproduce il comportamento di 1200 utenti all’interno di un funnel B2B SaaS governato da 7 canali marketing: SEO, Google Ads, LinkedIn Ads, Content, Webinar, Email e Demo.

Ogni utente percorre un customer journey differente muovendosi all’interno di una catena di Markov probabilistica, fino ad arrivare a due stati assorbenti finali: la conversione (Conversion) o l’abbandono (Null). Una volta mappati i percorsi, abbiamo passato i log al nostro motore calcolando l’attribuzione complessiva del funnel.

Script Python Completo

"""
Modelli di Attribuzione Multi-Touch: Confronto Completo
Dataset sintetico B2B SaaS | Markov + Shapley vs Euristiche
"""

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from collections import defaultdict
import warnings
warnings.filterwarnings('ignore')

# ============================================================
# 1. GENERAZIONE DATASET SINTETICO B2B SAAS
# ============================================================

np.random.seed(42)

N_USERS = 1200
CHANNELS = ['SEO', 'Google_Ads', 'LinkedIn_Ads', 'Email', 'Webinar', 'Content', 'Demo']
ABS_STATES = ['Conversion', 'Null']
ALL_STATES = ['Start'] + CHANNELS + ABS_STATES

# Matrice di transizione realistica per funnel B2B SaaS
# Righe: stato origine | Colonne: probabilità verso stato destinazione
TRANS_PROBS = {
    'Start': {
        'SEO': 0.25, 'Google_Ads': 0.20, 'LinkedIn_Ads': 0.15,
        'Content': 0.20, 'Email': 0.05, 'Webinar': 0.10,
        'Demo': 0.00, 'Null': 0.05
    },
    'SEO': {
        'SEO': 0.10, 'Content': 0.15, 'Email': 0.10, 'Webinar': 0.10,
        'Demo': 0.15, 'Google_Ads': 0.10, 'LinkedIn_Ads': 0.05,
        'Conversion': 0.05, 'Null': 0.20
    },
    'Google_Ads': {
        'SEO': 0.10, 'Content': 0.10, 'Email': 0.20, 'Demo': 0.25,
        'Webinar': 0.05, 'LinkedIn_Ads': 0.05,
        'Conversion': 0.10, 'Null': 0.15
    },
    'LinkedIn_Ads': {
        'SEO': 0.15, 'Content': 0.20, 'Webinar': 0.15, 'Email': 0.10,
        'Demo': 0.10, 'Google_Ads': 0.10,
        'Conversion': 0.05, 'Null': 0.15
    },
    'Email': {
        'Email': 0.05, 'Demo': 0.30, 'SEO': 0.05, 'Content': 0.10,
        'Webinar': 0.10, 'Conversion': 0.25, 'Null': 0.15
    },
    'Webinar': {
        'Email': 0.20, 'Demo': 0.25, 'SEO': 0.10, 'Content': 0.10,
        'Conversion': 0.15, 'Null': 0.20
    },
    'Content': {
        'SEO': 0.10, 'Email': 0.15, 'Webinar': 0.10, 'Demo': 0.15,
        'Google_Ads': 0.05, 'LinkedIn_Ads': 0.05,
        'Conversion': 0.10, 'Null': 0.30
    },
    'Demo': {
        'Email': 0.15, 'Conversion': 0.45, 'Null': 0.40
    }
}

# Normalizzazione di sicurezza
for s_from in TRANS_PROBS:
    total = sum(TRANS_PROBS[s_from].values())
    for s_to in TRANS_PROBS[s_from]:
        TRANS_PROBS[s_from][s_to] /= total


def generate_journey(user_id, max_steps=15):
    """Simula un customer journey come catena di Markov."""
    journey = ['Start']
    current = 'Start'
    for _ in range(max_steps):
        if current in ABS_STATES:
            break
        probs = TRANS_PROBS[current]
        states = list(probs.keys())
        probabilities = [probs[s] for s in states]
        next_state = str(np.random.choice(states, p=probabilities))
        journey.append(next_state)
        current = next_state
    if current not in ABS_STATES:
        journey.append('Null')
    return journey


# Generazione log eventi
records = []
for uid in range(1, N_USERS + 1):
    journey = generate_journey(f'U{uid}')
    converted = 1 if journey[-1] == 'Conversion' else 0
    # Escludi 'Start' e stato finale dai log grezzi
    for step, state in enumerate(journey[1:-1], start=1):
        records.append({
            'user_id': f'U{uid}',
            'touchpoint': state,
            'timestamp': pd.Timestamp('2026-01-01') + pd.Timedelta(hours=uid*24 + step*3),
            'converted': converted
        })

df_logs = pd.DataFrame(records)
print(f"Dataset generato: {len(df_logs)} eventi | {df_logs['user_id'].nunique()} utenti unici")


# ============================================================
# 2. SESSION STITCHING
# ============================================================

def build_journeys(df):
    """Ricostruisce percorsi ordinati per utente."""
    df_sorted = df.sort_values(by=['user_id', 'timestamp'])
    rows = []
    for user_id, group in df_sorted.groupby('user_id'):
        touchpoints = group['touchpoint'].tolist()
        conversion_status = int(group['converted'].iloc[-1])
        end_state = 'Conversion' if conversion_status == 1 else 'Null'
        journey = ['Start'] + touchpoints + [end_state]
        rows.append({'user_id': user_id, 'journey': journey, 'converted': conversion_status})
    return pd.DataFrame(rows)


df_journeys = build_journeys(df_logs)
TOTAL_CONVERSIONS = int(df_journeys['converted'].sum())
print(f"Percorsi ricostruiti: {len(df_journeys)} | Conversioni: {TOTAL_CONVERSIONS}")


# ============================================================
# 3. ENGINE DI ATTRIBUZIONE MULTI-MODELLO
# ============================================================

class AttributionEngine:
    def __init__(self, df_journeys):
        self.df = df_journeys.copy()
        self.journeys = [list(j) for j in self.df['journey'].tolist()]
        self.channels = sorted(list(set(
            [tp for j in self.journeys for tp in j if tp not in ['Start', 'Conversion', 'Null']]
        )))
        self.transient_states = ['Start'] + self.channels
        self.absorbing_states = ['Conversion', 'Null']
        self.all_states = self.transient_states + self.absorbing_states
        self.total_conversions = int(self.df['converted'].sum())
        
        # Costi mensili/simulati per canale (€)
        self.costs = {
            'SEO': 45000, 'Google_Ads': 60000, 'LinkedIn_Ads': 55000,
            'Email': 25000, 'Webinar': 40000, 'Content': 35000, 'Demo': 30000
        }

    # --- Modelli Euristici ---
    def heuristic_models(self):
        results = {ch: {'last': 0.0, 'first': 0.0, 'linear': 0.0, 'time_decay': 0.0}
                   for ch in self.channels}
        
        for journey, conv in zip(self.journeys, self.df['converted']):
            if not conv:
                continue
            touchpoints = [tp for tp in journey if tp in self.channels]
            if not touchpoints:
                continue
            
            n = len(touchpoints)
            results[touchpoints[-1]]['last'] += 1.0
            results[touchpoints[0]]['first'] += 1.0
            
            for tp in touchpoints:
                results[tp]['linear'] += 1.0 / n
            
            # Time-Decay esponenziale (peso crescente verso conversione)
            for i, tp in enumerate(touchpoints):
                weight = 2 ** (i / max(n - 1, 1))
                results[tp]['time_decay'] += weight
        
        # Normalizza Time-Decay a somma = conversioni totali
        td_total = sum(v['time_decay'] for v in results.values())
        if td_total > 0:
            for ch in results:
                results[ch]['time_decay'] = (results[ch]['time_decay'] / td_total) * self.total_conversions
        
        return pd.DataFrame(results).T

    # --- Matrice di Transizione Markov ---
    def build_transition_matrix(self, journeys):
        counts = pd.DataFrame(0.0, index=self.all_states, columns=self.all_states)
        for journey in journeys:
            for i in range(len(journey) - 1):
                s_from, s_to = journey[i], journey[i+1]
                if s_from in self.all_states and s_to in self.all_states:
                    counts.loc[s_from, s_to] += 1.0
        
        trans = counts.copy()
        for state in self.transient_states:
            row_sum = trans.loc[state].sum()
            if row_sum > 0:
                trans.loc[state] /= row_sum
        for state in self.absorbing_states:
            trans.loc[state, state] = 1.0
        return trans

    def markov_conversion_prob(self, trans_matrix):
        """Probabilità di assorbimento in Conversion dallo stato Start."""
        Q = trans_matrix.loc[self.transient_states, self.transient_states].values.astype(float)
        R = trans_matrix.loc[self.transient_states, self.absorbing_states].values.astype(float)
        I = np.eye(len(self.transient_states))
        try:
            M = np.linalg.inv(I - Q)
            B = M @ R
            return float(B[0, 0])  # Start -> Conversion
        except np.linalg.LinAlgError:
            return 0.0

    def markov_removal_effect(self, method='null'):
        """
        Calcola Removal Effect per ogni canale.
        method='null': redirige transizioni verso stato Null (robusto, didattico)
        method='reroute': redistribuisce ai successori del canale rimosso (Anderl et al.)
        """
        base_trans = self.build_transition_matrix(self.journeys)
        base_prob = self.markov_conversion_prob(base_trans)
        
        removal_effects = {}
        for channel in self.channels:
            mod_trans = base_trans.copy()
            
            if method == 'reroute':
                # Rerouting avanzato: redistribuisce ai successori del nodo rimosso
                for s_from in self.transient_states:
                    prob_to_ch = float(mod_trans.loc[s_from, channel])
                    if prob_to_ch > 0:
                        mod_trans.loc[s_from, channel] = 0.0
                        ch_out = base_trans.loc[channel].copy()
                        ch_out[channel] = 0.0
                        ch_out_sum = float(ch_out.sum())
                        if ch_out_sum > 0:
                            for s_to in self.all_states:
                                if s_to != channel:
                                    mod_trans.loc[s_from, s_to] += prob_to_ch * (float(ch_out[s_to]) / ch_out_sum)
                        else:
                            mod_trans.loc[s_from, 'Null'] += prob_to_ch
            else:
                # Null-redirect: traffico verso canale -> abbandono
                for s_from in self.transient_states:
                    prob_to_ch = float(mod_trans.loc[s_from, channel])
                    if prob_to_ch > 0:
                        mod_trans.loc[s_from, 'Null'] += prob_to_ch
                        mod_trans.loc[s_from, channel] = 0.0
            
            # Isola il canale rimosso (solo verso Null)
            mod_trans.loc[channel, :] = 0.0
            mod_trans.loc[channel, 'Null'] = 1.0
            
            mod_prob = self.markov_conversion_prob(mod_trans)
            re = 1.0 - (mod_prob / base_prob) if base_prob > 0 else 0.0
            removal_effects[channel] = max(0.0, re)
        
        total_re = sum(removal_effects.values())
        weights = {ch: removal_effects[ch] / total_re if total_re > 0 else 0.0
                   for ch in self.channels}
        return removal_effects, weights, base_trans, base_prob

    # --- Shapley Value (Monte Carlo) ---
    def shapley_value(self, max_samples=5000):
        """Approssimazione Monte Carlo dello Shapley Value."""
        user_channels = []
        for journey, conv in zip(self.journeys, self.df['converted']):
            if conv:
                touchpoints = [tp for tp in journey if tp in self.channels]
                # Rimuovi duplicati consecutivi preservando ordine
                unique = []
                seen = set()
                for tp in touchpoints:
                    if tp not in seen:
                        unique.append(tp)
                        seen.add(tp)
                if unique:
                    user_channels.append(unique)
        
        n_total = len(user_channels)
        if n_total == 0:
            return {ch: 0.0 for ch in self.channels}
        
        user_sets = [set(uc) for uc in user_channels]
        
        def value(coalition):
            coal_set = set(coalition)
            return sum(1 for us in user_sets if us.issubset(coal_set)) / n_total
        
        shapley = {ch: 0.0 for ch in self.channels}
        n_samples = min(max_samples, len(user_channels) * 40)
        
        for _ in range(n_samples):
            idx = np.random.randint(len(user_channels))
            perm = np.random.permutation(user_channels[idx])
            for i, ch in enumerate(perm):
                before = list(perm[:i])
                after = list(perm[:i+1])
                marginal = value(after) - value(before)
                shapley[ch] += marginal
        
        for ch in shapley:
            shapley[ch] = (shapley[ch] / n_samples) * self.total_conversions
        
        # Normalizza a somma esatta conversioni
        total_shap = sum(shapley.values())
        if total_shap > 0:
            shapley = {ch: v / total_shap * self.total_conversions for ch, v in shapley.items()}
        return shapley


# ============================================================
# 4. ESECUZIONE E CONFRONTO
# ============================================================

engine = AttributionEngine(df_journeys)

# 4.1 Modelli Euristici
heuristics = engine.heuristic_models()

# 4.2 Markov (Removal Effect con null-redirect)
re, markov_w, base_trans, base_prob = engine.markov_removal_effect(method='null')
markov_df = pd.DataFrame({
    'Removal_Effect': re,
    'Markov': {ch: markov_w[ch] * engine.total_conversions for ch in engine.channels}
})

# 4.3 Shapley Value
shapley = engine.shapley_value(max_samples=6000)
shapley_series = pd.Series(shapley, name='Shapley')

# 4.4 Tabella comparativa finale
comparison = pd.DataFrame({
    'Last_Click': heuristics['last'],
    'First_Click': heuristics['first'],
    'Linear': heuristics['linear'],
    'Time_Decay': heuristics['time_decay'],
    'Markov': markov_df['Markov'],
    'Shapley': shapley_series
})
comparison = comparison[['Last_Click', 'First_Click', 'Linear', 'Time_Decay', 'Markov', 'Shapley']]
comparison['Markov_RE'] = pd.Series(re)

print("\n=== CONFRONTO ATTRIBUZIONE CONVERSIONI ===")
print(comparison.round(2))

# 4.5 Calcolo CAC per canale e per modello
cac_comparison = pd.DataFrame(index=engine.channels)
for model in ['Last_Click', 'First_Click', 'Linear', 'Time_Decay', 'Markov', 'Shapley']:
    cac_comparison[f'CAC_{model}'] = [
        engine.costs[ch] / max(comparison.loc[ch, model], 0.1) for ch in engine.channels
    ]

print("\n=== CONFRONTO CAC (€) ===")
print(cac_comparison.round(2))


# ============================================================
# 5. VISUALIZZAZIONI
# ============================================================

fig, axes = plt.subplots(2, 2, figsize=(16, 14))

# --- Plot 1: Heatmap Matrice di Transizione ---
sns.heatmap(base_trans, annot=True, fmt='.2f', cmap='YlGnBu',
            linewidths=.5, ax=axes[0,0], cbar_kws={'label': 'Probabilità'})
axes[0,0].set_title('Matrice di Transizione di Markov', fontsize=13, fontweight='bold')
axes[0,0].set_ylabel('Stato Origine')
axes[0,0].set_xlabel('Stato Destinazione')

# --- Plot 2: Confronto Pesi di Attribuzione ---
comparison_plot = comparison.drop(columns=['Markov_RE'])
comparison_plot.plot(kind='bar', ax=axes[0,1], width=0.8)
axes[0,1].set_title('Confronto Modelli di Attribuzione (Conversioni)', fontsize=13, fontweight='bold')
axes[0,1].set_ylabel('Conversioni Attribuite')
axes[0,1].legend(loc='upper right', fontsize=8)
axes[0,1].tick_params(axis='x', rotation=45)

# --- Plot 3: CAC per Canale ---
cac_comparison.plot(kind='bar', ax=axes[1,0], width=0.8, colormap='RdYlGn_r')
axes[1,0].set_title('Customer Acquisition Cost per Modello (€)', fontsize=13, fontweight='bold')
axes[1,0].set_ylabel('CAC (€)')
axes[1,0].legend(loc='upper right', fontsize=8)
axes[1,0].tick_params(axis='x', rotation=45)

# --- Plot 4: Delta CAC: Markov vs Last-Click ---
delta_cac = pd.DataFrame({
    'Delta_CAC_%': ((cac_comparison['CAC_Markov'] - cac_comparison['CAC_Last_Click']) 
                    / cac_comparison['CAC_Last_Click'] * 100)
})
colors = ['green' if x < 0 else 'red' for x in delta_cac['Delta_CAC_%']]
delta_cac.plot(kind='bar', ax=axes[1,1], color=colors, legend=False)
axes[1,1].axhline(0, color='black', linewidth=0.8)
axes[1,1].set_title('Variazione CAC: Markov vs Last-Click (%)', fontsize=13, fontweight='bold')
axes[1,1].set_ylabel('Delta %')
axes[1,1].tick_params(axis='x', rotation=45)

plt.tight_layout()
plt.savefig('mta_comparison_dashboard.png', dpi=150, bbox_inches='tight')
plt.show()

print(f"\nProbabilità base di conversione del funnel: {base_prob:.4f}")
print("Dashboard salvata come 'mta_comparison_dashboard.png'")

Output

Forse potrebbe interessarti anche:  Ottimizzazione della Produzione: Un Modello Didattico con Funzione Cobb-Douglas e Libreria Scipy

 

Dataset generato: 2806 eventi | 1142 utenti unici
Percorsi ricostruiti: 1142 | Conversioni: 507

=== CONFRONTO ATTRIBUZIONE CONVERSIONI ===
              Last_Click  First_Click  Linear  Time_Decay  Markov  Shapley  \
Content             55.0        108.0   79.96       64.38   74.93   103.96   
Demo               220.0          0.0   92.00      131.62  103.22    69.21   
Email              124.0         41.0   94.14      108.69   92.08   115.50   
Google_Ads          30.0        106.0   69.30       49.46   61.02    72.78   
LinkedIn_Ads        12.0         76.0   39.15       29.87   40.60    22.16   
SEO                 25.0        125.0   77.12       69.54   76.88    81.83   
Webinar             41.0         51.0   55.34       53.44   58.28    41.54   

              Markov_RE  
Content            0.35  
Demo               0.49  
Email              0.43  
Google_Ads         0.29  
LinkedIn_Ads       0.19  
SEO                0.36  
Webinar            0.27  

=== CONFRONTO CAC (€) ===
              CAC_Last_Click  CAC_First_Click  CAC_Linear  CAC_Time_Decay  \
Content               636.36           324.07      437.74          543.63   
Demo                  136.36        300000.00      326.08          227.92   
Email                 201.61           609.76      265.57          230.01   
Google_Ads           2000.00           566.04      865.86         1213.18   
LinkedIn_Ads         4583.33           723.68     1405.02         1841.36   
SEO                  1800.00           360.00      583.48          647.14   
Webinar               975.61           784.31      722.77          748.51   

              CAC_Markov  CAC_Shapley  
Content           467.12       336.66  
Demo              290.64       433.44  
Email             271.50       216.44  
Google_Ads        983.30       824.35  
LinkedIn_Ads     1354.78      2481.71  
SEO               585.36       549.92  
Webinar           686.37       962.82  
Probabilità base di conversione del funnel: 0.4440

 

 


💡Perché questo approccio è scalabile

Dal punto di vista ingegneristico, lo script che abbiamo utilizzato presenta due peculiarità che lo rendono adatto ad ambienti di produzione reali:

  • Il calcolo matriciale per Markov: Invece di simulare migliaia di percorsi fittizi per determinare le probabilità di conversione (operazione pesante dal punto di vista computazionale), la funzione markov_conversion_prob sfrutta l’algebra lineare. Calcolando l’inversa della matrice fondamentale, otteniamo matematicamente in un colpo solo la probabilità di assorbimento esatta. Una soluzione elegante e rapida.
  • L’Approssimazione Monte Carlo per Shapley: Il vero tallone d’Achille dello Shapley Value classico è che richiede di calcolare le permutazioni per ogni singola coalizione di canali. Se con 7 canali il calcolo è immediato, con 20 touchpoint diversi un sistema andrebbe fatalmente in blocco. La nostra funzione shapley_value aggira il problema campionando casualmente le permutazioni (metodo Monte Carlo), garantendo risultati solidi ed estremamente precisi con tempi di esecuzione frazionali.

La matrice di transizione

La prima figura restituita dal nostro codice rappresenta il cuore dell’intero modello. La matrice di transizione (o heatmap) mostra empiricamente la probabilità degli utenti di passare da uno stato all’altro. Leggendola, notiamo ad esempio che la fase di Start spinge molti utenti a entrare nel funnel tramite SEO, Content o Google Ads; che dall’Email un segmento importante atterra sulla Demo; e che dalla Demo, infine, quasi la metà dell’audience arriva alla conversione.

Questa matrice racchiude un potenziale analitico formidabile. Il modello Markoviano, a differenza delle euristiche, non osserva un touchpoint come un evento a se stante, ma ne valuta il ruolo all’interno dell’intero ecosistema probabilistico del funnel. È proprio questa visione sistemica che restituisce risultati tanto dirompenti.


Il confronto tra i modelli di attribuzione

La discrepanza tra i diversi approcci emerge chiaramente nel momento in cui confrontiamo il numero di conversioni attribuite per canale.

Il modello Last Click assegna l’intero merito della conversione all’ultimo contatto. Nel nostro dataset, questo si traduce in oltre 220 conversioni imputate alla Demo e appena una ventina riconosciute alla SEO. Leggendo questo dato in modo letterale e decontestualizzato, si direbbe che il traffico organico o il Content Marketing ricoprano un ruolo totalmente marginale. Diametralmente opposto, il First Click premia senza logica il primo aggancio, azzerando quasi totalmente il credito dei canali che presidiano il Bottom of the Funnel. Entrambi offrono una visione miope del customer journey, incapace di mappare l’effort reale che porta alla firma del contratto.

Forse potrebbe interessarti anche:  Data Storytelling: Dalla Semiotica Visiva a quella Sociale per Grafici che Parlano Davvero

Se passiamo ai modelli distribuiti, il Linear spartisce banalmente il merito in modo uniforme, introducendo la pericolosa (e falsa) assunzione che ogni touchpoint valga esattamente quanto l’altro. Il Time Decay, pur assegnando logicamente più peso man mano che l’utente si avvicina alla conversione, fallisce tipicamente nel SaaS B2B dove i cicli commerciali si misurano in mesi: finirà inevitabilmente per affossare il reale impatto delle attività di awareness pura come Webinar e traffico organico.


Cosa cambia con Markov (e cos’è il Removal Effect)

Le Catene di Markov superano questi limiti filosofici. Il modello non riconosce un valore a un canale per il semplice fatto che sia apparso nello storico del cliente. Il valore gli viene assegnato misurando matematicamente quanto le performance del funnel crollerebbero in sua assenza. Questo concetto prende il nome di Removal Effect.

Guardando i risultati del nostro codice:

Canale Removal Effect
Demo 0,49
Email 0,43
SEO 0,36
Content 0,35
Google Ads 0,29
Webinar 0,27
LinkedIn Ads 0,19

Il Removal Effect non indica la percentuale assoluta di conversioni generate da un canale, ma calcola in maniera precisa la dipendenza strutturale del funnel da quel nodo. Avere la Demo a 0,49 significa che estirpandola dalla rete, la probabilità complessiva dell’azienda di convertire un lead crollerebbe di circa il 49%. È la prova della sua essenzialità. Ma lo stesso ragionamento riabilita la SEO: pur non registrando quasi nessun Last Click, il suo Removal Effect (0,36) certifica che eliminarla inaridirebbe irrimediabilmente il funnel a valle.

⚙️ Nota tecnica: Null Redirect vs Rerouting

Nel nostro codice Python abbiamo implementato due versioni del calcolo del Removal Effect: method='null' e method='reroute'. Nel nostro esempio utilizziamo la versione Null Redirect perché è più immediata e leggibile dal punto di vista didattico (il traffico del canale rimosso viene semplicemente mandato allo stato di abbandono). Nei sistemi di attribuzione industriali avanzati è invece frequente utilizzare il Rerouting (proposto accademicamente da Anderl et al.), che redistribuisce proporzionalmente il traffico residuo agli altri nodi successori anziché interromperlo.


Shapley racconta una storia diversa

Mentre Markov guarda alle dipendenze strutturali, lo Shapley Value si affida alla teoria dei giochi cooperativi, trattando ogni canale come il membro di una squadra.

Il valore finale restituito è il contributo marginale medio che quel touchpoint apporta all’interno di tutte le possibili formazioni (o coalizioni). Per la sua architettura matematica rigorosa, Shapley è considerato da molti analisti il modello più solido ed equo del panorama. Smette di cercare disperatamente un unico eroe da premiare (il primo o l’ultimo) e quantifica, invece, l’effettivo apporto incrementale che ogni canale garantisce al marketing mix nel suo complesso.


Il CAC cambia radicalmente

Il vero punto di caduta manageriale di questo ragionamento emerge dal Costo di Acquisizione Cliente (CAC). I costi vivi sostenuti dal marketing restano ovviamente invariati, ma la scelta del modello rivoluziona la distribuzione e l’ammortamento dell’investimento. I risultati parlano chiaro:

Canale CAC Last Click CAC Markov
Content 636 € 467 €
Demo 136 € 291 €
Email 202 € 272 €
Google Ads 2000 € 983 €
LinkedIn Ads 4583 € 1355 €
SEO 1800 € 585 €
Webinar 976 € 686 €

Il caso SEO

Sotto la lente limitata del Last Click, la SEO ottiene pochissimo credito e registra di conseguenza un CAC stellare di 1.800 €. Spostandoci su Markov, che riconosce il prezioso lavoro di innesco, le vengono attribuite un volume più veritiero di conversioni e il suo CAC crolla a 585 €. Agli occhi di un CFO questo dato cambia la strategia annuale. La SEO non è diventata improvvisamente economica, era il modello precedente a raccontare una bugia.

Il caso LinkedIn Ads

Scenario analogo per LinkedIn Ads. Penalizzato dal Last Click con un costo per acquisizione apparente di quasi 4.600 €, trova giustizia in Markov che lo riconosce come assiduo preparatore e lo riposiziona a un molto più gestibile 1.355 €. Pur non essendo il touchpoint più a buon mercato in assoluto, assume una dimensione completamente diversa a livello strategico.

Il delta percentuale tra i modelli si fa ancora più evidente visualizzando graficamente la variazione. Osserverete abbattimenti sostanziali per canali formativi (Webinar, Content) ed esplorativi. Noterete altresì che la Demo vede il proprio CAC peggiorare rispetto al Last Click. Non è un demerito della Demo: semplicemente, il nuovo modello le toglie i meriti eccessivi sottratti agli altri touchpoint. Grafici come questo sono la vostra arma migliore in sala riunioni.


Dal dataset sintetico al CRM reale: cosa succede in un’azienda vera

Il nostro script Python ha lavorato su un log di eventi simulato, immacolato e perfettamente lineare. Nel mondo reale, però, i dati non nascono già ordinati in un comodo file pronto per essere processato. L’implementazione di modelli avanzati come Markov o Shapley all’interno di un’azienda strutturata richiede un’infrastruttura solida.

In un ambiente SaaS B2B, i touchpoint del cliente sono frammentati lungo mesi di interazioni e provengono solitamente da una combinazione complessa di sistemi:

  • Sistemi Web e App Analytics: Google Analytics 4, Mixpanel, Amplitude;
  • Piattaforme CRM e Marketing Automation: HubSpot, Salesforce, Microsoft Dynamics;
  • Infrastrutture di Event Tracking: Segment, Snowplow;
  • Customer Data Platform (CDP).

È in questo ecosistema frammentato che entra in gioco il ruolo cruciale del Data Engineer (o dell’Analytics Engineer). Prima ancora di lanciare un singolo calcolo probabilistico, il team dati deve affrontare la vera sfida tecnica del Multi-Touch Attribution: il Session Stitching e l’Identity Resolution.

Cosa significa all’atto pratico? Significa prendere l’utente anonimo che ha letto due articoli del blog tramite SEO, associarlo al lead che un mese dopo ha scaricato un whitepaper (lasciando l’email in HubSpot) e collegarlo all’opportunità vinta nel CRM (Salesforce) dopo un paio di call commerciali.

Forse potrebbe interessarti anche:  Attribuzione Multi-Touch nel SaaS B2B: Catene di Markov, Shapley Value e Calcolo del CAC

Il lavoro preparatorio del Data Engineer consiste nel:

  • Risolvere le identità utente: unire cookie di navigazione, indirizzi email, user_id loggati e ID del CRM in un unico profilo cronologico e coerente.
  • Eliminare il rumore e i duplicati: pulire i rimbalzi tecnici tra i vari domini aziendali, i refresh di pagina o gli eventi di sistema che non rappresentano un reale touchpoint di marketing.
  • Standardizzare le fonti: trasformare la giungla di log sparsi in quel dataset ordinato e temporale che nel nostro script Python abbiamo chiamato [math]df_logs[/math].

Solo quando la pipeline dati è in grado di ricostruire fedelmente il customer journey dal primo clic anonimo su Google fino alla fattura pagata, il motore probabilistico di Markov e Shapley può sprigionare il suo reale valore strategico. Non serve il “modello perfetto” se i dati in ingresso non riflettono la realtà del percorso d’acquisto.

 


I limiti del modello: quando Markov e Shapley non bastano

Sebbene le Catene di Markov e lo Shapley Value rappresentino lo stato dell’arte dell’Attribuzione Multi-Touch (MTA) per la loro robustezza statistica, è fondamentale non trattarli come oracoli infallibili. Ogni modello matematico è una semplificazione della realtà e presenta delle vulnerabilità strutturali.

Per mantenere un alto livello di affidabilità nelle decisioni (e per tutelare l’autorevolezza del team dati), è necessario conoscere e dichiarare questi limiti:

  • Dipendenza totale dalla qualità dei log (Garbage In, Garbage Out): Come abbiamo visto nella sezione dedicata all’infrastruttura dati aziendale, se il tracciamento è lacunoso, duplicato o se la Identity Resolution fallisce, l’algoritmo calcolerà i pesi probabilistici su reti di percorsi fittizi, restituendo risultati fuorvianti.
  • Necessità di customer journey completi: L’approccio MTA richiede dati granulari a livello di singolo utente (user-level data). Con le crescenti restrizioni sulla privacy (ITP, cookie deprecation, ad-blocker) e la frammentazione cross-device, tracciare il 100% di un percorso lungo mesi sta diventando un’utopia tecnica.
  • Cecità verso i fattori esogeni: Il modello osserva esclusivamente gli stati e i touchpoint tracciati. Non sa se le conversioni sono schizzate alle stelle per via di una promozione stagionale, se c’è stata una mossa aggressiva di un competitor, o se un trend macroeconomico sta contraendo la domanda.
  • Incapacità di valutare la qualità creativa: Per una Catena di Markov, un clic su una creatività eccezionale e un clic su un annuncio mediocre sono identici dal punto di vista topologico. Il modello ottimizza le “strade”, ma non valuta la qualità del “veicolo” (il messaggio, il copy, l’impatto visivo).
  • Obsolescenza e aggiornamento periodico: La matrice di transizione fotografa i comportamenti di un intervallo di tempo specifico. Se il mercato muta, se si lancia un nuovo prodotto o se si stravolge il budget, la matrice deve essere ricalcolata per non prendere decisioni basate su pesi ormai anacronistici.

L’evoluzione: la triangolazione dei dati

È proprio a causa di queste limitazioni (in particolare l’impossibilità di misurare i fattori esterni) che le organizzazioni data-driven più mature non si affidano quasi mai esclusivamente al Multi-Touch Attribution.

L’approccio moderno consiste nell’integrare i modelli granulari bottom-up (come Markov) con modelli macroscopici top-down. Affiancando l’MTA al Marketing Mix Modeling (MMM), specialmente nella sua veste evoluta di Bayesian MMM,  e a tecniche di Causal Inference (come gli esperimenti di incrementality o i geo-lift test), si chiude finalmente il cerchio.

In questo ecosistema, Markov e Shapley spiegano come gli utenti si muovono e convertono tra i vari canali, mentre l’MMM e l’inferenza causale misurano se quell’investimento sta generando un valore realmente incrementale sul fatturato aziendale.


Quale modello usare?

Una domanda attraversa trasversalmente tutti i dipartimenti di data analysis: non esiste un modello universalmente superiore agli altri, ma esiste il modello ideale in base all’obiettivo di business in corso.

Obiettivo Modello consigliato
Reportistica base per allineamento Last Click
Cruscotti operativi di controllo settimanale Time Decay
Analisi dell’impalcatura del funnel Markov
Distribuzione delle risorse finanziarie Shapley
Budgeting previsionale avanzato Markov + Shapley

Le aziende ad alta maturità analitica tendono a impiegare il Last Click quasi unicamente come tracciante per i report storici ed operativi, delegando a Markov e Shapley ogni decisione strategica legata alla pianificazione finanziaria e agli investimenti.


Conclusioni

Abbandonare le certezze immediate del Last Click per abbracciare logiche probabilistiche può sembrare una complicazione inutile. Eppure, in un panorama B2B dove i cicli di acquisto durano mesi e i touchpoint si accavallano, è l’unico modo per dare un senso ai dati. I numeri e i grafici che abbiamo generato non sono un mero esercizio di stile in Python: sono l’argomentazione solida che vi serve per difendere gli investimenti che lavorano dietro le quinte. Ora i modelli li avete, e l’algoritmo per calcolarli anche. Non vi resta che collegarli ai vostri log e vedere cosa succede.

Tuttavia, mentre l’attribuzione multi-touch risponde alla domanda “quale touchpoint ha contribuito alla conversione?”, il passo successivo per la direzione marketing è un altro:

“quanto budget assegnare ottimamente a ciascun canale su scala macro?“.

Per affrontare questa sfida strategica entrano in gioco i modelli di Marketing Mix Modeling (MMM) e la Causal Inference, che analizzeremo nel dettaglio nel prossimo articolo di questa serie.

📚 Analytics, SaaS e Decision Intelligence

Comprendere le dinamiche di crescita di un business digitale richiede strumenti che vadano oltre le dashboard tradizionali. Dai modelli di attribuzione multi-touch alle catene di Markov, fino alle metriche finanziarie del SaaS e al lock-in competitivo, queste guide mostrano come dati, matematica e strategia convergano nelle decisioni aziendali.

👉Attribuzione Multi-Touch nel SaaS B2B: Catene di Markov, Shapley Value e calcolo del CAC

👉Rule of 40 nel SaaS: la formula che gli investitori usano per valutare un business

👉Dal prodotto all’ecosistema: la matematica del lock-in nel business digitale

👉Catene di Markov in Python: modellare funnel, retention e churn oltre le classiche dashboard

👉Analisi dati customer-centrica con Python: CLV, sentiment analysis e churn prediction

Pubblicità