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();
|
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)
|
||||||
|
|||||||
@@ -75,6 +75,12 @@
|
|||||||
Style="{StaticResource VerticalTabButton}"
|
Style="{StaticResource VerticalTabButton}"
|
||||||
Checked="TabEsporta_Checked"/>
|
Checked="TabEsporta_Checked"/>
|
||||||
|
|
||||||
|
<RadioButton x:Name="TabApprendimento"
|
||||||
|
Content="Apprendimento"
|
||||||
|
Tag=""
|
||||||
|
Style="{StaticResource VerticalTabButton}"
|
||||||
|
Checked="TabApprendimento_Checked"/>
|
||||||
|
|
||||||
<RadioButton x:Name="TabImpostazioni"
|
<RadioButton x:Name="TabImpostazioni"
|
||||||
Content="Impostazioni"
|
Content="Impostazioni"
|
||||||
Tag=""
|
Tag=""
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────
|
// ── Avvio ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public static void Start(AppSettings settings)
|
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.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 =
|
||||||
|
|||||||
@@ -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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user