Attribution Models Deep Dive
Master every attribution model: First-touch, last-touch, linear, time decay, position-based, W-shaped, and data-driven. Implementation code and use cases.
Understanding Attribution Models
Attribution models are mathematical frameworks that determine how conversion credit is distributed across marketing touchpoints. The model you choose fundamentally shapes how you measure channel performance and allocate budget.
Critical Insight: There is no "correct" attribution model. Each model tells a different story. The best approach is to understand what each model reveals and use multiple models to gain complete insight.
Model Categories
┌─────────────────────────────────────────────────────────────┐
│ ATTRIBUTION MODELS │
├─────────────────────────────────────────────────────────────┤
│ │
│ SINGLE-TOUCH (Rule-Based) │
│ ├── First-Touch → All credit to first interaction │
│ ├── Last-Touch → All credit to final interaction │
│ └── Last Non-Direct → Credit to last marketing touch │
│ │
│ MULTI-TOUCH (Rule-Based) │
│ ├── Linear → Equal credit to all touchpoints │
│ ├── Time Decay → More credit to recent touches │
│ ├── Position-Based → 40/20/40 first/middle/last │
│ └── W-Shaped → Credit to key pipeline stages │
│ │
│ ALGORITHMIC (Data-Driven) │
│ ├── Markov Chain → Probability-based transitions │
│ ├── Shapley Value → Game theory fair distribution │
│ └── Machine Learning → Pattern-based prediction │
│ │
└─────────────────────────────────────────────────────────────┘Single-Touch Models
First-Touch Attribution
First-touch gives 100% credit to the initial interaction that introduced the customer to your brand.
// First-Touch Attribution Implementation
function firstTouchAttribution(touchpoints, conversionValue) {
if (touchpoints.length === 0) {
return { direct: { credit: 1, value: conversionValue } };
}
const firstTouch = touchpoints[0];
return {
[firstTouch.channel]: {
credit: 1.0,
value: conversionValue,
campaign: firstTouch.campaign,
timestamp: firstTouch.timestamp
}
};
}
// Example journey
const journey = [
{ channel: 'organic_search', campaign: null, timestamp: '2026-01-01' },
{ channel: 'email', campaign: 'newsletter', timestamp: '2026-01-05' },
{ channel: 'paid_search', campaign: 'brand', timestamp: '2026-01-10' }
];
firstTouchAttribution(journey, 100);
// Result: { organic_search: { credit: 1.0, value: 100 } }When to Use:
- Measuring brand awareness campaigns
- Understanding acquisition channel effectiveness
- Top-of-funnel optimization
- Long sales cycles where first touch matters
Limitations:
- Ignores all subsequent touchpoints
- Overvalues awareness, undervalues conversion
- Doesn't reflect full customer journey
Best Channels for First-Touch: | Channel | Typical First-Touch Share | |---------|---------------------------| | Organic Search | 25-35% | | Paid Social | 15-25% | | Display | 10-20% | | Referral | 10-15% | | Direct | 15-25% |
Last-Touch Attribution
Last-touch gives 100% credit to the final interaction before conversion.
// Last-Touch Attribution Implementation
function lastTouchAttribution(touchpoints, conversionValue) {
if (touchpoints.length === 0) {
return { direct: { credit: 1, value: conversionValue } };
}
const lastTouch = touchpoints[touchpoints.length - 1];
return {
[lastTouch.channel]: {
credit: 1.0,
value: conversionValue,
campaign: lastTouch.campaign,
timestamp: lastTouch.timestamp
}
};
}
// Same journey as above
lastTouchAttribution(journey, 100);
// Result: { paid_search: { credit: 1.0, value: 100 } }When to Use:
- Quick performance snapshots
- Bottom-of-funnel optimization
- Short sales cycles (< 7 days)
- When conversion channels are well-defined
Limitations:
- Overvalues closing channels
- Undervalues awareness and nurturing
- Branded search appears artificially effective
Why Last-Touch Over-Values Brand Search:
Typical Journey:
Display Ad → Blog Post → Email → Brand Search → Purchase
Last-Touch sees:
"Brand Search drove the sale!"
Reality:
Without Display, Blog, and Email, the user would never have
searched for your brand in the first place.Last Non-Direct Click
Gives credit to the last marketing touchpoint, ignoring direct traffic.
// Last Non-Direct Click Implementation
function lastNonDirectAttribution(touchpoints, conversionValue) {
// Filter out direct visits
const marketingTouches = touchpoints.filter(t => t.channel !== 'direct');
if (marketingTouches.length === 0) {
// Only direct visits - attribute to direct
return { direct: { credit: 1, value: conversionValue } };
}
const lastMarketing = marketingTouches[marketingTouches.length - 1];
return {
[lastMarketing.channel]: {
credit: 1.0,
value: conversionValue,
campaign: lastMarketing.campaign,
timestamp: lastMarketing.timestamp
}
};
}
// Journey with direct at end
const journeyWithDirect = [
{ channel: 'paid_social', campaign: 'prospecting', timestamp: '2026-01-01' },
{ channel: 'email', campaign: 'nurture', timestamp: '2026-01-05' },
{ channel: 'direct', campaign: null, timestamp: '2026-01-07' }
];
lastNonDirectAttribution(journeyWithDirect, 100);
// Result: { email: { credit: 1.0, value: 100 } }When to Use:
- GA4 default (understand what you're seeing)
- When direct traffic is significant
- Better than pure last-touch for most cases
Multi-Touch Models
Linear Attribution
Distributes credit equally across all touchpoints.
// Linear Attribution Implementation
function linearAttribution(touchpoints, conversionValue) {
if (touchpoints.length === 0) {
return { direct: { credit: 1, value: conversionValue } };
}
const creditPerTouch = 1 / touchpoints.length;
const valuePerTouch = conversionValue / touchpoints.length;
const attribution = {};
touchpoints.forEach(touch => {
if (!attribution[touch.channel]) {
attribution[touch.channel] = {
credit: 0,
value: 0,
touchpoints: 0
};
}
attribution[touch.channel].credit += creditPerTouch;
attribution[touch.channel].value += valuePerTouch;
attribution[touch.channel].touchpoints += 1;
});
return attribution;
}
// Five-touch journey
const fiveTouchJourney = [
{ channel: 'display', timestamp: '2026-01-01' },
{ channel: 'organic_search', timestamp: '2026-01-03' },
{ channel: 'email', timestamp: '2026-01-05' },
{ channel: 'paid_search', timestamp: '2026-01-07' },
{ channel: 'direct', timestamp: '2026-01-08' }
];
linearAttribution(fiveTouchJourney, 100);
// Result:
// {
// display: { credit: 0.2, value: 20 },
// organic_search: { credit: 0.2, value: 20 },
// email: { credit: 0.2, value: 20 },
// paid_search: { credit: 0.2, value: 20 },
// direct: { credit: 0.2, value: 20 }
// }When to Use:
- When all touchpoints are considered equally important
- Long, complex customer journeys
- As a baseline for comparison
- When you lack data for more sophisticated models
Limitations:
- Doesn't account for touchpoint importance
- May over-credit low-value interactions
- Naive assumption of equal contribution
Time Decay Attribution
More recent touchpoints receive more credit, using exponential decay.
// Time Decay Attribution Implementation
function timeDecayAttribution(touchpoints, conversionValue, halfLifeDays = 7) {
if (touchpoints.length === 0) {
return { direct: { credit: 1, value: conversionValue } };
}
const conversionTime = new Date(touchpoints[touchpoints.length - 1].timestamp);
const halfLifeMs = halfLifeDays * 24 * 60 * 60 * 1000;
const attribution = {};
let totalWeight = 0;
// Calculate weights
const weights = touchpoints.map(touch => {
const touchTime = new Date(touch.timestamp);
const timeDiff = conversionTime - touchTime;
const weight = Math.pow(2, -timeDiff / halfLifeMs);
totalWeight += weight;
return { touch, weight };
});
// Normalize and assign credit
weights.forEach(({ touch, weight }) => {
const normalizedCredit = weight / totalWeight;
if (!attribution[touch.channel]) {
attribution[touch.channel] = { credit: 0, value: 0 };
}
attribution[touch.channel].credit += normalizedCredit;
attribution[touch.channel].value += conversionValue * normalizedCredit;
});
return attribution;
}
// Journey over 14 days
const timeJourney = [
{ channel: 'display', timestamp: '2026-01-01' }, // 14 days ago
{ channel: 'organic_search', timestamp: '2026-01-08' }, // 7 days ago
{ channel: 'email', timestamp: '2026-01-12' }, // 3 days ago
{ channel: 'paid_search', timestamp: '2026-01-15' } // Conversion day
];
timeDecayAttribution(timeJourney, 100, 7);
// Result (with 7-day half-life):
// {
// display: { credit: 0.07, value: 7 }, // 14 days = 1/4 weight
// organic_search: { credit: 0.14, value: 14 }, // 7 days = 1/2 weight
// email: { credit: 0.28, value: 28 }, // 3 days = ~0.7 weight
// paid_search: { credit: 0.51, value: 51 } // 0 days = 1.0 weight
// }Configuring Half-Life:
| Business Type | Recommended Half-Life | Rationale | |---------------|----------------------|-----------| | E-commerce (impulse) | 1-3 days | Quick purchase decisions | | E-commerce (considered) | 7 days | Research period | | B2B SaaS | 14-30 days | Longer evaluation | | Enterprise | 30-60 days | Complex buying process |
When to Use:
- Short to medium sales cycles
- When recent interactions drive decisions
- E-commerce and retail
- When you want to balance recency with journey
Position-Based (U-Shaped) Attribution
40% to first touch, 40% to last touch, 20% split among middle.
// Position-Based Attribution Implementation
function positionBasedAttribution(touchpoints, conversionValue) {
if (touchpoints.length === 0) {
return { direct: { credit: 1, value: conversionValue } };
}
if (touchpoints.length === 1) {
return {
[touchpoints[0].channel]: { credit: 1, value: conversionValue }
};
}
if (touchpoints.length === 2) {
// Split 50/50 between first and last
const attribution = {};
attribution[touchpoints[0].channel] = { credit: 0.5, value: conversionValue * 0.5 };
if (touchpoints[1].channel === touchpoints[0].channel) {
attribution[touchpoints[0].channel].credit = 1;
attribution[touchpoints[0].channel].value = conversionValue;
} else {
attribution[touchpoints[1].channel] = { credit: 0.5, value: conversionValue * 0.5 };
}
return attribution;
}
const attribution = {};
const first = touchpoints[0];
const last = touchpoints[touchpoints.length - 1];
const middle = touchpoints.slice(1, -1);
// First touch: 40%
attribution[first.channel] = {
credit: 0.4,
value: conversionValue * 0.4
};
// Last touch: 40%
if (attribution[last.channel]) {
attribution[last.channel].credit += 0.4;
attribution[last.channel].value += conversionValue * 0.4;
} else {
attribution[last.channel] = {
credit: 0.4,
value: conversionValue * 0.4
};
}
// Middle touches: 20% split
const middleCredit = 0.2 / middle.length;
const middleValue = (conversionValue * 0.2) / middle.length;
middle.forEach(touch => {
if (attribution[touch.channel]) {
attribution[touch.channel].credit += middleCredit;
attribution[touch.channel].value += middleValue;
} else {
attribution[touch.channel] = {
credit: middleCredit,
value: middleValue
};
}
});
return attribution;
}
// Example journey
positionBasedAttribution(fiveTouchJourney, 100);
// Result:
// {
// display: { credit: 0.4, value: 40 }, // First
// organic_search: { credit: 0.067, value: 6.7 }, // Middle
// email: { credit: 0.067, value: 6.7 }, // Middle
// paid_search: { credit: 0.067, value: 6.7 }, // Middle
// direct: { credit: 0.4, value: 40 } // Last
// }When to Use:
- Most B2B and B2C businesses
- When both acquisition and conversion matter
- Moderate sales cycles (7-60 days)
- As a good default multi-touch model
Visualization:
Credit Distribution Curve (U-Shape):
40% │ ● ●
│ ╲ ╱
│ ╲ ╱
20% │ ● ─ ─ ● ─ ─ ● ─ ─ ● ─ ─ ●
│
0% └───────────────────────────────────────
First Middle Touchpoints LastW-Shaped Attribution
For B2B: 30% each to first touch, lead creation, and opportunity creation.
// W-Shaped Attribution Implementation
function wShapedAttribution(touchpoints, conversionValue, keyStages) {
// keyStages: { leadCreation: timestamp, opportunityCreation: timestamp }
if (touchpoints.length === 0) {
return { direct: { credit: 1, value: conversionValue } };
}
const attribution = {};
const first = touchpoints[0];
const last = touchpoints[touchpoints.length - 1];
// Find touchpoint closest to lead creation
const leadTouch = findClosestTouchpoint(touchpoints, keyStages.leadCreation);
// Find touchpoint closest to opportunity creation
const opptyTouch = findClosestTouchpoint(touchpoints, keyStages.opportunityCreation);
// Assign 30% to each key stage
const keyStageCredit = 0.3;
const keyStageValue = conversionValue * 0.3;
// First touch: 30%
addCredit(attribution, first.channel, keyStageCredit, keyStageValue);
// Lead creation touch: 30%
addCredit(attribution, leadTouch.channel, keyStageCredit, keyStageValue);
// Opportunity creation touch: 30%
addCredit(attribution, opptyTouch.channel, keyStageCredit, keyStageValue);
// Remaining 10% split among other touches
const otherTouches = touchpoints.filter(t =>
t !== first && t !== leadTouch && t !== opptyTouch
);
if (otherTouches.length > 0) {
const otherCredit = 0.1 / otherTouches.length;
const otherValue = (conversionValue * 0.1) / otherTouches.length;
otherTouches.forEach(touch => {
addCredit(attribution, touch.channel, otherCredit, otherValue);
});
}
return attribution;
}
function findClosestTouchpoint(touchpoints, targetTimestamp) {
const target = new Date(targetTimestamp).getTime();
let closest = touchpoints[0];
let minDiff = Math.abs(new Date(touchpoints[0].timestamp).getTime() - target);
touchpoints.forEach(touch => {
const diff = Math.abs(new Date(touch.timestamp).getTime() - target);
if (diff < minDiff) {
minDiff = diff;
closest = touch;
}
});
return closest;
}
function addCredit(attribution, channel, credit, value) {
if (attribution[channel]) {
attribution[channel].credit += credit;
attribution[channel].value += value;
} else {
attribution[channel] = { credit, value };
}
}B2B Journey Example:
Timeline:
─────────────────────────────────────────────────────────────────→
│ │ │ │
Blog Post Webinar Demo Request Proposal
(First Touch) (Lead Created) (Opportunity) (Close)
30% 30% 30% 10%Data-Driven Attribution
Markov Chain Attribution
Models customer journeys as state transitions with removal effect.
# Markov Chain Attribution Implementation
import numpy as np
from collections import defaultdict
class MarkovAttribution:
def __init__(self):
self.transitions = defaultdict(lambda: defaultdict(int))
self.total_conversions = 0
def add_journey(self, touchpoints, converted):
"""Add a customer journey to the model"""
# Add start state
path = ['(start)'] + [t['channel'] for t in touchpoints]
if converted:
path.append('(conversion)')
self.total_conversions += 1
else:
path.append('(null)')
# Count transitions
for i in range(len(path) - 1):
self.transitions[path[i]][path[i + 1]] += 1
def get_transition_matrix(self):
"""Convert counts to probabilities"""
states = list(self.transitions.keys())
n = len(states)
matrix = np.zeros((n, n))
for i, from_state in enumerate(states):
total = sum(self.transitions[from_state].values())
for j, to_state in enumerate(states):
if total > 0:
matrix[i][j] = self.transitions[from_state][to_state] / total
return matrix, states
def calculate_removal_effect(self, channel):
"""Calculate conversion probability when channel is removed"""
# Get base conversion rate
base_rate = self._simulate_conversions()
# Calculate rate without channel
removed_rate = self._simulate_conversions(remove_channel=channel)
# Removal effect is the decrease in conversions
return (base_rate - removed_rate) / base_rate if base_rate > 0 else 0
def get_attribution(self):
"""Calculate attribution for each channel"""
channels = [s for s in self.transitions.keys()
if not s.startswith('(')]
removal_effects = {}
for channel in channels:
removal_effects[channel] = self.calculate_removal_effect(channel)
# Normalize to sum to 1
total_effect = sum(removal_effects.values())
attribution = {
channel: effect / total_effect
for channel, effect in removal_effects.items()
} if total_effect > 0 else {}
return attribution
def _simulate_conversions(self, remove_channel=None, simulations=10000):
"""Monte Carlo simulation of conversion probability"""
matrix, states = self.get_transition_matrix()
state_to_idx = {s: i for i, s in enumerate(states)}
conversions = 0
start_idx = state_to_idx.get('(start)', 0)
conv_idx = state_to_idx.get('(conversion)')
for _ in range(simulations):
current = start_idx
visited = set()
while current not in visited:
visited.add(current)
state_name = states[current]
if state_name == '(conversion)':
conversions += 1
break
elif state_name == '(null)':
break
# Skip removed channel
if remove_channel and state_name == remove_channel:
# Redistribute probability
probs = matrix[current].copy()
channel_idx = state_to_idx.get(remove_channel)
if channel_idx:
probs[channel_idx] = 0
if probs.sum() > 0:
probs = probs / probs.sum()
else:
probs = matrix[current]
if probs.sum() == 0:
break
current = np.random.choice(len(states), p=probs)
return conversions / simulations
# Usage example
markov = MarkovAttribution()
# Add journeys
journeys = [
(['display', 'organic', 'email', 'paid_search'], True),
(['organic', 'email', 'direct'], True),
(['paid_social', 'display', 'organic'], False),
(['email', 'direct'], True),
# ... add more journeys
]
for touchpoints, converted in journeys:
markov.add_journey([{'channel': c} for c in touchpoints], converted)
attribution = markov.get_attribution()
print(attribution)Shapley Value Attribution
Game theory approach for fair credit distribution.
# Shapley Value Attribution Implementation
from itertools import combinations
import numpy as np
class ShapleyAttribution:
def __init__(self):
self.conversion_data = []
def add_journey(self, touchpoints, converted, value=1):
channels = set(t['channel'] for t in touchpoints)
self.conversion_data.append({
'channels': channels,
'converted': converted,
'value': value
})
def get_conversion_rate(self, channel_subset):
"""Get conversion rate for journeys containing exactly these channels"""
matching = [j for j in self.conversion_data
if j['channels'] == channel_subset]
if not matching:
return 0
converted = sum(1 for j in matching if j['converted'])
return converted / len(matching)
def calculate_shapley_values(self):
"""Calculate Shapley values for each channel"""
# Get all unique channels
all_channels = set()
for journey in self.conversion_data:
all_channels.update(journey['channels'])
all_channels = list(all_channels)
n = len(all_channels)
shapley_values = {}
for channel in all_channels:
shapley = 0
other_channels = [c for c in all_channels if c != channel]
# Iterate over all possible subsets of other channels
for size in range(len(other_channels) + 1):
for subset in combinations(other_channels, size):
subset_set = set(subset)
# Value with channel
with_channel = self._coalition_value(subset_set | {channel})
# Value without channel
without_channel = self._coalition_value(subset_set)
# Marginal contribution
marginal = with_channel - without_channel
# Shapley weight
weight = (np.math.factorial(size) *
np.math.factorial(n - size - 1) /
np.math.factorial(n))
shapley += weight * marginal
shapley_values[channel] = shapley
# Normalize
total = sum(shapley_values.values())
if total > 0:
shapley_values = {k: v/total for k, v in shapley_values.items()}
return shapley_values
def _coalition_value(self, channels):
"""Value generated by a coalition of channels"""
matching = [j for j in self.conversion_data
if channels.issubset(j['channels'])]
if not matching:
return 0
return sum(j['value'] for j in matching if j['converted'])
# Usage
shapley = ShapleyAttribution()
# Add journey data
shapley.add_journey([{'channel': 'display'}, {'channel': 'email'}], True, 100)
shapley.add_journey([{'channel': 'organic'}, {'channel': 'email'}], True, 150)
shapley.add_journey([{'channel': 'display'}], False, 0)
# ... add more data
values = shapley.calculate_shapley_values()
print(values)Model Comparison Framework
Side-by-Side Analysis
interface ModelComparison {
channel: string;
firstTouch: number;
lastTouch: number;
linear: number;
timeDecay: number;
positionBased: number;
dataDriven: number;
}
function compareModels(
touchpoints: Touchpoint[],
conversionValue: number
): ModelComparison[] {
const models = {
firstTouch: firstTouchAttribution(touchpoints, conversionValue),
lastTouch: lastTouchAttribution(touchpoints, conversionValue),
linear: linearAttribution(touchpoints, conversionValue),
timeDecay: timeDecayAttribution(touchpoints, conversionValue, 7),
positionBased: positionBasedAttribution(touchpoints, conversionValue)
};
// Get all channels
const allChannels = new Set<string>();
Object.values(models).forEach(model => {
Object.keys(model).forEach(channel => allChannels.add(channel));
});
// Build comparison
return Array.from(allChannels).map(channel => ({
channel,
firstTouch: models.firstTouch[channel]?.value || 0,
lastTouch: models.lastTouch[channel]?.value || 0,
linear: models.linear[channel]?.value || 0,
timeDecay: models.timeDecay[channel]?.value || 0,
positionBased: models.positionBased[channel]?.value || 0,
dataDriven: 0 // Requires historical data
}));
}Model Selection Guide
| Scenario | Recommended Model | Why | |----------|-------------------|-----| | < 500 conversions/month | Position-Based | Not enough data for DDA | | E-commerce, short cycle | Time Decay (3-day) | Recent touches drive purchase | | B2B, long cycle | W-Shaped | Key stages matter most | | Content/media | Linear | All content contributes | | > 1000 conversions/month | Data-Driven | Enough data for ML | | Multi-channel comparison | Position-Based | Balanced view |
Implementing in GA4
// GA4 Attribution Model Selection
// Navigate to: Admin → Attribution Settings
const ga4Models = {
// Available models
'data_driven': {
name: 'Data-Driven',
description: 'ML assigns credit based on your data',
requirements: '400+ conversions in 30 days',
best_for: 'High-volume accounts'
},
'last_click': {
name: 'Last Click',
description: '100% to final click',
requirements: 'None',
best_for: 'Simple reporting'
},
'first_click': {
name: 'First Click',
description: '100% to first click',
requirements: 'None',
best_for: 'Acquisition focus'
},
'linear': {
name: 'Linear',
description: 'Equal credit to all',
requirements: 'None',
best_for: 'Journey view'
},
'position_based': {
name: 'Position Based',
description: '40/20/40 distribution',
requirements: 'None',
best_for: 'Balanced analysis'
},
'time_decay': {
name: 'Time Decay',
description: 'More credit to recent',
requirements: 'None',
best_for: 'Short cycles'
}
};
// Configure in code (for reporting API)
const reportConfig = {
dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }],
dimensions: [{ name: 'sessionDefaultChannelGroup' }],
metrics: [
{ name: 'conversions' },
{ name: 'totalRevenue' }
],
// Attribution model applied via Admin settings
};Best Practices
Model Selection Checklist
## Before Choosing a Model
□ Analyze your typical customer journey length
□ Count average touchpoints before conversion
□ Measure your sales cycle duration
□ Understand your conversion volume
□ Identify key funnel stages (B2B)
## Implementation
□ Start with Position-Based as baseline
□ Run multiple models in parallel
□ Compare results monthly
□ Validate with incrementality tests
□ Document methodology for stakeholders
## Common Patterns
Short cycle + High volume → Time Decay or Data-Driven
Long cycle + B2B → W-Shaped or Position-Based
Mixed business → Position-Based + Data-Driven comparison
Limited data → Position-Based or LinearNext Steps: Learn how to implement attribution tracking in Attribution Implementation, or explore Cross-Channel Attribution for unified measurement.