///|
/// Model explanation and feature attribution utilities for review workflows.
pub struct FeatureAttribution {
feature : String
contribution : Double
absolute_contribution : Double
rank : Int
direction : Int
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn FeatureAttribution::new(
feature : String,
contribution : Double,
) -> FeatureAttribution {
{
feature,
contribution,
absolute_contribution: contribution.abs(),
rank: 0,
direction: if contribution >= 0.0 {
1
} else {
-1
},
}
}
///|
pub struct Explanation {
baseline : Double
prediction : Double
bias : Double
attributions : Array[FeatureAttribution]
residual : Double
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn Explanation::new(
baseline : Double,
prediction : Double,
attributions : Array[FeatureAttribution],
) -> Explanation {
let contribution = attributions.fold(init=0.0, (total, item) => {
total + item.contribution
})
{
baseline,
prediction,
bias: prediction - baseline - contribution,
attributions: rank_attributions(attributions),
residual: prediction - baseline - contribution,
}
}
///|
pub fn rank_attributions(
attributions : Array[FeatureAttribution],
) -> Array[FeatureAttribution] {
let ordered = attributions.copy()
for left in 0..
ordered[left].absolute_contribution {
let temp = ordered[left]
ordered[left] = ordered[right]
ordered[right] = temp
}
}
}
for index in 0.. Array[FeatureAttribution] {
let result : Array[FeatureAttribution] = []
let limit = count.max(0).min(self.attributions.length())
for index in 0.. Double {
let total = self.attributions.fold(init=0.0, (sum_value, item) => {
sum_value + item.absolute_contribution
})
if total == 0.0 {
0.0
} else {
self.attributions
.filter(fn(item) { item.contribution > 0.0 })
.fold(init=0.0, (sum_value, item) => sum_value + item.contribution) /
total
}
}
///|
pub fn permutation_importance(
actual : Array[Double],
baseline : Array[Double],
feature_values : Array[Array[Double]],
shuffled_values : Array[Array[Double]],
) -> Array[FeatureAttribution] {
if feature_values.length() != shuffled_values.length() {
abort("permutation_importance: feature counts differ")
}
let base_loss = mean_squared_error_explain(actual, baseline)
let result : Array[FeatureAttribution] = []
for index in 0.. Double {
if actual.length() != predicted.length() || actual.length() == 0 {
abort("mean_squared_error_explain: lengths must match")
}
let mut total = 0.0
for index in 0.. Array[PartialDependencePoint] {
if predictions.length() == 0 || feature_index < 0 || grid.length() == 0 {
abort("partial_dependence: invalid inputs")
}
let result : Array[PartialDependencePoint] = []
for value in grid.iter() {
let values : Array[Double] = []
for row in predictions.iter() {
if feature_index >= row.length() {
abort("partial_dependence: feature out of range")
}
values.push(row[feature_index] + value)
}
let center = mean(values)
let spread = variance(values).sqrt()
result.push({
feature_value: value,
average_prediction: center,
lower: center - 1.96 * spread,
upper: center + 1.96 * spread,
samples: values.length(),
})
}
result
}
///|
pub struct CounterfactualResult {
original : Double
counterfactual : Double
change : Double
relative_change : Double
feasible : Bool
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn counterfactual(
original : Double,
counterfactual_value : Double,
minimum : Double,
maximum : Double,
) -> CounterfactualResult {
let feasible = counterfactual_value >= minimum &&
counterfactual_value <= maximum
{
original,
counterfactual: counterfactual_value,
change: counterfactual_value - original,
relative_change: if original == 0.0 {
0.0
} else {
(counterfactual_value - original) / original.abs()
},
feasible,
}
}
///|
pub struct MonotonicityReport {
feature : String
violations : Int
pairs : Int
violation_rate : Double
monotonic : Bool
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn monotonicity_report(
feature : String,
values : Array[Double],
predictions : Array[Double],
increasing : Bool,
tolerance : Double,
) -> MonotonicityReport {
if values.length() != predictions.length() || values.length() < 2 {
abort("monotonicity_report: lengths must match")
}
let mut violations = 0
let mut pairs = 0
for left in 0..= predictions[right] - tolerance
}
if !expected {
violations = violations + 1
}
}
}
}
{
feature,
violations,
pairs,
violation_rate: if pairs == 0 {
0.0
} else {
violations.to_double() / pairs.to_double()
},
monotonic: violations == 0,
}
}
///|
pub struct FairnessGroup {
name : String
count : Int
positive_rate : Double
selection_rate : Double
true_positive_rate : Double
false_positive_rate : Double
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn fairness_group(
name : String,
predicted : Array[Bool],
actual : Array[Bool],
) -> FairnessGroup {
let matrix = confusion_matrix(predicted, actual)
let count = predicted.length()
let positive = predicted.filter(fn(value) { value }).length()
let true_rate = if matrix.true_positive + matrix.false_negative == 0 {
0.0
} else {
matrix.true_positive.to_double() /
(matrix.true_positive + matrix.false_negative).to_double()
}
let false_rate = if matrix.false_positive + matrix.true_negative == 0 {
0.0
} else {
matrix.false_positive.to_double() /
(matrix.false_positive + matrix.true_negative).to_double()
}
{
name,
count,
positive_rate: if count == 0 {
0.0
} else {
positive.to_double() / count.to_double()
},
selection_rate: if count == 0 {
0.0
} else {
positive.to_double() / count.to_double()
},
true_positive_rate: true_rate,
false_positive_rate: false_rate,
}
}
///|
pub struct FairnessGap {
metric : String
maximum_gap : Double
reference : String
worst_group : String
passes : Bool
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn fairness_gap(
groups : Array[FairnessGroup],
metric : String,
tolerance : Double,
) -> FairnessGap {
if groups.length() == 0 {
abort("fairness_gap: no groups")
}
let values = groups.map(fn(group) {
if metric == "tpr" {
group.true_positive_rate
} else if metric == "fpr" {
group.false_positive_rate
} else {
group.selection_rate
}
})
let mut minimum = values[0]
let mut maximum = values[0]
let mut worst = 0
for index in 1.. Double {
let common = left.attributions.fold(init=0.0, (total, item) => {
if right.attributions.any(fn(other) { other.feature == item.feature }) {
total + 1.0
} else {
total
}
})
common / left.attributions.length().max(1).to_double()
}
///|
pub fn local_sensitivity(values : Array[Double], epsilon : Double) -> Double {
if values.length() < 2 || epsilon <= 0.0 {
abort("local_sensitivity: invalid inputs")
}
let sorted = values.copy()
let mut spread = 0.0
for left in 0..