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>
This commit is contained in:
2026-09-07 23:09:03 +02:00
co-authored by Claude Fable 5.1
parent 46134c822d
commit afb778ea26
10 changed files with 847 additions and 224 deletions
+218
View File
@@ -0,0 +1,218 @@
<UserControl x:Class="AutoBidder.Controls.LearningControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ctl="clr-namespace:AutoBidder.Controls"
mc:Ignorable="d"
d:DesignHeight="800" d:DesignWidth="1200"
Background="{DynamicResource Brush.Bg}">
<UserControl.Resources>
<Style x:Key="Glyph" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
</Style>
<Style x:Key="Stat" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.Text}"/>
<Setter Property="FontSize" Value="{StaticResource Font.Size.Lg}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="StatLabel" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Brush.TextMuted}"/>
<Setter Property="FontSize" Value="{StaticResource Font.Size.Sm}"/>
<Setter Property="Margin" Value="0,2,0,0"/>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ═══ Barra strumenti ═══ -->
<Border Grid.Row="0" Background="{DynamicResource Brush.Surface}" Padding="10,7"
BorderBrush="{DynamicResource Brush.Border}" BorderThickness="0,0,0,1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="RefreshButton" Style="{StaticResource IconButton}"
ToolTip="Aggiorna i numeri" Click="RefreshButton_Click">
<TextBlock Style="{StaticResource Glyph}" Text="&#xE72C;"/>
</Button>
<TextBlock Text="Apprendimento" FontWeight="SemiBold" Margin="8,0,0,0"
FontSize="{StaticResource Font.Size.Lg}"
Foreground="{DynamicResource Brush.Text}" VerticalAlignment="Center"/>
<Border Width="1" Background="{DynamicResource Brush.Border}" Margin="10,4"/>
<Border Style="{StaticResource Pill}" ToolTip="Aste da cui il modello ha imparato">
<TextBlock x:Name="PillAuctions" Text="—" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.Text}"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Esempi (puntate) che hanno aggiornato i pesi">
<TextBlock x:Name="PillUpdates" Text="—" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</Border>
<Border Style="{StaticResource Pill}" ToolTip="Fattore di calibrazione: osservato/previsto sugli ultimi esempi. 1 = scala giusta">
<TextBlock x:Name="PillCalibration" Text="—" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.TextMuted}"/>
</Border>
<Border x:Name="PillReadyBorder" Style="{StaticResource Pill}"
ToolTip="Pronto = ha appreso abbastanza aste per fermare una puntata. Prima parla soltanto.">
<TextBlock x:Name="PillReady" Text="—" FontSize="{StaticResource Font.Size.Sm}"
Foreground="{DynamicResource Brush.Warning}"/>
</Border>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="EvaluateButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.Accent}"
Foreground="{DynamicResource Brush.TextOnAccent}"
Content="Valuta ora" Margin="0,0,8,0"
ToolTip="Addestra sui dossier più vecchi di questa macchina e giudica sui più recenti, mai visti. Poi la prova prequenziale: ogni asta prima prevista e poi appresa, come fa il motore. Qualche minuto in sottofondo."
Click="EvaluateButton_Click"/>
<Button x:Name="OpenFolderButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
Content="Apri cartella" Margin="0,0,8,0"
ToolTip="La cartella con modello, profilo, elenco dei dossier letti e ultima valutazione"
Click="OpenFolderButton_Click"/>
<Button x:Name="RetrainButton" Style="{StaticResource SmallRoundedButton}"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Danger}"
Content="Ricomincia da capo"
ToolTip="Butta modello e profilo e ristudia tutti i dossier. Non tocca i dossier né lo storico."
Click="RetrainButton_Click"/>
</StackPanel>
</Grid>
</Border>
<!-- ═══ Corpo ═══ -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<Grid Margin="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Stato -->
<Border Grid.Row="0" Grid.Column="0" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Stato" Margin="0,0,0,10"
Help="Il modello impara da ogni asta che chiude: per ogni puntata, è rimasta senza risposta? È l'unica etichetta abbondante — milioni di esempi — mentre le vittorie proprie sono poche decine. Il comportamento avversario non dipende da chi ha puntato, e questo rende utilizzabili le puntate altrui.&#10;&#10;Decide col valore atteso: probabilità appresa × margine residuo costo della puntata. Sotto zero non punta, e lo scrive nel registro con i numeri. Decide solo dopo la soglia di aste apprese: prima parla soltanto."/>
<UniformGrid Columns="3" Rows="2">
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatAuctions" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="aste apprese"/>
</StackPanel>
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatUpdates" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="esempi visti"/>
</StackPanel>
<StackPanel Margin="0,0,8,10">
<TextBlock x:Name="StatProfile" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="aste nel profilo"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatCalibration" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="calibrazione (1 = giusta)"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatThreshold" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="soglia per decidere"/>
</StackPanel>
<StackPanel Margin="0,0,8,0">
<TextBlock x:Name="StatBootstrap" Style="{StaticResource Stat}" Text="—"/>
<TextBlock Style="{StaticResource StatLabel}" Text="studio dei dossier"/>
</StackPanel>
</UniformGrid>
<TextBlock x:Name="StatDecisions" Style="{StaticResource Hint}" Margin="0,10,0,0" Text=""/>
</StackPanel>
</Border>
<!-- Cosa ha imparato -->
<Border Grid.Row="0" Grid.Column="2" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Cosa ha imparato" Margin="0,0,0,10"
Help="I pesi del modello, dal più forte. Positivo = quella condizione rende più probabile che una puntata resti senza risposta; negativo = meno probabile. Le variabili sono a fasce e il modello è lineare proprio per poterli leggere così.&#10;&#10;Il prodotto è ridotto a 32 cassetti con una funzione di hash: due prodotti possono condividere un cassetto, ed è accettato."/>
<DataGrid x:Name="WeightsGrid" MaxHeight="260" IsReadOnly="True" HeadersVisibility="Column"
AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Condizione" Binding="{Binding Nome}" Width="*"/>
<DataGridTextColumn Header="Peso" Binding="{Binding PesoDisplay}" Width="Auto" MinWidth="70"/>
<DataGridTextColumn Header="Effetto" Binding="{Binding Effetto}" Width="Auto" MinWidth="110"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<!-- Ultime decisioni -->
<Border Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Ultime decisioni" Margin="0,0,0,10"
Help="Ogni volta che il motore era pronto a puntare ha chiesto al modello. P è la probabilità che la puntata resti senza risposta; il valore atteso è P × margine residuo costo. «Fermata» = il cancello ha detto no. «Parere» = il modello non aveva ancora appreso abbastanza per decidere, e si è limitato a dirlo."/>
<DataGrid x:Name="DecisionsGrid" MaxHeight="280" IsReadOnly="True" HeadersVisibility="Column"
AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Ora" Binding="{Binding Ora}" Width="Auto" MinWidth="80"/>
<DataGridTextColumn Header="Asta" Binding="{Binding Asta}" Width="*"/>
<DataGridTextColumn Header="Prezzo" Binding="{Binding Prezzo}" Width="Auto" MinWidth="70"/>
<DataGridTextColumn Header="P senza risposta" Binding="{Binding Prob}" Width="Auto" MinWidth="110"/>
<DataGridTextColumn Header="Valore atteso" Binding="{Binding Ev}" Width="Auto" MinWidth="100"/>
<DataGridTextColumn Header="Esito" Binding="{Binding Esito}" Width="Auto" MinWidth="90"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<!-- Profilo per prodotto -->
<Border Grid.Row="2" Grid.Column="0" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Profilo per prodotto e fascia oraria" Margin="0,0,0,10"
Help="Cosa aspettarsi prima che un'asta cominci: puntate del vincitore e prezzo di chiusura in percentuale del valore, per prodotto, fascia oraria e tipo di giorno. Solo le combinazioni con aste osservate; quando i dati sono pochi il motore restringe verso il prodotto e poi verso la media generale.&#10;&#10;Fasce: 0-8 notte e ore sospese · 9 · 10-12 · 13-17 · 18-20 · 21-23."/>
<DataGrid x:Name="ProfileGrid" MaxHeight="320" IsReadOnly="True" HeadersVisibility="Column"
AutoGenerateColumns="False" CanUserAddRows="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Prodotto" Binding="{Binding Prodotto}" Width="*"/>
<DataGridTextColumn Header="Fascia" Binding="{Binding Fascia}" Width="Auto" MinWidth="60"/>
<DataGridTextColumn Header="Giorno" Binding="{Binding Giorno}" Width="Auto" MinWidth="60"/>
<DataGridTextColumn Header="Aste" Binding="{Binding Aste}" Width="Auto" MinWidth="50"/>
<DataGridTextColumn Header="Punt. vincitore" Binding="{Binding Puntate}" Width="Auto" MinWidth="100"/>
<DataGridTextColumn Header="Chiusura" Binding="{Binding Chiusura}" Width="Auto" MinWidth="80"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</Border>
<!-- Valutazione -->
<Border Grid.Row="2" Grid.Column="2" Style="{StaticResource CardBlock}">
<StackPanel>
<ctl:HelpHeader Title="Ultima valutazione" Margin="0,0,0,10"
Help="Il rapporto dell'ultima valutazione sui dossier di questa macchina. Le misure che contano: il sollevamento (quanto più spesso le puntate a probabilità alta erano davvero finali, rispetto al caso), la calibrazione per fasce (la probabilità prevista deve somigliare a quella osservata), e cosa il cancello avrebbe fatto sulle tue puntate vere."/>
<TextBox x:Name="EvaluationBox" IsReadOnly="True" TextWrapping="NoWrap"
MinHeight="200" MaxHeight="320"
FontFamily="Consolas" FontSize="11.5"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"
Background="{DynamicResource Brush.SurfaceAlt}"
Foreground="{DynamicResource Brush.Text}"
BorderBrush="{DynamicResource Brush.Border}"
Text="Nessuna valutazione ancora. Premi «Valuta ora»."/>
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</Grid>
</UserControl>
+223
View File
@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using AutoBidder.Ml;
using AutoBidder.Utilities;
namespace AutoBidder.Controls
{
/// <summary>
/// La scheda dell'apprendimento: cosa sa il modello, cosa ha deciso di recente, cosa
/// aspettarsi per prodotto e ora, e l'ultima valutazione.
///
/// <para>Legge direttamente da <see cref="LearningService"/>: non ha bisogno della
/// finestra principale per niente, e si aggiorna da sola ogni pochi secondi mentre è
/// visibile. Tutto il resto dell'applicazione non sa che esiste.</para>
/// </summary>
public partial class LearningControl : UserControl
{
private readonly DispatcherTimer _refresh;
private CancellationTokenSource? _evaluation;
private sealed class PesoRow
{
public string Nome { get; init; } = "";
public double Peso { get; init; }
public string PesoDisplay => Peso.ToString("+0.000;-0.000");
public string Effetto => Math.Abs(Peso) < 0.05 ? "quasi nullo"
: Peso > 0 ? "più senza risposta" : "più risposte";
}
private sealed class ProfiloRow
{
public string Prodotto { get; init; } = "";
public string Fascia { get; init; } = "";
public string Giorno { get; init; } = "";
public long Aste { get; init; }
public string Puntate { get; init; } = "";
public string Chiusura { get; init; } = "";
}
private sealed class DecisioneRow
{
public string Ora { get; init; } = "";
public string Asta { get; init; } = "";
public string Prezzo { get; init; } = "";
public string Prob { get; init; } = "";
public string Ev { get; init; } = "";
public string Esito { get; init; } = "";
}
public LearningControl()
{
InitializeComponent();
_refresh = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
_refresh.Tick += (_, _) => { if (IsVisible) Refresh(); };
IsVisibleChanged += (_, e) =>
{
if ((bool)e.NewValue) { Refresh(); _refresh.Start(); }
else _refresh.Stop();
};
}
/// <summary>Rilegge tutto dal servizio. Costa poco: sono numeri già in memoria.</summary>
public void Refresh()
{
try
{
var settings = SettingsManager.Load();
var apprese = LearningService.AuctionsLearned;
var soglia = Math.Max(1, settings.LearningMinAuctions);
var pronto = LearningService.IsReady(settings);
var calib = LearningService.CalibrationFactor;
PillAuctions.Text = $"{apprese:N0} aste";
PillUpdates.Text = $"{LearningService.ModelUpdates:N0} esempi";
PillCalibration.Text = $"calibrazione {calib:N2}";
PillReady.Text = pronto ? "pronto" : $"in ascolto ({apprese}/{soglia})";
PillReady.Foreground = (System.Windows.Media.Brush)FindResource(pronto ? "Brush.Success" : "Brush.Warning");
StatAuctions.Text = apprese.ToString("N0");
StatUpdates.Text = LearningService.ModelUpdates.ToString("N0");
StatProfile.Text = LearningService.ProfileAuctions.ToString("N0");
StatCalibration.Text = calib.ToString("N2");
StatThreshold.Text = soglia.ToString("N0");
StatBootstrap.Text = LearningService.BootstrapRunning ? "in corso" : "fermo";
var decisioni = LearningService.RecentDecisions();
var fermate = decisioni.Count(d => d.Blocked);
StatDecisions.Text = decisioni.Count == 0
? "Nessuna decisione ancora: il modello risponde quando il motore è pronto a puntare."
: $"Ultime {decisioni.Count} decisioni: {fermate} fermate, {decisioni.Count - fermate} lasciate passare" +
(settings.LearningGateEnabled ? "." : ". Il cancello è spento nelle impostazioni: il modello parla ma non decide.");
WeightsGrid.ItemsSource = LearningService.Pesi()
.Take(30)
.Select(p => new PesoRow { Nome = p.Nome, Peso = p.Peso })
.ToList();
DecisionsGrid.ItemsSource = decisioni
.Select(d => new DecisioneRow
{
Ora = d.At.ToString("HH:mm:ss"),
Asta = d.Auction,
Prezzo = d.Price.ToString("F2") + " €",
Prob = d.Probability.ToString("P2"),
Ev = d.ExpectedValue.ToString("+0.000;-0.000") + " €",
Esito = !d.Ready ? "parere" : d.Blocked ? "fermata" : "passa"
})
.ToList();
ProfileGrid.ItemsSource = LearningService.ProfileRows(150)
.Select(r => new ProfiloRow
{
Prodotto = r.Product,
Fascia = FasciaLabel(r.HourBand),
Giorno = r.Weekend ? "festivo" : "feriale",
Aste = r.Auctions,
Puntate = double.IsNaN(r.WinnerBids) ? "—" : r.WinnerBids.ToString("N0"),
Chiusura = double.IsNaN(r.CloseRatio) ? "—" : r.CloseRatio.ToString("P1")
})
.ToList();
if (_evaluation == null)
{
var ultima = LearningService.LastEvaluation();
if (!string.IsNullOrWhiteSpace(ultima)) EvaluationBox.Text = ultima;
}
}
catch (Exception ex)
{
StatDecisions.Text = $"Aggiornamento non riuscito: {ex.Message}";
}
}
private static string FasciaLabel(int band) => band switch
{
0 => "0-8", 1 => "9", 2 => "10-12", 3 => "13-17", 4 => "18-20", _ => "21-23"
};
private void RefreshButton_Click(object sender, RoutedEventArgs e) => Refresh();
private void OpenFolderButton_Click(object sender, RoutedEventArgs e)
{
try
{
var folder = Path.Combine(AppPaths.StatsFolder, "Apprendimento");
Directory.CreateDirectory(folder);
Process.Start(new ProcessStartInfo { FileName = folder, UseShellExecute = true });
}
catch (Exception ex)
{
MessageBox.Show($"Cartella non apribile: {ex.Message}", "Apprendimento",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private async void EvaluateButton_Click(object sender, RoutedEventArgs e)
{
if (_evaluation != null)
{
_evaluation.Cancel();
return;
}
_evaluation = new CancellationTokenSource();
EvaluateButton.Content = "Annulla";
EvaluationBox.Text = "Valutazione in corso: lettura dei dossier…";
try
{
// Al massimo tremila dossier, i più recenti: bastano a giudicare, e la
// lettura di tutto l'archivio in sottofondo mentre le aste corrono non serve.
var report = await LearningService.EvaluateAsync(3000,
msg => Dispatcher.BeginInvoke(() => EvaluationBox.Text = "Valutazione in corso: " + msg),
_evaluation.Token);
EvaluationBox.Text = report.Text;
}
catch (OperationCanceledException)
{
EvaluationBox.Text = "Valutazione annullata.";
}
catch (Exception ex)
{
EvaluationBox.Text = $"Valutazione non riuscita: {ex.Message}";
}
finally
{
_evaluation = null;
EvaluateButton.Content = "Valuta ora";
}
}
private async void RetrainButton_Click(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(
"Butto modello, profilo ed elenco dei dossier letti, e ricomincio a studiare tutto l'archivio.\n\n" +
"I dossier e lo storico non vengono toccati. Ci vorranno un paio di minuti in sottofondo.\n\nProcedo?",
"Ricomincia da capo", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
RetrainButton.IsEnabled = false;
try
{
await LearningService.RetrainFromScratchAsync(SettingsManager.Load());
}
finally
{
RetrainButton.IsEnabled = true;
Refresh();
}
}
}
}
+9 -1
View File
@@ -54,6 +54,12 @@ namespace AutoBidder
LoadStatistics(); LoadStatistics();
} }
private void TabApprendimento_Checked(object sender, RoutedEventArgs e)
{
ShowPanel(Learning);
Learning.Refresh();
}
private void TabImpostazioni_Checked(object sender, RoutedEventArgs e) private void TabImpostazioni_Checked(object sender, RoutedEventArgs e)
{ {
try try
@@ -74,7 +80,8 @@ namespace AutoBidder
{ {
// Prevent NullReferenceException during initialization // Prevent NullReferenceException during initialization
if (AuctionMonitor == null || Browser == null || StatisticsPanel == null || if (AuctionMonitor == null || Browser == null || StatisticsPanel == null ||
Settings == null || FreeBids == null || Products == null || Export == null) Settings == null || FreeBids == null || Products == null || Export == null ||
Learning == null)
return; return;
// Hide all panels // Hide all panels
@@ -85,6 +92,7 @@ namespace AutoBidder
StatisticsPanel.Visibility = Visibility.Collapsed; StatisticsPanel.Visibility = Visibility.Collapsed;
Export.Visibility = Visibility.Collapsed; Export.Visibility = Visibility.Collapsed;
Settings.Visibility = Visibility.Collapsed; Settings.Visibility = Visibility.Collapsed;
Learning.Visibility = Visibility.Collapsed;
// Show selected panel // Show selected panel
if (panelToShow != null) if (panelToShow != null)
+9
View File
@@ -75,6 +75,12 @@
Style="{StaticResource VerticalTabButton}" Style="{StaticResource VerticalTabButton}"
Checked="TabEsporta_Checked"/> Checked="TabEsporta_Checked"/>
<RadioButton x:Name="TabApprendimento"
Content="Apprendimento"
Tag="&#xE945;"
Style="{StaticResource VerticalTabButton}"
Checked="TabApprendimento_Checked"/>
<RadioButton x:Name="TabImpostazioni" <RadioButton x:Name="TabImpostazioni"
Content="Impostazioni" Content="Impostazioni"
Tag="&#xE713;" Tag="&#xE713;"
@@ -216,6 +222,9 @@
OpenExportFolderClicked="Export_OpenFolderClicked" OpenExportFolderClicked="Export_OpenFolderClicked"
OpenLastFileClicked="Export_OpenLastFileClicked"/> OpenLastFileClicked="Export_OpenLastFileClicked"/>
<!-- Apprendimento Panel -->
<controls:LearningControl x:Name="Learning" Visibility="Collapsed"/>
<!-- Storico Panel --> <!-- Storico Panel -->
<Grid x:Name="StatisticsPanel" Visibility="Collapsed" Background="{DynamicResource Brush.Bg}"> <Grid x:Name="StatisticsPanel" Visibility="Collapsed" Background="{DynamicResource Brush.Bg}">
<Grid.RowDefinitions> <Grid.RowDefinitions>
+37
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Text.Json; using System.Text.Json;
namespace AutoBidder.Ml namespace AutoBidder.Ml
@@ -117,6 +118,42 @@ namespace AutoBidder.Ml
return new[] { "g", "p:" + prod, "p:" + prod + "|h" + band + "|" + day }; return new[] { "g", "p:" + prod, "p:" + prod + "|h" + band + "|" + day };
} }
/// <summary>Una riga del profilo, leggibile: per mostrarlo.</summary>
public readonly record struct Row(string Product, int HourBand, bool Weekend, long Auctions, double WinnerBids, double CloseRatio);
/// <summary>
/// Le combinazioni esatte con almeno un'asta, dalla più numerosa. Solo le esatte:
/// le righe di prodotto e quella globale sono medie delle esatte, e mostrarle
/// insieme confonderebbe.
/// </summary>
public IReadOnlyList<Row> Rows(int max = 200)
{
var list = new List<Row>();
lock (_sync)
{
foreach (var kv in _cells)
{
// chiave esatta: "p:{prodotto}|h{fascia}|{fer|fest}"
var k = kv.Key;
if (!k.StartsWith("p:", StringComparison.Ordinal)) continue;
var h = k.LastIndexOf("|h", StringComparison.Ordinal);
if (h < 0) continue;
var product = k.Substring(2, h - 2);
var rest = k[(h + 2)..]; // "{fascia}|{fer|fest}"
var bar = rest.IndexOf('|');
if (bar < 0 || !int.TryParse(rest[..bar], out var band)) continue;
var weekend = rest[(bar + 1)..] == "fest";
var c = kv.Value;
list.Add(new Row(product, band, weekend, c.N,
c.NBids > 0 ? c.MeanBids : double.NaN,
c.NRatio > 0 ? c.MeanRatio : double.NaN));
}
}
return list.OrderByDescending(r => r.Auctions).Take(max).ToList();
}
// ── Persistenza ────────────────────────────────────────────────── // ── Persistenza ──────────────────────────────────────────────────
private sealed class Snapshot private sealed class Snapshot
+217
View File
@@ -0,0 +1,217 @@
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)
};
}
}
}
+117
View File
@@ -90,6 +90,123 @@ namespace AutoBidder.Ml
} }
} }
/// <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 ──────────────────────────────────────────────────────── // ── Avvio ────────────────────────────────────────────────────────
public static void Start(AppSettings settings) public static void Start(AppSettings settings)
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -233,7 +233,10 @@ namespace AutoBidder.Services
auction.LearnedUnansweredProbability = p; auction.LearnedUnansweredProbability = p;
auction.LearnedExpectedValue = ev; auction.LearnedExpectedValue = ev;
if (Ml.LearningService.IsReady(settings) && ev < 0) var pronto = Ml.LearningService.IsReady(settings);
Ml.LearningService.RecordDecision(auction.Name, state.Price, p, ev, blocked: pronto && ev < 0, ready: pronto);
if (pronto && ev < 0)
{ {
decision.ShouldBid = false; decision.ShouldBid = false;
decision.Reason = decision.Reason =
+13 -201
View File
@@ -1,8 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Text;
using AutoBidder.Ml; using AutoBidder.Ml;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
@@ -10,22 +7,16 @@ using Xunit.Abstractions;
namespace AutoBidder.Tests; namespace AutoBidder.Tests;
/// <summary> /// <summary>
/// Valuta il modello sui dossier veri, con una divisione onesta nel tempo: si impara dai /// Valuta il modello sui dossier veri. La logica sta in <see cref="LearningEvaluator"/>,
/// più vecchi e si giudica sui più recenti, che il modello non ha mai visto. Sul futuro, /// condivisa con la scheda dell'applicazione: qui si aggiunge solo l'attivazione da
/// non su ciò che ha già letto. /// variabili d'ambiente e la condizione minima.
/// ///
/// <para>Si attiva solo con le variabili d'ambiente, come la rigiocata:</para>
/// <list type="bullet"> /// <list type="bullet">
/// <item><c>AUTOBIDDER_ML_DIR</c> — cartella dei dossier;</item> /// <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_MAX</c> — quanti file al massimo (0 = tutti);</item>
/// <item><c>AUTOBIDDER_ML_OUT</c> — dove scrivere il rapporto.</item> /// <item><c>AUTOBIDDER_ML_OUT</c> — dove scrivere il rapporto;</item>
/// <item><c>AUTOBIDDER_ML_ME</c> — il nostro nome utente, per i dossier vecchi.</item>
/// </list> /// </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> /// </summary>
public class MlModelBacktest public class MlModelBacktest
{ {
@@ -33,8 +24,6 @@ public class MlModelBacktest
public MlModelBacktest(ITestOutputHelper output) => _output = output; public MlModelBacktest(ITestOutputHelper output) => _output = output;
private sealed record Row(double P, bool Unanswered, bool Mine, double Ev);
[Fact] [Fact]
public void Valuta_il_modello_sui_dossier_reali() public void Valuta_il_modello_sui_dossier_reali()
{ {
@@ -47,199 +36,22 @@ public class MlModelBacktest
} }
var max = int.TryParse(Environment.GetEnvironmentVariable("AUTOBIDDER_ML_MAX"), out var m) ? m : 0; 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 me = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_ME");
var sessions = new List<DossierTrainingReader.Session>(); var report = LearningEvaluator.Run(folder, max, me, _output.WriteLine);
foreach (var f in files) _output.WriteLine(report.Text);
{
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"); var outFile = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_OUT");
if (!string.IsNullOrWhiteSpace(outFile)) if (!string.IsNullOrWhiteSpace(outFile))
{ {
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outFile))!); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outFile))!);
File.WriteAllText(outFile, text); File.WriteAllText(outFile, report.Text);
} }
// La condizione minima perché il modello serva a qualcosa: nella cima della if (!report.Enough) return;
// 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}"); // La condizione minima perche' il modello serva a qualcosa: nella cima della
// classifica le puntate finali devono essere piu' frequenti che a caso.
Assert.True(report.Lift5 > 1.0, $"il modello non separa: sollevamento al 5% = {report.Lift5:N2}");
} }
} }