///|
/// Explainable contribution of one feature to a robust numeric score.
pub struct FeatureAttribution {
feature : String
contribution : Double
absolute_contribution : Double
direction : String
rank : Int
}
///|
pub struct AttributionReport {
baseline : Double
prediction : Double
total_contribution : Double
residual : Double
features : Array[FeatureAttribution]
}
///|
pub fn feature_attribution(
feature : String,
contribution : Double,
rank : Int,
) -> FeatureAttribution {
{
feature,
contribution,
absolute_contribution: abs_double(contribution),
direction: if contribution < 0.0 {
"negative"
} else if contribution > 0.0 {
"positive"
} else {
"neutral"
},
rank,
}
}
///|
pub fn attribution_report(
baseline : Double,
prediction : Double,
features : Array[FeatureAttribution],
) -> AttributionReport {
let mut total = 0.0
for feature in features {
total += feature.contribution
}
{
baseline,
prediction,
total_contribution: total,
residual: prediction - baseline - total,
features,
}
}
///|
pub fn attribution_from_values(
names : Array[String],
values : Array[Double],
baseline : Double,
prediction : Double,
) -> AttributionReport {
let features = []
let count = if names.length() < values.length() {
names.length()
} else {
values.length()
}
for index = 0; index < count; index = index + 1 {
features.push(feature_attribution(names[index], values[index], index))
}
attribution_report(baseline, prediction, features)
}
///|
pub fn attribution_abs_rank(
report : AttributionReport,
) -> Array[FeatureAttribution] {
let result = report.features.copy()
result.sort_by((left, right) => {
if left.absolute_contribution > right.absolute_contribution {
-1
} else if left.absolute_contribution < right.absolute_contribution {
1
} else {
0
}
})
for index = 0; index < result.length(); index = index + 1 {
result[index] = { ..result[index], rank: index }
}
result
}
///|
pub fn attribution_top(
report : AttributionReport,
count : Int,
) -> Array[FeatureAttribution] {
let ranked = attribution_abs_rank(report)
let result = []
let width = if count < 0 {
0
} else if count > ranked.length() {
ranked.length()
} else {
count
}
for index = 0; index < width; index = index + 1 {
result.push(ranked[index])
}
result
}
///|
pub fn attribution_contributions(report : AttributionReport) -> Array[Double] {
let result = []
for feature in report.features {
result.push(feature.contribution)
}
result
}
///|
pub fn attribution_names(report : AttributionReport) -> Array[String] {
let result = []
for feature in report.features {
result.push(feature.feature)
}
result
}
///|
pub fn attribution_direction_counts(report : AttributionReport) -> Array[Int] {
let mut positive = 0
let mut negative = 0
let mut neutral = 0
for feature in report.features {
if feature.direction == "positive" {
positive += 1
} else if feature.direction == "negative" {
negative += 1
} else {
neutral += 1
}
}
[positive, negative, neutral]
}
///|
pub fn attribution_share(report : AttributionReport) -> Array[Double] {
let total = sum_absolute(attribution_contributions(report))
let result = []
for feature in report.features {
result.push(
if total == 0.0 {
0.0
} else {
feature.absolute_contribution / total
},
)
}
result
}
///|
pub fn attribution_reconstruct(report : AttributionReport) -> Double {
report.baseline + report.total_contribution + report.residual
}
///|
pub fn attribution_add(
report : AttributionReport,
feature : FeatureAttribution,
) -> AttributionReport {
let features = report.features.copy()
features.push(feature)
attribution_report(report.baseline, report.prediction, features)
}
///|
pub fn attribution_merge(
left : AttributionReport,
right : AttributionReport,
) -> AttributionReport {
let features = left.features.copy()
for feature in right.features {
features.push(feature)
}
attribution_report(left.baseline, right.prediction, features)
}
///|
pub fn attribution_from_linear(
feature_names : Array[String],
coefficients : Array[Double],
feature_values : Array[Double],
intercept : Double,
) -> AttributionReport {
let contributions = []
let mut prediction = intercept
let count = if coefficients.length() < feature_values.length() {
coefficients.length()
} else {
feature_values.length()
}
for index = 0; index < count; index = index + 1 {
let value = coefficients[index] * feature_values[index]
contributions.push(value)
prediction += value
}
attribution_from_values(feature_names, contributions, intercept, prediction)
}
///|
pub fn attribution_residual_quality(report : AttributionReport) -> Double {
1.0 / (1.0 + abs_double(report.residual))
}
///|
pub fn attribution_consistency(report : AttributionReport) -> Double {
let reconstructed = attribution_reconstruct(report)
1.0 / (1.0 + abs_double(reconstructed - report.prediction))
}
///|
pub fn attribution_report_vector(report : AttributionReport) -> Array[Double] {
[
report.baseline,
report.prediction,
report.total_contribution,
report.residual,
report.features.length().to_double(),
attribution_residual_quality(report),
attribution_consistency(report),
]
}
///|
pub fn attribution_lines(report : AttributionReport) -> Array[String] {
let lines = [
"baseline=" + report.baseline.to_string(),
"prediction=" + report.prediction.to_string(),
"total_contribution=" + report.total_contribution.to_string(),
"residual=" + report.residual.to_string(),
]
for feature in attribution_abs_rank(report) {
lines.push(
feature.feature +
"|" +
feature.contribution.to_string() +
"|" +
feature.direction +
"|rank=" +
feature.rank.to_string(),
)
}
lines
}
///|
pub fn attribution_string(report : AttributionReport) -> String {
attribution_lines(report).join("\n")
}
///|
pub fn attribution_stable(
left : AttributionReport,
right : AttributionReport,
tolerance : Double,
) -> Bool {
let left_ranked = attribution_abs_rank(left)
let right_ranked = attribution_abs_rank(right)
if left_ranked.length() != right_ranked.length() {
return false
}
for index = 0; index < left_ranked.length(); index = index + 1 {
if left_ranked[index].feature != right_ranked[index].feature ||
abs_double(
left_ranked[index].contribution - right_ranked[index].contribution,
) >
tolerance {
return false
}
}
true
}
///|
pub fn attribution_sensitivity(
base : Array[Double],
changed : Array[Double],
names : Array[String],
) -> AttributionReport {
let count = if base.length() < changed.length() {
base.length()
} else {
changed.length()
}
let values = []
for index = 0; index < count; index = index + 1 {
values.push(changed[index] - base[index])
}
let mut base_total = 0.0
let mut changed_total = 0.0
for value in base {
base_total += value
}
for value in changed {
changed_total += value
}
attribution_from_values(names, values, base_total, changed_total)
}
///|
pub fn attribution_batch(reports : Array[AttributionReport]) -> Array[Double] {
let result = []
for report in reports {
result.push(attribution_residual_quality(report))
}
result
}
///|
pub fn attribution_summary(report : AttributionReport) -> Array[Double] {
let directions = attribution_direction_counts(report)
[
report.prediction,
report.residual,
report.features.length().to_double(),
directions[0].to_double(),
directions[1].to_double(),
directions[2].to_double(),
attribution_consistency(report),
]
}