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.
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
💡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_probsfrutta 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_valueaggira 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.
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 |
| 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'emethod='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 € |
| 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.
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





