Files
Mimante/Mimante/Ml/LearningEvaluator.cs
T
Alby96andClaude Fable 5.1 afb778ea26 Scheda Apprendimento: cosa sa il modello, cosa ha deciso, e la valutazione dall'app
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>
2026-09-07 23:09:03 +02:00

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)
};
}
}
}