///|
/// A named numeric fixture and its expected segment boundaries.
pub struct NumericScenario {
name : String
values : Array[Double]
boundaries : Array[Boundary]
}
///|
pub fn NumericScenario::mean_shift() -> NumericScenario {
{
name: "mean-shift",
values: piecewise_constant([30, 30], [0.0, 5.0], 0.2, 73).unwrap(),
boundaries: [Boundary::new(31).unwrap()],
}
}
///|
pub fn NumericScenario::no_change() -> NumericScenario {
{
name: "no-change",
values: piecewise_constant([60], [0.0], 0.2, 73).unwrap(),
boundaries: [],
}
}
///|
pub fn NumericScenario::two_shifts() -> NumericScenario {
{
name: "two-shifts",
values: piecewise_constant([20, 20, 20], [0.0, 4.0, -3.0], 0.15, 11).unwrap(),
boundaries: [Boundary::new(21).unwrap(), Boundary::new(41).unwrap()],
}
}
///|
/// A fully specified evaluation result for one detector configuration.
pub struct NumericExperiment {
scenario : String
hazard : Double
threshold : Double
score : Score
trace : TraceSummary
}
///|
pub fn NumericExperiment::f1(self : NumericExperiment) -> Double {
self.score.f1()
}
///|
pub fn NumericExperiment::true_positives(self : NumericExperiment) -> Int {
self.score.true_positives
}
///|
pub fn NumericExperiment::false_positives(self : NumericExperiment) -> Int {
self.score.false_positives
}
///|
/// Evaluate Gaussian BOCPD against a deterministic labeled scenario.
pub fn evaluate_numeric(
scenario : NumericScenario,
model : GaussianModel,
hazard : Double,
max_run_length : Int,
threshold : Double,
tolerance : Int,
) -> Result[NumericExperiment, DetectError] {
let config = match Config::new(hazard, max_run_length, model) {
Ok(value) => value
Err(error) => return Err(error)
}
let detector = Detector::new(config)
let updates = match detector.replay(scenario.values) {
Ok(value) => value
Err(error) => return Err(error)
}
let predictions = match predicted_boundaries(updates, threshold) {
Ok(value) => value
Err(error) => return Err(error)
}
let score = match
score_boundaries(scenario.boundaries, predictions, tolerance) {
Ok(value) => value
Err(error) => return Err(error)
}
let trace = match summarize(updates, threshold) {
Ok(value) => value
Err(error) => return Err(error)
}
Ok({ scenario: scenario.name, hazard, threshold, score, trace, })
}
///|
/// Pick the best threshold by F1; ties prefer the lower threshold.
pub fn sweep_thresholds(
scenario : NumericScenario,
model : GaussianModel,
hazard : Double,
max_run_length : Int,
thresholds : Array[Double],
tolerance : Int,
) -> Result[Array[NumericExperiment], DetectError] {
if thresholds.length() == 0 {
return Err(InvalidRunLengthLimit)
}
let result : Array[NumericExperiment] = []
for threshold in thresholds {
match
evaluate_numeric(
scenario, model, hazard, max_run_length, threshold, tolerance,
) {
Ok(experiment) => result.push(experiment)
Err(error) => return Err(error)
}
}
result.sort_by(fn(left, right) {
if left.f1() > right.f1() {
-1
} else if left.f1() < right.f1() {
1
} else if left.threshold < right.threshold {
-1
} else {
1
}
})
Ok(result)
}
///|
/// Count stream fixture for the Poisson-Gamma model.
pub struct CountScenario {
name : String
values : Array[Int]
boundaries : Array[Boundary]
}
///|
pub fn CountScenario::rate_shift() -> CountScenario {
{
name: "rate-shift",
values: [1, 0, 2, 1, 1, 0, 1, 24, 28, 31, 27, 30],
boundaries: [Boundary::new(8).unwrap()],
}
}
///|
pub struct CountExperiment {
scenario : String
threshold : Double
score : Score
maximum_probability : Double
}
///|
/// Evaluate a count-rate detector with the same matching rule as numeric BOCPD.
pub fn evaluate_count(
scenario : CountScenario,
model : PoissonModel,
hazard : Double,
max_run_length : Int,
threshold : Double,
tolerance : Int,
) -> Result[CountExperiment, DetectError] {
let config = match PoissonConfig::new(hazard, max_run_length, model) {
Ok(value) => value
Err(error) => return Err(error)
}
let detector = PoissonDetector::new(config)
let updates = match detector.replay(scenario.values) {
Ok(value) => value
Err(error) => return Err(error)
}
let predictions : Array[Boundary] = []
let mut maximum = 0.0
for update in updates {
if update.change_probability > maximum {
maximum = update.change_probability
}
if update.change_probability >= threshold {
predictions.push(Boundary::new(update.step).unwrap())
}
}
let score = match
score_boundaries(scenario.boundaries, predictions, tolerance) {
Ok(value) => value
Err(error) => return Err(error)
}
Ok({
scenario: scenario.name,
threshold,
score,
maximum_probability: maximum,
})
}