///|
/// A marketing or product touch associated with a customer.
pub struct Touchpoint {
customer_id : String
channel : String
day : Int
cost : Int
}
///|
pub fn Touchpoint::new(
customer_id : String,
channel : String,
day : Int,
cost : Int,
) -> Touchpoint {
{ customer_id, channel, day, cost }
}
///|
/// Attribution policies trade explainability for recency sensitivity.
pub enum AttributionModel {
FirstTouch
LastTouch
Linear
TimeWeighted
} derive(Debug, Eq)
///|
/// Expose the supported models in documentation and examples.
pub fn attribution_models() -> Array[AttributionModel] {
[FirstTouch, LastTouch, Linear, TimeWeighted]
}
///|
pub struct ChannelCredit {
channel : String
touches : Int
credited_value : Int
cost : Int
roi_percent : Int
}
///|
pub struct AttributionReport {
credits : Array[ChannelCredit]
total_value : Int
total_cost : Int
}
///|
fn event_value(events : Array[Event], customer_id : String) -> Int {
let mut value = 0
for event in events {
if event.customer_id == customer_id {
value += event.amount
}
}
value
}
///|
fn ordered_touches(
touches : Array[Touchpoint],
customer_id : String,
) -> Array[Touchpoint] {
let selected = touches.filter(touch => touch.customer_id == customer_id)
selected.sort_by((left, right) => left.day - right.day)
selected
}
///|
fn channel_index(channels : Array[String], channel : String) -> Int {
for index, item in channels {
if item == channel {
return index
}
}
-1
}
///|
fn ensure_channel(channels : Array[String], channel : String) -> Int {
let index = channel_index(channels, channel)
if index >= 0 {
index
} else {
channels.push(channel)
channels.length() - 1
}
}
///|
fn credit_for(
model : AttributionModel,
position : Int,
count : Int,
value : Int,
) -> Int {
if count <= 0 || value <= 0 {
0
} else {
match model {
FirstTouch => if position == 0 { value } else { 0 }
LastTouch => if position == count - 1 { value } else { 0 }
Linear => value / count
TimeWeighted => value * (position + 1) / (count * (count + 1) / 2)
}
}
}
///|
/// Attribute observed customer value to touchpoint channels.
pub fn analyze_attribution(
events : Array[Event],
touches : Array[Touchpoint],
model : AttributionModel,
) -> AttributionReport {
let channels : Array[String] = []
let credited : Array[Int] = []
let costs : Array[Int] = []
let touch_counts : Array[Int] = []
let customers : Array[String] = []
for event in events {
if !customers.contains(event.customer_id) {
customers.push(event.customer_id)
}
}
for customer in customers {
let ordered = ordered_touches(touches, customer)
let value = event_value(events, customer)
for position, touch in ordered {
let index = ensure_channel(channels, touch.channel)
while credited.length() <= index {
credited.push(0)
}
while costs.length() <= index {
costs.push(0)
}
while touch_counts.length() <= index {
touch_counts.push(0)
}
credited[index] = credited[index] +
credit_for(model, position, ordered.length(), value)
costs[index] = costs[index] + touch.cost
touch_counts[index] = touch_counts[index] + 1
}
}
let credits : Array[ChannelCredit] = []
for index, channel in channels {
let cost = costs[index]
let value = credited[index]
credits.push({
channel,
touches: touch_counts[index],
credited_value: value,
cost,
roi_percent: if cost == 0 {
0
} else {
(value - cost) * 100 / cost
},
})
}
{
credits,
total_value: events.fold(init=0, (sum, event) => sum + event.amount),
total_cost: touches.fold(init=0, (sum, touch) => sum + touch.cost),
}
}
///|
/// Return channel credits from highest attributed value to lowest.
pub fn rank_channel_credit(report : AttributionReport) -> Array[ChannelCredit] {
let result = report.credits.copy()
result.sort_by((left, right) => right.credited_value - left.credited_value)
result
}