///|
/// A customer exposure to a campaign.
pub struct CampaignExposure {
campaign : String
customer_id : String
day : Int
cost : Int
}
///|
pub fn CampaignExposure::new(
campaign : String,
customer_id : String,
day : Int,
cost : Int,
) -> CampaignExposure {
{ campaign, customer_id, day, cost }
}
///|
/// An observed value event used to evaluate campaign outcomes.
pub struct CampaignOutcome {
customer_id : String
day : Int
value : Int
}
///|
pub fn CampaignOutcome::new(
customer_id : String,
day : Int,
value : Int,
) -> CampaignOutcome {
{ customer_id, day, value }
}
///|
pub struct CampaignResult {
campaign : String
exposed_customers : Int
converted_customers : Int
conversion_percent : Int
attributed_value : Int
cost : Int
roi_percent : Int
average_value : Int
}
///|
fn unique_ids(ids : Array[String]) -> Array[String] {
let result : Array[String] = []
for id in ids {
if !result.contains(id) {
result.push(id)
}
}
result
}
///|
fn campaign_value(
customer_id : String,
outcomes : Array[CampaignOutcome],
start : Int,
end : Int,
) -> Int {
outcomes.fold(init=0, (sum, outcome) => {
if outcome.customer_id == customer_id &&
outcome.day >= start &&
outcome.day < end {
sum + outcome.value
} else {
sum
}
})
}
///|
fn first_exposure_day(
exposures : Array[CampaignExposure],
customer_id : String,
) -> Int {
let mut first = 1_000_000_000
for exposure in exposures {
if exposure.customer_id == customer_id && exposure.day < first {
first = exposure.day
}
}
if first == 1_000_000_000 {
0
} else {
first
}
}
///|
/// Evaluate conversion, value and return on investment for one campaign.
pub fn evaluate_campaign(
campaign : String,
exposures : Array[CampaignExposure],
outcomes : Array[CampaignOutcome],
window : Int,
) -> CampaignResult {
let selected = exposures.filter(exposure => exposure.campaign == campaign)
let ids = unique_ids(selected.map(exposure => exposure.customer_id))
let mut converted = 0
let mut value = 0
for id in ids {
let first_day = first_exposure_day(selected, id)
let customer_value = campaign_value(
id,
outcomes,
first_day,
first_day + window,
)
if customer_value > 0 {
converted += 1
}
value += customer_value
}
let cost = selected.fold(init=0, (sum, exposure) => sum + exposure.cost)
{
campaign,
exposed_customers: ids.length(),
converted_customers: converted,
conversion_percent: if ids.is_empty() {
0
} else {
converted * 100 / ids.length()
},
attributed_value: value,
cost,
roi_percent: if cost == 0 {
0
} else {
(value - cost) * 100 / cost
},
average_value: if ids.is_empty() {
0
} else {
value / ids.length()
},
}
}
///|
/// Evaluate all named campaigns in stable input order.
pub fn evaluate_campaigns(
campaigns : Array[String],
exposures : Array[CampaignExposure],
outcomes : Array[CampaignOutcome],
window : Int,
) -> Array[CampaignResult] {
campaigns.map(name => evaluate_campaign(name, exposures, outcomes, window))
}
///|
/// Rank campaigns by return and then by conversion.
pub fn rank_campaigns(results : Array[CampaignResult]) -> Array[CampaignResult] {
let ranked = results.copy()
ranked.sort_by((left, right) => {
let roi_delta = right.roi_percent - left.roi_percent
if roi_delta == 0 {
right.conversion_percent - left.conversion_percent
} else {
roi_delta
}
})
ranked
}