Task 9.3 - Weight recalibration via gradient descent:
- Recalibrator module fits logistic regression weights using Nx
- Trains on QSO positives vs random baseline negatives
- Cross-validates by month, normalizes weights to sum to 1.0
- Mix task: mix recalibrate_scorer --sample 5000 --epochs 2000
Task 9.4 - Side-by-side scorer comparison:
- ScorerDiff.compare/3 re-scores grid with old vs new weights
- Reports mean diff, regressions, improvements, per-band breakdown
- Mix task: mix scorer_diff --new-weights '{...}'
Phase 3 - NEXRAD ingestion pipeline:
- NexradClient fetches IEM n0q composite PNGs, extracts per-point
box statistics (mean/max dBZ, texture variance)
- NexradObservation schema with unique (lat, lon, observed_at)
- NexradWorker on :nexrad queue for background processing
- nexrad_texture backtest feature in Features module
- mix nexrad_backfill --limit 200
All tasks added to AdminTaskWorker and Release for production use.
1116 tests, 0 failures.
206 lines
5.7 KiB
Elixir
206 lines
5.7 KiB
Elixir
defmodule Microwaveprop.Propagation.ScorerDiff do
|
|
@moduledoc """
|
|
Compare two weight sets on the same propagation scoring data.
|
|
|
|
Loads the most recent HRRR frame's worth of grid points from
|
|
`propagation_scores` (which stores the per-factor scores in JSONB),
|
|
re-scores each with both weight sets, and returns diff statistics.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Microwaveprop.Propagation.GridScore
|
|
alias Microwaveprop.Repo
|
|
|
|
@regression_threshold 5
|
|
@worst_count 10
|
|
|
|
@doc """
|
|
Compare old and new weights on recent propagation scores.
|
|
|
|
Options:
|
|
* `:limit` — max number of scores to process (default: all for the latest valid_time)
|
|
|
|
Returns a map with:
|
|
* `:summary` — aggregate stats (mean_diff, max_diff, regressions, improvements, total)
|
|
* `:band_diffs` — per-band breakdown keyed by band_mhz
|
|
* `:worst_regressions` — up to 10 rows with largest score decrease
|
|
"""
|
|
@spec compare(map(), map(), keyword()) :: %{
|
|
summary: map(),
|
|
band_diffs: map(),
|
|
worst_regressions: [map()]
|
|
}
|
|
def compare(old_weights, new_weights, opts \\ []) do
|
|
scores = load_scores(opts)
|
|
diffs = compute_diffs(scores, old_weights, new_weights)
|
|
|
|
%{
|
|
summary: summarize(diffs),
|
|
band_diffs: band_breakdown(diffs),
|
|
worst_regressions: worst_regressions(diffs)
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Compute a weighted sum from a factors map and a weights map.
|
|
|
|
Both maps use string keys. Returns a rounded integer.
|
|
"""
|
|
@spec weighted_sum(map(), map()) :: integer()
|
|
def weighted_sum(factors, weights) do
|
|
weights
|
|
|> Enum.reduce(0.0, fn {factor, weight}, acc ->
|
|
score = Map.get(factors, factor, 0)
|
|
acc + score * weight
|
|
end)
|
|
|> round()
|
|
end
|
|
|
|
# ── Private ────────────────────────────────────────────────────────
|
|
|
|
defp load_scores(opts) do
|
|
limit = Keyword.get(opts, :limit)
|
|
|
|
latest_time =
|
|
GridScore
|
|
|> select([g], max(g.valid_time))
|
|
|> Repo.one()
|
|
|
|
if is_nil(latest_time) do
|
|
[]
|
|
else
|
|
query =
|
|
GridScore
|
|
|> where([g], g.valid_time == ^latest_time)
|
|
|> order_by([g], [g.band_mhz, g.lat, g.lon])
|
|
|
|
query = if limit, do: limit(query, ^limit), else: query
|
|
|
|
Repo.all(query)
|
|
end
|
|
end
|
|
|
|
defp compute_diffs(scores, old_weights, new_weights) do
|
|
Enum.map(scores, fn score ->
|
|
factors = score.factors
|
|
old_score = weighted_sum(factors, old_weights)
|
|
new_score = weighted_sum(factors, new_weights)
|
|
diff = new_score - old_score
|
|
|
|
%{
|
|
lat: score.lat,
|
|
lon: score.lon,
|
|
band_mhz: score.band_mhz,
|
|
old_score: old_score,
|
|
new_score: new_score,
|
|
diff: diff
|
|
}
|
|
end)
|
|
end
|
|
|
|
defp summarize([]), do: %{mean_diff: 0.0, max_diff: 0, regressions: 0, improvements: 0, total: 0}
|
|
|
|
defp summarize(diffs) do
|
|
total = length(diffs)
|
|
abs_diffs = Enum.map(diffs, fn d -> abs(d.diff) end)
|
|
mean_diff = Float.round(Enum.sum(abs_diffs) / total, 2)
|
|
max_diff = Enum.max(abs_diffs)
|
|
regressions = Enum.count(diffs, fn d -> d.diff < -@regression_threshold end)
|
|
improvements = Enum.count(diffs, fn d -> d.diff > @regression_threshold end)
|
|
|
|
%{
|
|
mean_diff: mean_diff,
|
|
max_diff: max_diff,
|
|
regressions: regressions,
|
|
improvements: improvements,
|
|
total: total
|
|
}
|
|
end
|
|
|
|
defp band_breakdown(diffs) do
|
|
diffs
|
|
|> Enum.group_by(& &1.band_mhz)
|
|
|> Map.new(fn {band_mhz, band_diffs} ->
|
|
abs_diffs = Enum.map(band_diffs, fn d -> abs(d.diff) end)
|
|
mean = Float.round(Enum.sum(abs_diffs) / length(abs_diffs), 2)
|
|
|
|
regressions = Enum.filter(band_diffs, fn d -> d.diff < 0 end)
|
|
|
|
max_regression =
|
|
case regressions do
|
|
[] -> 0
|
|
list -> list |> Enum.map(fn d -> abs(d.diff) end) |> Enum.max()
|
|
end
|
|
|
|
{band_mhz, %{mean_diff: mean, max_regression: max_regression}}
|
|
end)
|
|
end
|
|
|
|
defp worst_regressions(diffs) do
|
|
diffs
|
|
|> Enum.filter(fn d -> d.diff < 0 end)
|
|
|> Enum.sort_by(fn d -> d.diff end)
|
|
|> Enum.take(@worst_count)
|
|
|> Enum.map(fn d ->
|
|
%{
|
|
lat: d.lat,
|
|
lon: d.lon,
|
|
band_mhz: d.band_mhz,
|
|
old_score: d.old_score,
|
|
new_score: d.new_score
|
|
}
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Format comparison results as a markdown report string.
|
|
"""
|
|
@spec to_markdown(%{summary: map(), band_diffs: map(), worst_regressions: [map()]}) :: String.t()
|
|
def to_markdown(result) do
|
|
s = result.summary
|
|
|
|
summary = """
|
|
## Scorer Weight Comparison
|
|
|
|
| Metric | Value |
|
|
|--------|-------|
|
|
| Total grid points | #{s.total} |
|
|
| Mean absolute diff | #{s.mean_diff} |
|
|
| Max absolute diff | #{s.max_diff} |
|
|
| Regressions (>#{@regression_threshold} pts) | #{s.regressions} |
|
|
| Improvements (>#{@regression_threshold} pts) | #{s.improvements} |
|
|
"""
|
|
|
|
band_header = """
|
|
|
|
## Per-Band Breakdown
|
|
|
|
| Band (MHz) | Mean Diff | Max Regression |
|
|
|-----------|-----------|----------------|
|
|
"""
|
|
|
|
band_rows =
|
|
result.band_diffs
|
|
|> Enum.sort_by(fn {band, _} -> band end)
|
|
|> Enum.map_join("\n", fn {band, stats} ->
|
|
"| #{band} | #{stats.mean_diff} | #{stats.max_regression} |"
|
|
end)
|
|
|
|
worst_header = """
|
|
|
|
## Worst Regressions
|
|
|
|
| Lat | Lon | Band | Old | New | Diff |
|
|
|-----|-----|------|-----|-----|------|
|
|
"""
|
|
|
|
worst_rows =
|
|
Enum.map_join(result.worst_regressions, "\n", fn r ->
|
|
diff = r.new_score - r.old_score
|
|
"| #{r.lat} | #{r.lon} | #{r.band_mhz} | #{r.old_score} | #{r.new_score} | #{diff} |"
|
|
end)
|
|
|
|
summary <> band_header <> band_rows <> worst_header <> worst_rows
|
|
end
|
|
end
|