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:
@@ -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=""/>
|
||||
</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. 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ì. 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. 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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@ namespace AutoBidder
|
||||
LoadStatistics();
|
||||
}
|
||||
|
||||
private void TabApprendimento_Checked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ShowPanel(Learning);
|
||||
Learning.Refresh();
|
||||
}
|
||||
|
||||
private void TabImpostazioni_Checked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
@@ -74,7 +80,8 @@ namespace AutoBidder
|
||||
{
|
||||
// Prevent NullReferenceException during initialization
|
||||
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;
|
||||
|
||||
// Hide all panels
|
||||
@@ -85,6 +92,7 @@ namespace AutoBidder
|
||||
StatisticsPanel.Visibility = Visibility.Collapsed;
|
||||
Export.Visibility = Visibility.Collapsed;
|
||||
Settings.Visibility = Visibility.Collapsed;
|
||||
Learning.Visibility = Visibility.Collapsed;
|
||||
|
||||
// Show selected panel
|
||||
if (panelToShow != null)
|
||||
|
||||
@@ -75,6 +75,12 @@
|
||||
Style="{StaticResource VerticalTabButton}"
|
||||
Checked="TabEsporta_Checked"/>
|
||||
|
||||
<RadioButton x:Name="TabApprendimento"
|
||||
Content="Apprendimento"
|
||||
Tag=""
|
||||
Style="{StaticResource VerticalTabButton}"
|
||||
Checked="TabApprendimento_Checked"/>
|
||||
|
||||
<RadioButton x:Name="TabImpostazioni"
|
||||
Content="Impostazioni"
|
||||
Tag=""
|
||||
@@ -216,6 +222,9 @@
|
||||
OpenExportFolderClicked="Export_OpenFolderClicked"
|
||||
OpenLastFileClicked="Export_OpenLastFileClicked"/>
|
||||
|
||||
<!-- Apprendimento Panel -->
|
||||
<controls:LearningControl x:Name="Learning" Visibility="Collapsed"/>
|
||||
|
||||
<!-- Storico Panel -->
|
||||
<Grid x:Name="StatisticsPanel" Visibility="Collapsed" Background="{DynamicResource Brush.Bg}">
|
||||
<Grid.RowDefinitions>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AutoBidder.Ml
|
||||
@@ -117,6 +118,42 @@ namespace AutoBidder.Ml
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
private sealed class Snapshot
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
public static void Start(AppSettings settings)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -233,7 +233,10 @@ namespace AutoBidder.Services
|
||||
auction.LearnedUnansweredProbability = p;
|
||||
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.Reason =
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutoBidder.Ml;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
@@ -10,22 +7,16 @@ using Xunit.Abstractions;
|
||||
namespace AutoBidder.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Valuta il modello sui dossier veri, con una divisione onesta nel tempo: si impara dai
|
||||
/// più vecchi e si giudica sui più recenti, che il modello non ha mai visto. Sul futuro,
|
||||
/// non su ciò che ha già letto.
|
||||
/// Valuta il modello sui dossier veri. La logica sta in <see cref="LearningEvaluator"/>,
|
||||
/// condivisa con la scheda dell'applicazione: qui si aggiunge solo l'attivazione da
|
||||
/// variabili d'ambiente e la condizione minima.
|
||||
///
|
||||
/// <para>Si attiva solo con le variabili d'ambiente, come la rigiocata:</para>
|
||||
/// <list type="bullet">
|
||||
/// <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_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>
|
||||
///
|
||||
/// <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>
|
||||
public class MlModelBacktest
|
||||
{
|
||||
@@ -33,8 +24,6 @@ public class MlModelBacktest
|
||||
|
||||
public MlModelBacktest(ITestOutputHelper output) => _output = output;
|
||||
|
||||
private sealed record Row(double P, bool Unanswered, bool Mine, double Ev);
|
||||
|
||||
[Fact]
|
||||
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;
|
||||
|
||||
// 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 sessions = new List<DossierTrainingReader.Session>();
|
||||
foreach (var f in files)
|
||||
{
|
||||
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 report = LearningEvaluator.Run(folder, max, me, _output.WriteLine);
|
||||
_output.WriteLine(report.Text);
|
||||
|
||||
var outFile = Environment.GetEnvironmentVariable("AUTOBIDDER_ML_OUT");
|
||||
if (!string.IsNullOrWhiteSpace(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
|
||||
// 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}");
|
||||
if (!report.Enough) return;
|
||||
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user