`mix prop.compare` runs both scorers against a recent contact sample
and measures both against achieved contact distance. Each run appends
to priv/calibration/history.jsonl, so trends in alg-ML divergence and
algorithm-distance correlation surface over weeks of running.
When drift exceeds threshold (alg-ML RMSE > 8 score points, or current
correlation > 0.10 below the history median), the task writes a
recommendation pointing at the right remediation:
mix recalibrate_scorer # algorithm drift → refit weights
mix propagation_train # ML drift → retrain on the new algorithm
That is the feedback loop: measure → recommend → recalibrate/retrain →
loop. Operational rather than automatic, so a bad data window can't
silently corrupt the model.
Pure analysis lives in Microwaveprop.Propagation.Calibration with its
own unit tests; the Mix task only handles data loading, file I/O, and
console formatting.
141 lines
4.6 KiB
Elixir
141 lines
4.6 KiB
Elixir
defmodule Microwaveprop.Propagation.CalibrationTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Propagation.Calibration
|
|
|
|
describe "spearman/2" do
|
|
test "perfect monotonic correlation is 1.0" do
|
|
assert_in_delta Calibration.spearman([1, 2, 3, 4, 5], [10, 20, 30, 40, 50]), 1.0, 1.0e-9
|
|
end
|
|
|
|
test "perfect inverse correlation is -1.0" do
|
|
assert_in_delta Calibration.spearman([1, 2, 3, 4, 5], [50, 40, 30, 20, 10]), -1.0, 1.0e-9
|
|
end
|
|
|
|
test "uncorrelated input lands near zero" do
|
|
xs = Enum.to_list(1..100)
|
|
# constant ys → undefined ranking; we expect 0 by convention.
|
|
ys = List.duplicate(42, 100)
|
|
result = Calibration.spearman(xs, ys)
|
|
assert result == nil or abs(result) < 0.05
|
|
end
|
|
|
|
test "returns nil for fewer than 3 pairs" do
|
|
assert Calibration.spearman([1], [1]) == nil
|
|
assert Calibration.spearman([1, 2], [3, 4]) == nil
|
|
end
|
|
end
|
|
|
|
describe "rmse/2" do
|
|
test "zero for identical lists" do
|
|
assert Calibration.rmse([1, 2, 3], [1, 2, 3]) == 0.0
|
|
end
|
|
|
|
test "matches the textbook formula" do
|
|
# mean of [1, 4, 9] = 14/3 ; sqrt = 2.16
|
|
assert_in_delta Calibration.rmse([1, 2, 3], [2, 4, 6]), :math.sqrt(14 / 3), 1.0e-9
|
|
end
|
|
|
|
test "returns nil for empty input" do
|
|
assert Calibration.rmse([], []) == nil
|
|
end
|
|
end
|
|
|
|
describe "bucket_by_score/2" do
|
|
test "splits samples into 5 fixed score buckets and reports n + median distance" do
|
|
samples = [
|
|
%{algorithm_score: 5, distance_km: 50},
|
|
%{algorithm_score: 15, distance_km: 60},
|
|
%{algorithm_score: 25, distance_km: 100},
|
|
%{algorithm_score: 45, distance_km: 200},
|
|
%{algorithm_score: 65, distance_km: 300},
|
|
%{algorithm_score: 85, distance_km: 500}
|
|
]
|
|
|
|
buckets = Calibration.bucket_by_score(samples, :algorithm_score)
|
|
assert length(buckets) == 5
|
|
|
|
# First bucket is 0-20 with two contacts
|
|
assert hd(buckets) == %{
|
|
bucket: "0-20",
|
|
n: 2,
|
|
median_distance_km: 55.0
|
|
}
|
|
|
|
# Last bucket is 80-100 with one contact
|
|
last = List.last(buckets)
|
|
assert last.bucket == "80-100"
|
|
assert last.n == 1
|
|
assert last.median_distance_km == 500.0
|
|
end
|
|
|
|
test "returns 5 buckets even when some are empty" do
|
|
buckets = Calibration.bucket_by_score([%{algorithm_score: 50, distance_km: 100}], :algorithm_score)
|
|
assert length(buckets) == 5
|
|
empty = Enum.filter(buckets, &(&1.n == 0))
|
|
assert length(empty) == 4
|
|
end
|
|
end
|
|
|
|
describe "band_summary/1" do
|
|
test "computes n, mean scores, RMSE, and Spearman correlations" do
|
|
samples =
|
|
for i <- 1..10 do
|
|
%{algorithm_score: i * 10, ml_score: i * 10 + 2, distance_km: i * 50}
|
|
end
|
|
|
|
summary = Calibration.band_summary(samples)
|
|
assert summary.n == 10
|
|
assert summary.mean_algorithm_score == 55.0
|
|
assert summary.mean_ml_score == 57.0
|
|
assert_in_delta summary.alg_ml_rmse, 2.0, 1.0e-9
|
|
assert_in_delta summary.alg_distance_spearman, 1.0, 1.0e-9
|
|
assert_in_delta summary.ml_distance_spearman, 1.0, 1.0e-9
|
|
end
|
|
|
|
test "returns a zero-row summary for empty input" do
|
|
summary = Calibration.band_summary([])
|
|
assert summary.n == 0
|
|
assert summary.alg_ml_rmse == nil
|
|
end
|
|
end
|
|
|
|
describe "disagreements/2" do
|
|
test "returns top N samples by absolute alg-ml score difference, descending" do
|
|
samples = [
|
|
%{id: 1, algorithm_score: 50, ml_score: 51},
|
|
%{id: 2, algorithm_score: 50, ml_score: 80},
|
|
%{id: 3, algorithm_score: 50, ml_score: 30},
|
|
%{id: 4, algorithm_score: 50, ml_score: 49},
|
|
%{id: 5, algorithm_score: 50, ml_score: 0}
|
|
]
|
|
|
|
top = Calibration.disagreements(samples, 3)
|
|
assert Enum.map(top, & &1.id) == [5, 2, 3]
|
|
end
|
|
end
|
|
|
|
describe "detect_drift/3" do
|
|
test "flags when current alg_ml_rmse exceeds the drift threshold" do
|
|
assert {:drift, msg} = Calibration.detect_drift(:ml_drift, 12.0, threshold: 8.0)
|
|
assert msg =~ "ML model has drifted"
|
|
end
|
|
|
|
test "no drift when within threshold" do
|
|
assert :ok = Calibration.detect_drift(:ml_drift, 4.0, threshold: 8.0)
|
|
end
|
|
|
|
test "flags algorithm drift when current correlation falls below history median by margin" do
|
|
history_corrs = [0.42, 0.45, 0.43, 0.44]
|
|
|
|
assert {:drift, msg} =
|
|
Calibration.detect_drift(:algorithm_drift, 0.25, history: history_corrs, margin: 0.10)
|
|
|
|
assert msg =~ "Algorithm correlation dropped"
|
|
end
|
|
|
|
test "no algorithm drift if history empty" do
|
|
assert :ok = Calibration.detect_drift(:algorithm_drift, 0.10, history: [], margin: 0.10)
|
|
end
|
|
end
|
|
end
|