defmodule Mix.Tasks.RecalibrateScorer do @shortdoc "Recalibrate propagation scorer weights via gradient descent" @moduledoc """ Fits new scoring weights using logistic regression on the historical QSO corpus. Compares QSO conditions (positive examples) against random-time baselines (negative examples) to learn which factors best discriminate actual propagation events. ## Usage mix recalibrate_scorer mix recalibrate_scorer --sample 5000 --epochs 2000 --lr 0.01 ## Options * `--sample` - max contacts to load (default: 5000) * `--epochs` - training iterations (default: 2000) * `--lr` - learning rate (default: 0.01) """ use Mix.Task alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.Recalibrator @impl Mix.Task def run(argv) do Mix.Task.run("app.start") _ = Oban.pause_all_queues(Oban) {opts, _, _} = OptionParser.parse(argv, switches: [sample: :integer, epochs: :integer, lr: :float] ) sample_size = Keyword.get(opts, :sample, 5000) epochs = Keyword.get(opts, :epochs, 2000) learning_rate = Keyword.get(opts, :lr, 0.01) Mix.shell().info("Recalibrating scorer weights...") Mix.shell().info(" sample_size: #{sample_size}") Mix.shell().info(" epochs: #{epochs}") Mix.shell().info(" learning_rate: #{learning_rate}") Mix.shell().info("") result = Recalibrator.fit( sample_size: sample_size, epochs: epochs, learning_rate: learning_rate ) current_weights = BandConfig.weights() Mix.shell().info("=== Results ===") Mix.shell().info("") Mix.shell().info("Train loss: #{format_float(result.train_loss)}") Mix.shell().info("Val loss: #{format_float(result.val_loss)}") Mix.shell().info("Initial loss: #{format_float(result.initial_loss)}") Mix.shell().info("") Mix.shell().info("=== Weight Comparison ===") Mix.shell().info("") header = String.pad_trailing("Factor", 18) <> String.pad_trailing("Current", 10) <> String.pad_trailing("New", 10) <> "Delta" Mix.shell().info(header) Mix.shell().info(String.duplicate("-", 48)) for key <- result.weights |> Map.keys() |> Enum.sort() do current = Map.get(current_weights, key, 0.0) new_val = result.weights[key] delta = new_val - current sign = if delta >= 0, do: "+", else: "" line = String.pad_trailing(to_string(key), 18) <> String.pad_trailing(format_float(current), 10) <> String.pad_trailing(format_float(new_val), 10) <> "#{sign}#{format_float(delta)}" Mix.shell().info(line) end Mix.shell().info("") Mix.shell().info("To apply these weights, update @weights in lib/microwaveprop/propagation/band_config.ex") end defp format_float(f) when is_float(f), do: :erlang.float_to_binary(f, decimals: 4) defp format_float(f), do: to_string(f) end