Custom Attribution Models
Build custom attribution models using AML (Attribution Modeling Language).
Overview
AML is a Ruby-based domain-specific language for defining how credit is distributed across touchpoints in a customer journey.
Key Features:
- Declarative syntax with Ruby array operations
- Rails date helpers (30.days, 7.days.ago)
- Sandboxed execution (secure, no arbitrary code)
- Automatic validation (credits must sum to 1.0)
- Visual builder generates valid AML code
Security: AML runs in a sandboxed environment with strict constraints. Only whitelisted operations are allowed.
Quick Example
First Touch Attribution
within_window 30.days
apply 1.0 to touchpoints[0]
end
┌─────────────────────────────┐
│ Lookback: [30 days] │
├─────────────────────────────┤
│ First Touch → 100% │
└─────────────────────────────┘
Core Syntax
Every attribution model has this structure:
within_window <duration>
apply <credit> to <target>
[apply <credit> to <target>]
...
end
Required:
- within_window - Lookback period for attribution (must be first)
- apply - Credit assignment rule (one or more)
- Credits must sum to 1.0 (or use normalize!)
Standard Models
First Touch
within_window 30.days
apply 1.0 to touchpoints[0]
end
Gives 100% credit to the first touchpoint in the journey.
Last Touch
within_window 30.days
apply 1.0 to touchpoints[-1]
end
Gives 100% credit to the last touchpoint before conversion.
Linear (Equal Distribution)
within_window 30.days
apply 1.0 / touchpoints.length to touchpoints
end
Distributes credit equally across all touchpoints.
U-Shaped (40/40/20)
within_window 30.days
apply 0.4 to touchpoints[0]
apply 0.4 to touchpoints[-1]
apply 0.2 to touchpoints[1..-2], distribute: :equal
end
Credit distribution:
- First touch: 40%
- Last touch: 40%
- Middle touches: 20% (split equally)
Edge cases:
- 1 touchpoint: 100% to first
- 2 touchpoints: 50% to each
- 3+ touchpoints: 40/20/40 split
Time Decay (7-day half-life)
within_window 30.days
time_decay half_life: 7.days
end
Exponential decay with 7-day half-life:
- Touchpoint today: 100% weight
- Touchpoint 7 days ago: 50% weight
- Touchpoint 14 days ago: 25% weight
- Touchpoint 21 days ago: 12.5% weight
Helper method automatically normalizes credits to sum to 1.0.
Custom Models
Paid Channels Priority
within_window 30.days
paid = touchpoints.select { |tp| tp.channel.starts_with?("paid_") }
organic = touchpoints - paid
apply 0.7 to paid, distribute: :equal
apply 0.3 to organic, distribute: :equal
end
Gives 70% credit to paid channels, 30% to organic.
Recent Week Focus
within_window 90.days
recent = touchpoints.select { |tp| tp.occurred_at > 7.days.ago }
older = touchpoints - recent
apply 0.6 to recent, distribute: :equal
apply 0.4 to older, distribute: :equal
end
Prioritizes touchpoints from the last 7 days.
High-Value Last Touch
within_window 30.days
if conversion_value >= 1000
apply 0.7 to touchpoints[-1]
apply 0.3 to touchpoints[0]
else
apply 1.0 / touchpoints.length to touchpoints
end
end
Conditional logic based on conversion value:
- High-value (≥$1,000): Last touch gets 70%
- Regular conversions: Equal distribution
AML Language Reference
Window Declaration
Required first line. Defines the lookback period for attribution.
within_window 30.days # 30-day lookback
within_window 7.days # 7-day lookback
within_window 90.days # 90-day lookback
Allowed durations: 1.day, 7.days, 30.days, 60.days, 90.days, 180.days, 365.days
Array Selectors
Use Ruby array syntax to target specific touchpoints:
| Selector | Description | Example |
|---|---|---|
touchpoints[0] |
First touchpoint | First touch |
touchpoints[-1] |
Last touchpoint | Last touch |
touchpoints[1] |
Second touchpoint | Second touch |
touchpoints[1..-2] |
Middle touchpoints (excludes first/last) | U-shaped middle |
touchpoints[-3..-1] |
Last three touchpoints | Recency focus |
touchpoints |
All touchpoints | Linear attribution |
Filtering Methods
Filter touchpoints using Ruby Enumerable methods:
# By channel
touchpoints.select { |tp| tp.channel == "paid_search" }
touchpoints.select { |tp| tp.channel.starts_with?("paid_") }
touchpoints.reject { |tp| tp.channel == "direct" }
# By time
touchpoints.select { |tp| tp.occurred_at > 7.days.ago }
touchpoints.select { |tp| tp.occurred_at.between?(30.days.ago, 7.days.ago) }
# By event type
touchpoints.find { |tp| tp.event_type == "demo_requested" }
touchpoints.select { |tp| tp.event_type == "form_submission" }
# Set operations
touchpoints - excluded_touchpoints
Security Note: Only whitelisted methods are allowed (see Security section).
Credit Application
Fixed Credit
apply 1.0 to touchpoints[0] # 100% to first
apply 0.4 to touchpoints[0] # 40% to first
apply 0.2 to touchpoints[1..-2] # 20% to middle (must use distribute)
Calculated Credit
apply 1.0 / touchpoints.length to touchpoints # Equal distribution
Distribution Strategy
apply 0.6 to touchpoints[1..-2], distribute: :equal
# Splits 60% equally among middle touchpoints
Block-Based Credit (Advanced)
apply to touchpoints do |tp|
days_ago = (conversion_time - tp.occurred_at) / 1.day
2 ** (-days_ago / 7.0) # Exponential decay
end
normalize! # Required when using blocks
Available Context
Inside AML models, you have access to:
| Variable | Type | Description |
|---|---|---|
touchpoints |
Array | All touchpoints in journey |
touchpoints.length |
Integer | Number of touchpoints |
conversion_time |
Time | When conversion occurred |
conversion_value |
Decimal | Revenue amount |
Per touchpoint (inside blocks):
| Attribute | Type | Description |
|---|---|---|
tp.occurred_at |
Time | When touchpoint occurred |
tp.channel |
String | Channel name (paid_search, email, etc.) |
tp.event_type |
String | Event type (if touchpoint is an event) |
tp.properties |
Hash | Custom properties |
Time Helpers (Rails)
Leverage Rails' ActiveSupport duration helpers:
# Durations
1.day
7.days
30.days
90.days
1.week
1.month
1.year
# Relative time
7.days.ago
1.week.ago
30.days.ago
# Comparisons
tp.occurred_at > 7.days.ago
tp.occurred_at <= 30.days.ago
tp.occurred_at.between?(7.days.ago, 3.days.ago)
# Time math
(conversion_time - tp.occurred_at) / 1.day
(conversion_time - tp.occurred_at) / 1.hour
Normalization
When credits might not sum to exactly 1.0 (e.g., using blocks or weighted calculations):
normalize! # Forces credits to sum to 1.0
When to use:
- Block-based credit calculations
- Weighted distributions
- Complex conditional logic
- Any time credits might exceed or fall short of 1.0
Security & Sandboxing
AML runs in a sandboxed environment to prevent malicious code execution.
Allowed Operations
✅ Safe array operations:
- touchpoints[index], touchpoints[range]
- touchpoints.length, touchpoints.size, touchpoints.count
- touchpoints.select, touchpoints.reject, touchpoints.find
- touchpoints.map, touchpoints.each
- Array arithmetic: touchpoints - excluded
✅ Safe string methods:
- channel.starts_with?(prefix)
- channel.ends_with?(suffix)
- channel == value
- channel.match?(regex) (limited patterns)
✅ Safe time operations:
- occurred_at > time, occurred_at < time
- occurred_at.between?(start, stop)
- occurred_at.hour, occurred_at.wday
- Rails duration helpers: 30.days, 7.days.ago
- Time arithmetic: (time1 - time2) / 1.day
✅ Safe numeric operations:
- Basic math: +, -, *, /, **
- Comparisons: >, <, >=, <=, ==
- Math.exp, Math.log
✅ Safe control flow:
- if/elsif/else/end
- Blocks with whitelisted methods only
Blocked Operations
❌ Dangerous operations are blocked:
- File system access (File, Dir, IO)
- Network access (Net::HTTP, open, URI.open)
- System commands (backticks, system, exec, spawn)
- Arbitrary code execution (eval, instance_eval, class_eval)
- Constant manipulation (const_set, const_get)
- Method manipulation (define_method, send, method)
- Process operations (fork, exit, abort)
- Dangerous globals ($0, $LOAD_PATH, ENV)
Execution Limits
Timeout: 5 seconds per model execution
Memory: Limited allocation per execution
Iterations: Max 10,000 loop iterations
If any limit is exceeded, execution is terminated and an error is returned.
AST Validation
Before execution, AML code is:
- Parsed into an Abstract Syntax Tree (AST)
- Validated for:
- Required
within_windowdeclaration - Only whitelisted method calls
- No dangerous operations
- Valid syntax
- Required
- Analyzed for:
- Credit sum validation (must equal 1.0 or use
normalize!) - Type safety
- Resource usage estimation
- Credit sum validation (must equal 1.0 or use
Invalid code is rejected before execution.
Example: Blocked Code
These examples will fail validation:
within_window 30.days
# ❌ BLOCKED: File system access
File.read("/etc/passwd")
apply 1.0 to touchpoints[0]
end
within_window 30.days
# ❌ BLOCKED: System commands
`rm -rf /`
apply 1.0 to touchpoints[0]
end
within_window 30.days
# ❌ BLOCKED: Arbitrary code execution
eval("malicious code")
apply 1.0 to touchpoints[0]
end
within_window 30.days
# ❌ BLOCKED: Network access
Net::HTTP.get("evil.com", "/data")
apply 1.0 to touchpoints[0]
end
Error response:
json
{
"error": "Validation failed",
"message": "Forbidden operation detected: File system access not allowed"
}
Validation Rules
Credits Must Sum to 1.0
All credit assignments must total exactly 1.0 (within 0.0001 tolerance).
within_window 30.days
apply 0.4 to touchpoints[0]
apply 0.4 to touchpoints[-1]
apply 0.2 to touchpoints[1..-2], distribute: :equal
end
# Sum: 0.4 + 0.4 + 0.2 = 1.0 ✅
within_window 30.days
apply 0.5 to touchpoints[0]
apply 0.4 to touchpoints[-1]
apply 0.2 to touchpoints[1..-2], distribute: :equal
end
# Sum: 0.5 + 0.4 + 0.2 = 1.1 ❌
Error:
```
Validation failed: Credits sum to 1.1 but must equal 1.0
Current assignments:
touchpoints[0]: 0.5
touchpoints[-1]: 0.4
touchpoints[1..-2]: 0.2
Suggestion: Reduce one assignment by 0.1
```
Solution: Use normalize! to automatically adjust credits to 1.0.
Window is Required
Every model must start with within_window.
within_window 30.days
apply 1.0 to touchpoints[0]
end
# Missing within_window!
apply 1.0 to touchpoints[0]
No Overlapping Targets
Cannot apply credit to the same touchpoint twice without explicit strategy.
within_window 30.days
apply 0.5 to touchpoints[0]
apply 0.5 to touchpoints[0] # ❌ Duplicate target
end
within_window 30.days
apply 0.5 to touchpoints[0]
apply 0.5 to touchpoints[-1] # Different target
end
Error Handling
Validation Errors
Returned when model definition is invalid:
{
"error": "Validation failed",
"message": "Credits sum to 1.2 but must equal 1.0",
"line": 4,
"suggestion": "Add normalize! or adjust credit amounts"
}
Execution Errors
Returned when model execution fails:
{
"error": "Execution timeout",
"message": "Model execution exceeded 5 second limit"
}
Common Errors
| Error | Cause | Solution |
|---|---|---|
| Credits don't sum to 1.0 | Math error | Add normalize! or fix amounts |
| Missing within_window | Forgot window declaration | Add within_window at top |
| Forbidden operation | Tried to use blocked method | Use only whitelisted methods |
| Execution timeout | Model too complex | Simplify logic |
| Division by zero | Empty touchpoint array | Add edge case handling |
Edge Cases
Handle Empty/Small Journeys
Always consider edge cases:
within_window 30.days
case touchpoints.length
when 0
# No touchpoints - no attribution
when 1
apply 1.0 to touchpoints[0]
when 2
apply 0.5 to touchpoints[0]
apply 0.5 to touchpoints[-1]
else
apply 0.4 to touchpoints[0]
apply 0.4 to touchpoints[-1]
apply 0.2 to touchpoints[1..-2], distribute: :equal
end
end
Division by Zero Protection
within_window 30.days
paid = touchpoints.select { |tp| tp.channel.starts_with?("paid_") }
# ❌ Crashes if no paid touchpoints
apply 1.0 / paid.length to paid
end
within_window 30.days
paid = touchpoints.select { |tp| tp.channel.starts_with?("paid_") }
if paid.any?
apply 1.0 to paid, distribute: :equal
else
apply 1.0 to touchpoints, distribute: :equal
end
end
Best Practices
1. Always Handle Edge Cases
Test your model with:
- 0 touchpoints
- 1 touchpoint
- 2 touchpoints
- Many touchpoints
2. Use normalize! for Complex Logic
When using blocks or weighted calculations, always normalize:
within_window 30.days
apply to touchpoints do |tp|
# Complex calculation
end
normalize! # Ensures sum = 1.0
end
3. Test in Sandbox First
Use the Model Builder preview to test with sample journeys before saving.
4. Comment Your Logic
Add comments to explain custom logic:
within_window 90.days
# Prioritize high-value paid channels
paid = touchpoints.select { |tp| tp.channel.starts_with?("paid_") }
# 70% to paid, 30% to organic
apply 0.7 to paid, distribute: :equal
apply 0.3 to (touchpoints - paid), distribute: :equal
end
5. Start Simple, Iterate
Begin with simple models and add complexity as needed.
Iteration example:
- V1: Simple U-Shaped
- V2: Add paid channel boost
- V3: Add recency weighting
- V4: Add conversion value logic
Visual Model Builder
Create models visually without writing code:
- Navigate to Attribution Models → Create Model
- Drag rules to define credit distribution
- Preview with sample journey
- Save (generates AML automatically)
Behind the scenes: The visual builder generates valid AML code.
Power users: Switch to "Code" tab to edit AML directly.
Testing Your Model
Preview with Sample Journey
Test your model before saving:
Sample Journey:
┌─────────────────────────────────────┐
│ 1. Organic Search (30 days ago) │
│ 2. Paid Search (14 days ago) │
│ 3. Email (7 days ago) │
│ 4. Direct (today) → Conversion │
└─────────────────────────────────────┘
Your Model Attribution:
┌─────────────────────────────────────┐
│ Organic Search: 40% │
│ Paid Search: 10% │
│ Email: 10% │
│ Direct: 40% │
└─────────────────────────────────────┘
Compare Models
Compare your custom model against standard models:
| Model | Organic | Paid | Direct | |
|---|---|---|---|---|
| Your Model | 40% | 10% | 10% | 40% |
| First Touch | 100% | 0% | 0% | 0% |
| Last Touch | 0% | 0% | 0% | 100% |
| Linear | 25% | 25% | 25% | 25% |
FAQ
Can I use my own custom Ruby gems?
No. For security, only built-in Ruby/Rails methods are available in the sandbox.
Can I query the database?
No. AML has no database access. You can only operate on the provided touchpoints array.
What's the maximum lookback window?
365 days (1 year). Longer windows can be requested for Enterprise plans.
Can I use regular expressions?
Limited. Only simple patterns for string matching:
channel.starts_with?("paid_") # ✅ Allowed
channel.match?(/^paid_/) # ✅ Allowed (simple patterns)
channel.match?(/(?<!foo)bar/) # ❌ Blocked (complex patterns)
What happens if my model errors during execution?
- Execution stops immediately
- Error is logged
- Conversion is attributed using fallback model (Last Touch)
- You're notified via dashboard alert
Can I share models across accounts?
Not yet. This feature is planned for Q2 2026.
Advanced Examples
Channel Weight Matrix
within_window 30.days
weights = touchpoints.map do |tp|
case tp.channel
when "paid_search" then 2.0
when "paid_social" then 1.8
when "email" then 1.5
when "organic_search" then 1.2
when "referral" then 1.0
else 0.5
end
end
total_weight = weights.sum
apply to touchpoints.each_with_index do |tp, i|
weights[i] / total_weight
end
end
Position × Recency Weighting
within_window 60.days
apply to touchpoints.each_with_index do |tp, i|
# Position weight (first and last get 1.5x)
position_weight = (i == 0 || i == touchpoints.length - 1) ? 1.5 : 1.0
# Recency weight (last 7 days get 1.3x)
recency_weight = tp.occurred_at > 7.days.ago ? 1.3 : 1.0
# Combined
position_weight * recency_weight
end
normalize!
end
Multi-Tier Value-Based
within_window 90.days
multiplier = case conversion_value
when 0...100 then 1.0
when 100...500 then 1.2
when 500...1000 then 1.5
else 2.0
end
# High-value conversions favor last touch more
first_credit = 0.3 * multiplier
last_credit = 0.5 * multiplier
total = first_credit + last_credit
# Normalize first and last
first_credit = first_credit / total * 0.7
last_credit = last_credit / total * 0.7
apply first_credit to touchpoints[0]
apply last_credit to touchpoints[-1]
apply 0.3 to touchpoints[1..-2], distribute: :equal
end
Next Steps
Ready to create custom attribution models?
- Start simple - Try modifying a standard model
- Use visual builder - Drag-and-drop interface for quick models
- Test thoroughly - Preview with sample journeys
- Monitor performance - Check execution time in model dashboard
- Iterate - Refine based on business needs
Need inspiration? Check out our Model Library for community-contributed models.