Files
Mimante/Mimante/Tests/MlModelBacktest.cs
T
Alby96andClaude Fable 5.1 46134c822d Apprendimento sempre acceso: impara da ogni asta chiusa e decide col valore atteso
Via le due modalita' Addestramento/Gara, per scelta dell'utente: la misura aveva
detto che il lavoro di registrazione costa mezzo punto di un core, e allora tanto
vale registrare sempre. Resta la coalescenza verso l'interfaccia, che era la
correzione vera.

COSA IMPARA. Per ogni puntata di ogni asta chiusa: e' rimasta senza risposta?
E' l'unica etichetta abbondante — 1,8 milioni di esempi sui dossier locali —
contro le diciannove aste vinte dall'utente, che non farebbero un modello. Il
comportamento avversario non dipende da chi ha puntato, e questo rende
utilizzabili le puntate altrui. Regressione logistica online scritta a mano,
senza librerie, in Ml/: un passo di gradiente per esempio, aggiornata a ogni
chiusura, salvata subito. Variabili a fasce: durata del ciclo, quota di
autopuntate recenti, puntatori distinti, prezzo sul valore, fascia oraria (le
fasce ricalcano la curva misurata sulle chiusure), feriale/festivo, ora di
aggancio, tipo di puntata, profondita' dell'asta, stesso puntatore di due fa,
prodotto in 32 cassetti con hash stabile. In parallelo un profilo per prodotto x
fascia oraria x tipo di giorno, con restringimento verso il prodotto e poi verso
il globale dove i dati scarseggiano.

COME DECIDE. In ShouldPlaceBid: probabilita' appresa per il margine residuo meno
il costo della puntata; sotto zero non si punta, e il registro riporta i numeri.
Il modello parla sempre ma decide solo dopo 300 aste apprese. Le previsioni sono
calibrate in linea con il rapporto osservato/previsto sugli ultimi 20.000 esempi.

QUANTO VALE, MISURATO. Valutazione prequenziale sui 3000 dossier piu' recenti
(ogni asta prima prevista poi appresa, come nell'uso): sollevamento 10,8x nella
cima dell'1%, 5,8x al 5%; calibrazione entro il 10-30% in ogni fascia. Sulle 116
puntate vere dell'utente di agosto: nessuna era finale, e il cancello ne avrebbe
fermate 45 senza perderne una vincente. La valutazione congelata (addestra sui
vecchi, giudica sui nuovi) sovrastima di 1,5-1,8x per deriva del mercato, non per
passo o regolarizzazione: provati entrambi. Griglia e prequenziale stanno nel
test MlModelBacktest, target Apprendimento, attivita' VS Code "valuta
apprendimento".

PRIMO ADDESTRAMENTO. In sottofondo, dal piu' recente, con un tempo massimo per
avvio. Il lettore salta le righe di poll prima del JSON: 6289 dossier per 8,8 GB
in 64 secondi, verificato dal vivo, archivio completo al primo avvio.

ACQUISIZIONE. I poll nel dossier si scrivono solo quando cambia qualcosa di
visibile, e comunque uno al secondo: -88,8% misurato, senza perdere la
profondita' di nessun ciclo. Il reset porta il minimo del cronometro e la durata
del ciclo chiuso. Via le righe di log che copiavano i reset parola per parola.
Corretto un difetto vecchio: nessun dossier marcava le puntate nostre, perche' il
parser lo faceva solo se riceveva il nome utente e nel percorso dei poll non gli
arrivava. Ora si marcano in MergeBidHistory e l'intestazione porta il nome
utente; per i dossier vecchi il lettore accetta il nome da fuori.

Trovato dal vivo e corretto: salvataggi concorrenti all'avvio quando piu' aste
chiudono insieme. Il modello salvato non rilegge piu' i parametri (passo,
regolarizzazione): sono scelte del codice, e un cambio vale dal riavvio dopo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 22:45:49 +02:00

246 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AutoBidder.Ml;
using Xunit;
using Xunit.Abstractions;
namespace AutoBidder.Tests;
/// <summary>
/// Valuta il modello sui dossier veri, con una divisione onesta nel tempo: si impara dai
/// più vecchi e si giudica sui più recenti, che il modello non ha mai visto. Sul futuro,
/// non su ciò che ha già letto.
///
/// <para>Si attiva solo con le variabili d'ambiente, come la rigiocata:</para>
/// <list type="bullet">
/// <item><c>AUTOBIDDER_ML_DIR</c> — cartella dei dossier;</item>
/// <item><c>AUTOBIDDER_ML_MAX</c> — quanti file al massimo (0 = tutti);</item>
/// <item><c>AUTOBIDDER_ML_OUT</c> — dove scrivere il rapporto.</item>
/// </list>
///
/// <para><b>Cosa misura.</b> Quanto il modello separa le puntate rimaste senza risposta
/// dalle altre (sollevamento nella cima della classifica), se la probabilità è nella scala
/// giusta (calibrazione per fasce), e — sulle nostre puntate vere — cosa avrebbe fatto il
/// cancello del valore atteso: quante ne avrebbe fermate, e quante di quelle fermate erano
/// invece vincenti.</para>
/// </summary>
public class MlModelBacktest
{
private readonly ITestOutputHelper _output;
public MlModelBacktest(ITestOutputHelper output) => _output = output;
private sealed record Row(double P, bool Unanswered, bool Mine, double Ev);
[Fact]
public void Valuta_il_modello_sui_dossier_reali()
{
var folder = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_DIR");
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
{
_output.WriteLine("AUTOBIDDER_ML_DIR non impostata o inesistente: valutazione saltata.");
return;
}
var max = int.TryParse(Environment.GetEnvironmentVariable("AUTOBIDDER_ML_MAX"), out var m) ? m : 0;
// Ordine per nome = ordine di data: i dossier cominciano con la data.
var files = Directory.GetFiles(folder, "*.jsonl").OrderBy(Path.GetFileName, StringComparer.Ordinal).ToList();
if (max > 0 && files.Count > max) files = files.Skip(files.Count - max).ToList();
// Il nostro nome utente: i dossier scritti prima di oggi non lo portano
// nell'intestazione, e senza non si sa quali puntate erano nostre.
var me = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_ME");
var sessions = new List<DossierTrainingReader.Session>();
foreach (var f in files)
{
var s = DossierTrainingReader.ReadFile(f, me);
if (s != null && s.IsUsable) sessions.Add(s);
}
var report = new StringBuilder();
report.AppendLine($"Dossier letti: {files.Count:N0}, utilizzabili: {sessions.Count:N0}");
if (sessions.Count < 20)
{
report.AppendLine("Troppo pochi dossier utilizzabili per una valutazione.");
_output.WriteLine(report.ToString());
return;
}
var split = (int)(sessions.Count * 0.7);
var train = sessions.Take(split).ToList();
var test = sessions.Skip(split).ToList();
// Le variabili si costruiscono una volta sola: leggere i file è la parte lenta,
// addestrare è veloce, e così si possono confrontare più configurazioni.
var trainX = train.SelectMany(s => DossierTrainingReader.Examples(s))
.Select(e => (X: BidFeatures.Build(e.Context), Y: e.Unanswered)).ToList();
var testX = test.SelectMany(s => DossierTrainingReader.Examples(s).Select(e => (s, e)))
.Select(t => (
X: BidFeatures.Build(t.e.Context), Y: t.e.Unanswered, Mine: t.e.Mine,
Margine: t.s.BuyNowPrice is > 0 ? t.s.BuyNowPrice.Value - t.e.Context.AuctionDepthCents / 100.0 - 0.01 : double.NaN))
.ToList();
var n = testX.Count;
var positives = testX.Count(r => r.Y);
var baseRate = (double)positives / n;
report.AppendLine($"Addestramento: {train.Count:N0} aste, {trainX.Count:N0} puntate");
report.AppendLine($"Valutazione : {test.Count:N0} aste, {n:N0} puntate, {positives:N0} senza risposta ({baseRate:P3})");
report.AppendLine();
// ── Confronto fra configurazioni ─────────────────────────────────
//
// Il modello deve separare (sollevamento) E stare nella scala giusta
// (calibrazione): la probabilità entra in un valore atteso in euro. Si prova una
// griglia piccola e si sceglie la configurazione meglio calibrata fra quelle che
// separano davvero.
const double costo = 0.20;
// Prima griglia (passo × passate × media): il passo 0,01 in una passata era il
// migliore, ma sovrastimava ancora di 1,6-1,8 volte e la media non aiutava. Il
// sospetto è la regolarizzazione: con centinaia di migliaia di passi il
// decadimento accumulato riduce i pesi a due terzi, e i pesi sono quasi tutti
// negativi, quindi le probabilità salgono. Seconda griglia: passo × regolarizzazione.
var configs = new List<(string Nome, double Lr, int Epochs, bool Avg, double L2)>();
foreach (var lr in new[] { 0.01, 0.003 })
foreach (var l2 in new[] { 1e-4, 1e-6, 0.0 })
configs.Add(($"passo {lr:0.000}, L2 {l2:0.######}", lr, 1, false, l2));
report.AppendLine("Configurazione | sollev. 5% | prevista/osservata (cima 10%) | prevista/osservata (tutte)");
report.AppendLine("-------------------------------------+------------+-------------------------------+---------------------------");
OnlineLogit? best = null; string bestName = ""; double bestScore = double.MaxValue; double bestLift = 0;
foreach (var (nome, lr, ep, avg, l2) in configs)
{
var modello = new OnlineLogit(BidFeatures.Size) { LearningRate = lr, UseAveraging = avg, L2 = l2 };
for (var e = 0; e < ep; e++) foreach (var (x, y) in trainX) modello.Update(x, y);
var ps = testX.Select(r => modello.Predict(r.X)).ToArray();
var order = Enumerable.Range(0, n).OrderByDescending(i => ps[i]).ToArray();
var k5 = Math.Max(1, n / 20); var k10 = Math.Max(1, n / 10);
var lift5 = order.Take(k5).Count(i => testX[i].Y) / (double)k5 / baseRate;
var top10 = order.Take(k10).ToArray();
var ratioTop = top10.Average(i => ps[i]) / Math.Max(1e-9, top10.Average(i => testX[i].Y ? 1.0 : 0.0));
var ratioAll = ps.Average() / baseRate;
report.AppendLine($"{nome,-37} | {lift5,9:N2}x | {ratioTop,29:N2} | {ratioAll,25:N2}");
// Punteggio: distanza della scala da 1 (in logaritmo), solo se separa.
var score = Math.Abs(Math.Log(Math.Max(1e-9, ratioTop))) + Math.Abs(Math.Log(Math.Max(1e-9, ratioAll)));
if (lift5 > 2.0 && score < bestScore) { bestScore = score; best = modello; bestName = nome; bestLift = lift5; }
}
report.AppendLine();
if (best == null)
{
report.AppendLine("Nessuna configurazione separa abbastanza (sollevamento al 5% > 2x).");
_output.WriteLine(report.ToString());
Assert.Fail("nessuna configurazione separa");
return;
}
report.AppendLine($"Scelta: {bestName}");
report.AppendLine();
// ── Valutazione prequenziale: come nell'uso vero ─────────────────
//
// Il modello in produzione è online: quando decide su un'asta ha già imparato da
// tutte quelle chiuse prima. Giudicarlo "congelato" sui vecchi e applicato ai nuovi
// gli attribuisce una deriva che nell'uso non subisce. Qui ogni asta di valutazione
// viene prima prevista, poi appresa, in ordine di tempo: è esattamente la sequenza
// del motore. Le previsioni sono quelle calibrate, come le vede il motore.
var model = best;
var rows = new List<Row>();
var perAsta = test.Select(s => DossierTrainingReader.Examples(s)
.Select(e => (
X: BidFeatures.Build(e.Context), Y: e.Unanswered, Mine: e.Mine,
Margine: s.BuyNowPrice is > 0 ? s.BuyNowPrice.Value - e.Context.AuctionDepthCents / 100.0 - 0.01 : double.NaN))
.ToList()).ToList();
foreach (var esempi in perAsta)
{
foreach (var r in esempi)
{
var p = model.PredictCalibrated(r.X);
rows.Add(new Row(p, r.Y, r.Mine, double.IsNaN(r.Margine) ? double.NaN : p * r.Margine - costo));
}
foreach (var r in esempi) model.Update(r.X, r.Y);
}
report.AppendLine($"Valutazione prequenziale (prevista poi appresa, asta per asta), fattore di calibrazione finale {model.CalibrationFactor:N2}:");
var meanPos = rows.Where(r => r.Unanswered).Average(r => r.P);
var meanNeg = rows.Where(r => !r.Unanswered).Average(r => r.P);
report.AppendLine($"P media sulle puntate senza risposta : {meanPos:P3}");
report.AppendLine($"P media sulle altre : {meanNeg:P3}");
report.AppendLine($"rapporto : {meanPos / Math.Max(meanNeg, 1e-9):N2}x");
report.AppendLine();
// Sollevamento: fra le puntate a probabilità più alta, quante erano davvero finali.
var sorted = rows.OrderByDescending(r => r.P).ToList();
report.AppendLine("Sollevamento nella cima della classifica (precisione / tasso di base):");
double liftTop5 = 0;
foreach (var frac in new[] { 0.01, 0.05, 0.10, 0.25 })
{
var k = Math.Max(1, (int)(n * frac));
var top = sorted.Take(k);
var prec = top.Count(r => r.Unanswered) / (double)k;
var lift = prec / baseRate;
if (frac == 0.05) liftTop5 = lift;
report.AppendLine($" cima {frac:P0}: {k,8:N0} puntate, precisione {prec:P2}, sollevamento {lift:N2}x");
}
report.AppendLine();
// Calibrazione: la probabilità prevista deve somigliare a quella osservata.
report.AppendLine("Calibrazione per fasce di probabilità prevista:");
var edges = new[] { 0.0, 0.001, 0.0025, 0.005, 0.01, 0.02, 0.05, 1.0 };
for (var i = 0; i < edges.Length - 1; i++)
{
var bin = rows.Where(r => r.P >= edges[i] && r.P < edges[i + 1]).ToList();
if (bin.Count == 0) continue;
report.AppendLine($" P in [{edges[i]:P2}, {edges[i + 1]:P2}): {bin.Count,8:N0} puntate, prevista {bin.Average(r => r.P):P3}, osservata {bin.Average(r => r.Unanswered ? 1.0 : 0.0):P3}");
}
report.AppendLine();
// Le nostre puntate vere: cosa avrebbe fatto il cancello.
var mie = rows.Where(r => r.Mine && !double.IsNaN(r.Ev)).ToList();
if (mie.Count > 0)
{
var passate = mie.Where(r => r.Ev >= 0).ToList();
var fermate = mie.Where(r => r.Ev < 0).ToList();
report.AppendLine($"Le mie puntate nel periodo di valutazione: {mie.Count:N0}, di cui finali {mie.Count(r => r.Unanswered):N0}");
report.AppendLine($" il cancello ne avrebbe lasciate passare {passate.Count:N0} (finali: {passate.Count(r => r.Unanswered):N0})");
report.AppendLine($" e ne avrebbe fermate {fermate.Count:N0} (di cui finali, cioè vincenti perse: {fermate.Count(r => r.Unanswered):N0})");
}
else
{
report.AppendLine("Nessuna mia puntata nel periodo di valutazione.");
}
report.AppendLine();
report.AppendLine("Pesi più forti (cosa ha imparato):");
foreach (var (nome, peso) in BidFeatures.Names.Select((nm, i) => (nm, model.Weights[i])).OrderByDescending(t => Math.Abs(t.Item2)).Take(15))
report.AppendLine($" {peso,+8:F3} {nome}");
var text = report.ToString();
_output.WriteLine(text);
var outFile = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_OUT");
if (!string.IsNullOrWhiteSpace(outFile))
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outFile))!);
File.WriteAllText(outFile, text);
}
// La condizione minima perché il modello serva a qualcosa: nella cima della
// classifica le puntate finali devono essere più frequenti che a caso.
Assert.True(liftTop5 > 1.0, $"il modello non separa: sollevamento al 5% = {liftTop5:N2}");
}
}