Una scheda nuova nella barra laterale, fra Esporta e Impostazioni, che legge direttamente dal servizio e si aggiorna da sola ogni cinque secondi mentre e' visibile: nessun altro pezzo dell'applicazione sa che esiste. Mostra: lo stato (aste apprese, esempi visti, aste nel profilo, fattore di calibrazione, soglia per decidere, se lo studio dei dossier e' in corso); i pesi dal piu' forte con il verso dell'effetto, perche' le variabili sono a fasce e il modello e' lineare proprio per poterli leggere; le ultime decisioni date al motore, con probabilita', valore atteso ed esito (fermata, passa, o solo parere se il modello non aveva ancora appreso abbastanza); il profilo per prodotto e fascia oraria; e l'ultima valutazione. La valutazione si lancia dalla scheda, in sottofondo e annullabile. La logica e' uscita dal test ed e' entrata in LearningEvaluator, condivisa fra il target da riga di comando e la scheda: i numeri che vede l'utente sono gli stessi che vede chi sviluppa. "Ricomincia da capo" butta modello, profilo ed elenco dei dossier letti e ristudia tutto l'archivio, con una conferma prima. Le decisioni si tengono in una coda corta; lo stesso giro di un'asta ne produrrebbe molte uguali di fila, quindi si registra solo il cambiamento. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
218 lines
11 KiB
C#
218 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Threading;
|
|
|
|
namespace AutoBidder.Ml
|
|
{
|
|
/// <summary>
|
|
/// Valuta il modello sui dossier veri, con una divisione onesta nel tempo: si impara
|
|
/// dai più vecchi e si giudica sui più recenti, mai visti. Poi, sui recenti, la
|
|
/// valutazione <b>prequenziale</b>: ogni asta prima prevista e poi appresa, in ordine di
|
|
/// tempo, che è esattamente la sequenza del motore in produzione.
|
|
///
|
|
/// <para>Sta in una classe a sé perché la usano in due: il test da riga di comando
|
|
/// (target <c>Apprendimento</c>) e la scheda nell'applicazione. Una sola
|
|
/// implementazione, così i numeri che vede l'utente sono gli stessi che vede chi
|
|
/// sviluppa.</para>
|
|
///
|
|
/// <para><b>Cosa misura.</b> Quanto il modello separa le puntate rimaste senza risposta
|
|
/// (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 fermate, e quante di quelle erano invece vincenti.</para>
|
|
/// </summary>
|
|
public static class LearningEvaluator
|
|
{
|
|
public sealed class Report
|
|
{
|
|
public string Text { get; init; } = "";
|
|
public int Files { get; init; }
|
|
public int Usable { get; init; }
|
|
public double Lift5 { get; init; }
|
|
public double CalibrationFactor { get; init; }
|
|
public int MyBids { get; init; }
|
|
public int MyBlocked { get; init; }
|
|
public int MyBlockedWinning { get; init; }
|
|
public bool Enough => Usable >= 20;
|
|
}
|
|
|
|
private sealed record Row(double P, bool Unanswered, bool Mine, double Ev);
|
|
|
|
public static Report Run(string folder, int maxFiles, string? me, Action<string>? progress = null, CancellationToken ct = default)
|
|
{
|
|
var files = Directory.GetFiles(folder, "*.jsonl").OrderBy(Path.GetFileName, StringComparer.Ordinal).ToList();
|
|
if (maxFiles > 0 && files.Count > maxFiles) files = files.Skip(files.Count - maxFiles).ToList();
|
|
|
|
var sessions = new List<DossierTrainingReader.Session>();
|
|
var letti = 0;
|
|
foreach (var f in files)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var s = DossierTrainingReader.ReadFile(f, me);
|
|
if (s != null && s.IsUsable) sessions.Add(s);
|
|
if (++letti % 200 == 0) progress?.Invoke($"letti {letti:N0} dossier su {files.Count:N0}…");
|
|
}
|
|
|
|
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.");
|
|
return new Report { Text = report.ToString(), Files = files.Count, Usable = sessions.Count };
|
|
}
|
|
|
|
progress?.Invoke("addestramento e confronto delle configurazioni…");
|
|
|
|
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.
|
|
var trainX = train.SelectMany(s => DossierTrainingReader.Examples(s))
|
|
.Select(e => (X: BidFeatures.Build(e.Context), Y: e.Unanswered)).ToList();
|
|
|
|
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();
|
|
var testX = perAsta.SelectMany(a => a).ToList();
|
|
|
|
var n = testX.Count;
|
|
var positives = testX.Count(r => r.Y);
|
|
var baseRate = (double)positives / Math.Max(1, 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 (modello congelato) ─────────────
|
|
//
|
|
// Serve a scegliere; non è la misura finale. Un modello congelato sui vecchi e
|
|
// applicato ai nuovi sconta la deriva del mercato, che nell'uso online non
|
|
// subisce: la misura che conta è la prequenziale qui sotto.
|
|
const double costo = 0.20;
|
|
var configs = new List<(string Nome, double Lr, double L2)>();
|
|
foreach (var lr in new[] { 0.01, 0.003 })
|
|
foreach (var l2 in new[] { 1e-4, 0.0 })
|
|
configs.Add(($"passo {lr:0.000}, L2 {l2:0.######}", lr, l2));
|
|
|
|
report.AppendLine("Configurazione (modello congelato) | sollev. 5% | prevista/osservata (cima 10%) | prevista/osservata (tutte)");
|
|
report.AppendLine("-------------------------------------+------------+-------------------------------+---------------------------");
|
|
|
|
OnlineLogit? best = null; var bestName = ""; var bestScore = double.MaxValue;
|
|
foreach (var (nome, lr, l2) in configs)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var modello = new OnlineLogit(BidFeatures.Size) { LearningRate = lr, L2 = l2 };
|
|
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 / Math.Max(1e-9, 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() / Math.Max(1e-9, baseRate);
|
|
|
|
report.AppendLine($"{nome,-37} | {lift5,9:N2}x | {ratioTop,29:N2} | {ratioAll,25:N2}");
|
|
|
|
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; }
|
|
}
|
|
report.AppendLine();
|
|
|
|
if (best == null)
|
|
{
|
|
report.AppendLine("Nessuna configurazione separa abbastanza (sollevamento al 5% > 2x).");
|
|
return new Report { Text = report.ToString(), Files = files.Count, Usable = sessions.Count };
|
|
}
|
|
|
|
report.AppendLine($"Scelta: {bestName}");
|
|
report.AppendLine();
|
|
|
|
// ── Prequenziale: come nell'uso vero ─────────────────────────────
|
|
progress?.Invoke("valutazione prequenziale…");
|
|
var model = best;
|
|
var rows = new List<Row>();
|
|
foreach (var esempi in perAsta)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
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 (ogni asta prima prevista, poi appresa), fattore di calibrazione finale {model.CalibrationFactor:N2}:");
|
|
|
|
var meanPos = rows.Where(r => r.Unanswered).Select(r => r.P).DefaultIfEmpty(0).Average();
|
|
var meanNeg = rows.Where(r => !r.Unanswered).Select(r => r.P).DefaultIfEmpty(0).Average();
|
|
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();
|
|
|
|
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 prec = sorted.Take(k).Count(r => r.Unanswered) / (double)k;
|
|
var lift = prec / Math.Max(1e-9, 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();
|
|
|
|
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();
|
|
|
|
var mie = rows.Where(r => r.Mine && !double.IsNaN(r.Ev)).ToList();
|
|
var fermate = mie.Where(r => r.Ev < 0).ToList();
|
|
if (mie.Count > 0)
|
|
{
|
|
var passate = 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 (serve il nome utente, o non ci sono).");
|
|
}
|
|
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}");
|
|
|
|
return new Report
|
|
{
|
|
Text = report.ToString(),
|
|
Files = files.Count,
|
|
Usable = sessions.Count,
|
|
Lift5 = liftTop5,
|
|
CalibrationFactor = model.CalibrationFactor,
|
|
MyBids = mie.Count,
|
|
MyBlocked = fermate.Count,
|
|
MyBlockedWinning = fermate.Count(r => r.Unanswered)
|
|
};
|
|
}
|
|
}
|
|
}
|