Files
Mimante/Mimante/Ml/LearningService.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

548 lines
23 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Ml
{
/// <summary>
/// L'apprendimento, sempre acceso: impara da ogni asta che chiude e risponde al motore
/// quando deve decidere.
///
/// <para><b>Due cose imparate, per due domande diverse.</b> Il <i>modello delle
/// puntate</i> risponde a «se punto adesso, qualcuno risponderà?»: una regressione
/// logistica su ciò che si vede nell'istante della puntata. Il <i>profilo delle aste</i>
/// risponde a «quanto costa di solito vincere questo prodotto a quest'ora?»: medie per
/// prodotto, fascia oraria e tipo di giorno, con restringimento dove i dati scarseggiano.</para>
///
/// <para><b>Da dove impara.</b> Dai dossier. Ogni puntata di ogni asta chiusa è un
/// esempio: rimasta senza risposta oppure no. Non dalle nostre vittorie, che sono
/// diciannove in duemila aste: un modello addestrato su quelle non sarebbe un modello.
/// Il comportamento avversario non dipende da chi ha puntato, e questo rende
/// utilizzabili i milioni di puntate altrui.</para>
///
/// <para><b>Il primo addestramento</b> è incrementale e in sottofondo: i dossier sono
/// migliaia e pesano gigabyte, e si leggono un po' per avvio, dal più recente, con un
/// tempo massimo per sessione. Quello che è già stato letto non si rilegge: un elenco
/// dei file appresi lo tiene a mente. Il profilo delle aste invece parte subito dallo
/// storico compatto, che è piccolo e ha le puntate del vincitore già verificate.</para>
///
/// <para><b>Cosa NON può dire.</b> Il modello impara come finiscono le aste senza di
/// noi. La nostra puntata cambia il gioco: chi stava per smettere può rispondere.
/// Nessun dato storico dice questo. Il modello dà la stima migliore possibile di
/// quando gli altri sono esausti, ed è quanto si può chiedere ai dati.</para>
/// </summary>
public static class LearningService
{
private static readonly object Sync = new();
// I salvataggi hanno un lucchetto tutto loro: all'avvio più aste concluse nel
// frattempo vengono chiuse insieme, ognuna su un proprio thread, e senza questo
// scrivevano lo stesso file nello stesso istante — "file in uso da un altro
// processo", che in realtà eravamo noi.
private static readonly object SaveSync = new();
private static OnlineLogit _model = new(BidFeatures.Size);
private static AuctionProfileStats _profile = new();
private static readonly HashSet<string> _ingested = new(StringComparer.OrdinalIgnoreCase);
private static int _auctionsLearned;
private static bool _loaded;
private static volatile bool _bootstrapRunning;
/// <summary>Righe di registro sull'apprendimento: chi si aggancia le scrive dove vuole.</summary>
public static event Action<string>? OnLog;
// ── Percorsi ─────────────────────────────────────────────────────
private static string Folder => Path.Combine(AppPaths.StatsFolder, "Apprendimento");
private static string ModelFile => Path.Combine(Folder, "modello-puntate.json");
private static string ProfileFile => Path.Combine(Folder, "profilo-aste.json");
private static string ManifestFile => Path.Combine(Folder, "dossier-appresi.txt");
// ── Stato, per l'interfaccia e il registro ───────────────────────
public static long ModelUpdates { get { lock (Sync) return _model.Updates; } }
public static double CalibrationFactor { get { lock (Sync) return _model.CalibrationFactor; } }
public static int AuctionsLearned { get { lock (Sync) return _auctionsLearned; } }
public static long ProfileAuctions { get { lock (Sync) return _profile.AuctionsObserved; } }
public static bool BootstrapRunning => _bootstrapRunning;
/// <summary>
/// Abbastanza aste apprese perché il cancello sul valore atteso possa fermare una
/// puntata. Prima di questa soglia il modello parla, ma non decide.
/// </summary>
public static bool IsReady(AppSettings settings) =>
AuctionsLearned >= Math.Max(1, settings.LearningMinAuctions);
/// <summary>I pesi con i loro nomi, ordinati per peso: per guardare cosa ha imparato.</summary>
public static IReadOnlyList<(string Nome, double Peso)> Pesi()
{
lock (Sync)
{
return BidFeatures.Names
.Select((n, i) => (n, _model.Weights[i]))
.OrderByDescending(t => Math.Abs(t.Item2))
.ToList();
}
}
/// <summary>Il profilo per prodotto e fascia, dalla combinazione più numerosa.</summary>
public static IReadOnlyList<AuctionProfileStats.Row> ProfileRows(int max = 200)
{
lock (Sync) return _profile.Rows(max);
}
// ── Ultime decisioni, per la scheda ──────────────────────────────
//
// Il motore chiede al modello a ogni giro utile, e la risposta finisce nel
// registro dell'asta. Qui se ne tiene una coda corta per vederle tutte insieme:
// e' l'unico modo di capire a colpo d'occhio se il cancello sta fermando troppo o
// troppo poco.
/// <summary>Una risposta data al motore.</summary>
public readonly record struct Decision(
DateTime At, string Auction, double Price, double Probability, double ExpectedValue, bool Blocked, bool Ready);
private const int MaxDecisions = 300;
private static readonly Queue<Decision> _decisions = new();
public static void RecordDecision(string auction, double price, double probability, double expectedValue, bool blocked, bool ready)
{
lock (_decisions)
{
// Lo stesso giro dell'asta produce molte risposte uguali di fila: si tiene
// solo il cambiamento, altrimenti la coda si riempie di una sola asta.
if (_decisions.Count > 0)
{
var last = _decisions.Last();
if (last.Auction == auction && last.Blocked == blocked &&
Math.Abs(last.Price - price) < 0.005 && (DateTime.Now - last.At).TotalSeconds < 2)
return;
}
_decisions.Enqueue(new Decision(DateTime.Now, auction, price, probability, expectedValue, blocked, ready));
while (_decisions.Count > MaxDecisions) _decisions.Dequeue();
}
}
public static IReadOnlyList<Decision> RecentDecisions()
{
lock (_decisions) return _decisions.Reverse().ToList();
}
// ── Valutazione dall'applicazione ────────────────────────────────
private static string EvaluationFile => Path.Combine(Folder, "valutazione.txt");
/// <summary>L'ultimo rapporto di valutazione salvato, se c'è.</summary>
public static string? LastEvaluation()
{
try { return File.Exists(EvaluationFile) ? File.ReadAllText(EvaluationFile) : null; }
catch { return null; }
}
/// <summary>
/// Valuta il modello sui dossier di questa macchina, in sottofondo, e salva il
/// rapporto. È la stessa valutazione del target da riga di comando.
/// </summary>
public static Task<LearningEvaluator.Report> EvaluateAsync(int maxFiles, Action<string>? progress, System.Threading.CancellationToken ct)
{
return Task.Run(() =>
{
var me = AuctionDossier.CurrentUsername;
var report = LearningEvaluator.Run(AppPaths.AuctionLogFolder, maxFiles, string.IsNullOrEmpty(me) ? null : me, progress, ct);
try
{
Directory.CreateDirectory(Folder);
lock (SaveSync) File.WriteAllText(EvaluationFile, $"Valutazione del {DateTime.Now:dd/MM/yyyy HH:mm}\n\n" + report.Text);
}
catch { /* il rapporto e' comunque restituito */ }
return report;
}, ct);
}
// ── Ricominciare da capo ─────────────────────────────────────────
/// <summary>
/// Butta modello, profilo ed elenco dei dossier letti, e ricomincia a studiare
/// dall'archivio. Serve quando si cambiano le variabili o si sospetta che il
/// modello abbia imparato da dati sbagliati. Non tocca i dossier.
/// </summary>
public static Task RetrainFromScratchAsync(AppSettings settings)
{
lock (Sync)
{
_model = new OnlineLogit(BidFeatures.Size);
_profile = new AuctionProfileStats();
_ingested.Clear();
_auctionsLearned = 0;
}
lock (_decisions) _decisions.Clear();
try
{
lock (SaveSync)
{
foreach (var f in new[] { ModelFile, ProfileFile, ManifestFile })
if (File.Exists(f)) File.Delete(f);
}
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Archivio non cancellato del tutto: {ex.Message}");
}
OnLog?.Invoke("[APPRENDIMENTO] Ricomincio da capo: profilo dallo storico, poi tutti i dossier.");
BootstrapProfileFromHistory();
// Un riaddestramento voluto ha diritto a tutto il tempo che serve.
var generose = new AppSettings { LearningBootstrapSecondsPerStart = Math.Max(600, settings.LearningBootstrapSecondsPerStart) };
return Task.Run(() => BootstrapAsync(generose));
}
// ── Avvio ────────────────────────────────────────────────────────
public static void Start(AppSettings settings)
{
Load();
BootstrapProfileFromHistory();
_ = Task.Run(() => BootstrapAsync(settings));
}
private static void Load()
{
lock (Sync)
{
if (_loaded) return;
_loaded = true;
try
{
if (File.Exists(ModelFile))
_model = OnlineLogit.FromJson(File.ReadAllText(ModelFile), BidFeatures.Size);
if (File.Exists(ProfileFile))
_profile = AuctionProfileStats.FromJson(File.ReadAllText(ProfileFile));
if (File.Exists(ManifestFile))
{
foreach (var line in File.ReadLines(ManifestFile))
{
var l = line.Trim();
if (l.Length == 0) continue;
if (l.StartsWith("#", StringComparison.Ordinal))
{
if (l.StartsWith("#appresi=", StringComparison.Ordinal) &&
int.TryParse(l["#appresi=".Length..], out var n)) _auctionsLearned = n;
continue;
}
_ingested.Add(l);
}
}
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Archivio illeggibile, si riparte da zero: {ex.Message}");
_model = new OnlineLogit(BidFeatures.Size);
_profile = new AuctionProfileStats();
_ingested.Clear();
_auctionsLearned = 0;
}
}
OnLog?.Invoke($"[APPRENDIMENTO] Modello: {ModelUpdates:N0} aggiornamenti da {AuctionsLearned:N0} aste; " +
$"profilo: {ProfileAuctions:N0} aste; dossier già letti: {_ingested.Count:N0}");
}
private static void Save()
{
try
{
string model, profile;
lock (Sync)
{
model = _model.ToJson();
profile = _profile.ToJson();
}
lock (SaveSync)
{
Directory.CreateDirectory(Folder);
File.WriteAllText(ModelFile, model);
File.WriteAllText(ProfileFile, profile);
}
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Salvataggio non riuscito: {ex.Message}");
}
}
private static void SaveManifest()
{
try
{
string[] lines;
lock (Sync)
{
lines = new[] { "#appresi=" + _auctionsLearned }
.Concat(_ingested.OrderBy(x => x, StringComparer.Ordinal))
.ToArray();
}
lock (SaveSync)
{
Directory.CreateDirectory(Folder);
File.WriteAllLines(ManifestFile, lines);
}
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Elenco dei dossier letti non salvato: {ex.Message}");
}
}
/// <summary>
/// Il profilo parte dallo storico compatto: piccolo, immediato, e con le puntate
/// del vincitore già passate dal controllo di coerenza. Solo se il profilo è vuoto,
/// perché lo storico è la stessa fonte che poi lo alimenta asta per asta.
/// </summary>
private static void BootstrapProfileFromHistory()
{
lock (Sync) { if (_profile.AuctionsObserved > 0) return; }
try
{
var records = CompletedAuctionsStore.LoadAll();
var fresh = new AuctionProfileStats();
foreach (var r in records)
fresh.Observe(r.ProductKey, r.EndedAt.ToLocalTime(),
AuctionIntegrity.TrustedWinnerBids(r), Ratio(r.FinalPrice, r.BuyNowPrice));
lock (Sync) _profile = fresh;
Save();
OnLog?.Invoke($"[APPRENDIMENTO] Profilo delle aste costruito dallo storico: {records.Count:N0} aste");
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Profilo dallo storico non riuscito: {ex.Message}");
}
}
/// <summary>
/// Legge i dossier non ancora appresi, dal più recente, finché c'è tempo. Il tempo è
/// per avvio: la prima volta ne servono diversi, poi resta solo l'asta appena chiusa.
/// </summary>
private static async Task BootstrapAsync(AppSettings settings)
{
if (_bootstrapRunning) return;
_bootstrapRunning = true;
try
{
await Task.Delay(3000).ConfigureAwait(false); // prima l'avvio, poi lo studio
var folder = AppPaths.AuctionLogFolder;
if (!Directory.Exists(folder)) return;
string[] pending;
lock (Sync)
{
pending = Directory.GetFiles(folder, "*.jsonl")
.Where(f => !_ingested.Contains(Path.GetFileName(f)))
.OrderByDescending(Path.GetFileName, StringComparer.Ordinal)
.ToArray();
}
if (pending.Length == 0) return;
var budget = TimeSpan.FromSeconds(Math.Max(10, settings.LearningBootstrapSecondsPerStart));
var sw = Stopwatch.StartNew();
var done = 0;
var usable = 0;
OnLog?.Invoke($"[APPRENDIMENTO] {pending.Length:N0} dossier da leggere, al massimo {budget.TotalSeconds:N0} s per questo avvio");
foreach (var file in pending)
{
if (sw.Elapsed > budget) break;
if (IngestFile(file)) usable++;
done++;
if (done % 100 == 0)
{
Save();
SaveManifest();
OnLog?.Invoke($"[APPRENDIMENTO] {done:N0} dossier letti, {usable:N0} utilizzabili, {ModelUpdates:N0} aggiornamenti");
}
// Un respiro ogni file: il motore delle aste ha la precedenza.
await Task.Yield();
}
Save();
SaveManifest();
var remaining = pending.Length - done;
OnLog?.Invoke($"[APPRENDIMENTO] Letti {done:N0} dossier ({usable:N0} utilizzabili) in {sw.Elapsed.TotalSeconds:N0} s; " +
(remaining > 0 ? $"ne restano {remaining:N0} per i prossimi avvii." : "archivio completo."));
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Studio dei dossier interrotto: {ex.Message}");
}
finally
{
_bootstrapRunning = false;
}
}
/// <summary>
/// Impara da un dossier. Restituisce true se era utilizzabile. Un file letto si
/// segna come letto anche se non era utilizzabile: rileggerlo non lo renderebbe tale.
/// </summary>
private static bool IngestFile(string path)
{
var name = Path.GetFileName(path);
var session = DossierTrainingReader.ReadFile(path);
lock (Sync)
{
_ingested.Add(name);
if (session == null || !session.IsUsable) return false;
foreach (var (ctx, unanswered, _) in DossierTrainingReader.Examples(session))
_model.Update(BidFeatures.Build(ctx), unanswered);
_auctionsLearned++;
}
return true;
}
// ── Chiusura di un'asta: si impara subito ────────────────────────
/// <summary>
/// Da chiamare quando un'asta è chiusa e il suo dossier è stato scritto. Aggiorna
/// il profilo con lo storico appena registrato e il modello con le puntate del
/// dossier, poi salva: la prossima decisione le vede già.
/// </summary>
public static void OnAuctionClosed(CompletedAuctionRecord record, string? dossierPath)
{
if (record == null) return;
try
{
lock (Sync)
{
_profile.Observe(record.ProductKey, record.EndedAt.ToLocalTime(),
AuctionIntegrity.TrustedWinnerBids(record), Ratio(record.FinalPrice, record.BuyNowPrice));
}
var learned = false;
if (!string.IsNullOrWhiteSpace(dossierPath) && File.Exists(dossierPath))
{
// Il dossier è appena stato svuotato su disco da Close: si può leggere.
learned = IngestFile(dossierPath);
}
Save();
SaveManifest();
OnLog?.Invoke(learned
? $"[APPRENDIMENTO] '{record.Name}': appresa ({ModelUpdates:N0} aggiornamenti, {AuctionsLearned:N0} aste)"
: $"[APPRENDIMENTO] '{record.Name}': profilo aggiornato, dossier non utilizzabile per il modello");
}
catch (Exception ex)
{
OnLog?.Invoke($"[APPRENDIMENTO] Errore su '{record.Name}': {ex.Message}");
}
}
// ── Domande dal motore ───────────────────────────────────────────
/// <summary>
/// Se puntassi adesso, con che probabilità nessuno risponderebbe? Null quando non
/// c'è abbastanza contesto (nessuna puntata recente vista).
/// </summary>
public static double? PredictUnanswered(AuctionInfo auction, AuctionState state, DateTime nowLocal, string? myUsername)
{
if (auction == null || state == null) return null;
var recent = auction.SnapshotRecentBids(20); // dalla più recente
if (recent.Count == 0) return null;
var last = recent[0];
var gap = state.ServerUnixSeconds > 0 && last.Timestamp > 0
? state.ServerUnixSeconds - last.Timestamp
: -1;
// Quota di autopuntate: solo sui tipi dichiarati dal server.
int declared = 0, auto = 0;
foreach (var b in recent.Take(10))
{
if (b.IsAuto) { auto++; declared++; }
else if (b.IsManual) declared++;
}
var autoShare = declared == 0 ? -1 : (double)auto / declared;
var distinct = recent.Select(b => b.Username).Distinct(StringComparer.OrdinalIgnoreCase).Count();
var sinceManual = -1;
for (var i = 0; i < recent.Count; i++)
{
if (recent[i].IsManual) { sinceManual = i; break; }
if (!recent[i].IsAuto) break; // tipo ignoto: non si conta
}
var ctx = new BidContext(
CycleSeconds: gap,
AutoShareRecent: autoShare,
DistinctBiddersRecent: Math.Max(1, distinct),
PriceRatio: auction.BuyNowPrice is > 0 ? state.Price / auction.BuyNowPrice.Value : null,
Hour: nowLocal.Hour,
Day: nowLocal.DayOfWeek,
OpenHour: auction.AddedAt.ToLocalTime().Hour,
IsAutoBid: false,
CyclesSinceManual: sinceManual,
AuctionDepthCents: (int)Math.Round(state.Price * 100),
SameBidderAsTwoAgo: recent.Count >= 2 && !string.IsNullOrEmpty(myUsername) &&
string.Equals(recent[1].Username, myUsername, StringComparison.OrdinalIgnoreCase),
ProductKey: ProductKeyHelper.GenerateProductKey(auction.Name));
var x = BidFeatures.Build(ctx);
lock (Sync) return _model.PredictCalibrated(x);
}
/// <summary>Cosa aspettarsi da un'asta di questo prodotto se chiudesse a quest'ora.</summary>
public static AuctionProfileStats.Estimate? Expect(string? productKey, DateTime whenLocal)
{
lock (Sync) return _profile.Expect(productKey, whenLocal);
}
private static double? Ratio(double finalPrice, double? buyNow) =>
buyNow is > 0 && finalPrice > 0 ? finalPrice / buyNow.Value : null;
/// <summary>Solo per i test: stato pulito, senza toccare il disco.</summary>
public static void ResetForTests()
{
lock (Sync)
{
_model = new OnlineLogit(BidFeatures.Size);
_profile = new AuctionProfileStats();
_ingested.Clear();
_auctionsLearned = 0;
_loaded = true;
}
}
}
}