///|
/// Stores labeled detector scores for threshold selection.
pub struct ScoreObservation {
score : Double
changed : Bool
weight : Double
}
///|
pub fn ScoreObservation::new(
score : Double,
changed : Bool,
weight? : Double = 1.0,
) -> ScoreObservation {
{ score, changed, weight: if weight <= 0.0 { 1.0 } else { weight } }
}
///|
pub struct ThresholdReport {
threshold : Double
true_positives : Int
false_positives : Int
true_negatives : Int
false_negatives : Int
precision : Double
recall : Double
f1 : Double
expected_cost : Double
}
///|
pub fn ThresholdReport::empty(threshold : Double) -> ThresholdReport {
{
threshold,
true_positives: 0,
false_positives: 0,
true_negatives: 0,
false_negatives: 0,
precision: 0.0,
recall: 0.0,
f1: 0.0,
expected_cost: 0.0,
}
}
///|
pub fn evaluate_threshold(
observations : Array[ScoreObservation],
threshold : Double,
false_positive_cost? : Double = 1.0,
false_negative_cost? : Double = 4.0,
) -> ThresholdReport {
let mut true_positives = 0
let mut false_positives = 0
let mut true_negatives = 0
let mut false_negatives = 0
for observation in observations {
let predicted = observation.score >= threshold
if predicted && observation.changed {
true_positives += 1
} else if predicted {
false_positives += 1
} else if observation.changed {
false_negatives += 1
} else {
true_negatives += 1
}
}
let precision = if true_positives + false_positives == 0 {
0.0
} else {
true_positives.to_double() / (true_positives + false_positives).to_double()
}
let recall = if true_positives + false_negatives == 0 {
0.0
} else {
true_positives.to_double() / (true_positives + false_negatives).to_double()
}
let f1 = if precision + recall == 0.0 {
0.0
} else {
2.0 * precision * recall / (precision + recall)
}
{
threshold,
true_positives,
false_positives,
true_negatives,
false_negatives,
precision,
recall,
f1,
expected_cost: false_positives.to_double() * false_positive_cost +
false_negatives.to_double() * false_negative_cost,
}
}
///|
pub fn best_threshold(
observations : Array[ScoreObservation],
candidates : Array[Double],
false_positive_cost? : Double = 1.0,
false_negative_cost? : Double = 4.0,
) -> ThresholdReport {
let mut best = ThresholdReport::empty(
if candidates.length() == 0 {
1.0
} else {
candidates[0]
},
)
for candidate in candidates {
let report = evaluate_threshold(
observations,
candidate,
false_positive_cost~,
false_negative_cost~,
)
if report.f1 > best.f1 ||
(report.f1 == best.f1 && report.expected_cost < best.expected_cost) {
best = report
}
}
best
}
///|
pub struct ScoreCalibrator {
observations : Array[ScoreObservation]
max_observations : Int
mut positive_weight : Double
mut negative_weight : Double
}
///|
pub fn ScoreCalibrator::new(max_observations? : Int = 4096) -> ScoreCalibrator {
{
observations: [],
max_observations: if max_observations < 1 {
1
} else {
max_observations
},
positive_weight: 0.0,
negative_weight: 0.0,
}
}
///|
pub fn ScoreCalibrator::push(
self : ScoreCalibrator,
score : Double,
changed : Bool,
weight? : Double = 1.0,
) -> Unit {
let safe_weight = if weight <= 0.0 { 1.0 } else { weight }
let item = {
score: if score < 0.0 {
0.0
} else {
score
},
changed,
weight: safe_weight,
}
self.observations.push(item)
if changed {
self.positive_weight += safe_weight
} else {
self.negative_weight += safe_weight
}
if self.observations.length() > self.max_observations {
ignore(self.observations.remove(0))
}
}
///|
pub fn ScoreCalibrator::count(self : ScoreCalibrator) -> Int {
self.observations.length()
}
///|
pub fn ScoreCalibrator::positive_weight(self : ScoreCalibrator) -> Double {
self.positive_weight
}
///|
pub fn ScoreCalibrator::negative_weight(self : ScoreCalibrator) -> Double {
self.negative_weight
}
///|
pub fn ScoreCalibrator::report(
self : ScoreCalibrator,
threshold : Double,
) -> ThresholdReport {
evaluate_threshold(self.observations, threshold)
}
///|
pub fn ScoreCalibrator::best(
self : ScoreCalibrator,
candidates : Array[Double],
) -> ThresholdReport {
best_threshold(self.observations, candidates)
}
///|
pub fn calibration_bins(
observations : Array[ScoreObservation],
bins? : Int = 10,
) -> Array[Double] {
let size = if bins < 1 { 1 } else { bins }
let positives = Array::make(size, 0.0)
let totals = Array::make(size, 0.0)
for observation in observations {
let index = if observation.score >= 1.0 {
size - 1
} else {
(observation.score * size.to_double()).to_int()
}
totals[index] += observation.weight
if observation.changed {
positives[index] += observation.weight
}
}
let result : Array[Double] = []
for i in 0..