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

431 lines
18 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();
}
}
// ── 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;
}
}
}
}