namespace AndriyCo.AI.Tcu.Model
{
using SkiaSharp;
// TorchSharpTraining/SalesPredictor.cs
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using TorchSharp;
using TorchSharp.Modules;
using static Google.Protobuf.Reflection.UninterpretedOption.Types;
using static System.Runtime.InteropServices.JavaScript.JSType;
using static TorchSharp.torch;
// SELECT
// DATEADD(DAY, -((DATEPART(WEEKDAY, dbo.ArchiveDocuments.DateOfApprove) + @@DATEFIRST + 5) % 7), dbo.ArchiveDocuments.DateOfApprove) AS WeekStartDate,
// dbo.ArchiveDocumentDetails.GoodsItemId,
// ROUND(SUM(dbo.ArchiveDocumentDetails.Quantity), 0) AS Quantity
//FROM
// dbo.ArchiveDocuments
//INNER JOIN
// dbo.ArchiveDocumentDetails ON dbo.ArchiveDocuments.DocumentId = dbo.ArchiveDocumentDetails.DocumentId
//GROUP BY
// DATEADD(DAY, -((DATEPART(WEEKDAY, dbo.ArchiveDocuments.DateOfApprove) + @@DATEFIRST + 5) % 7), dbo.ArchiveDocuments.DateOfApprove),
// dbo.ArchiveDocumentDetails.GoodsItemId
//ORDER BY
// WeekStartDate, GoodsItemId;
// Модель для прогнозування обсягу продажів
///
/// Основний клас для навчання, прогнозу та збереження моделі прогнозу продажів.
/// Використовує нейромережу SalesNet та дані SalesData для тренування з TorchSharp.
///
public class SalesByWeekPredictor
{
public static void SplitData(List data, out List trainSet, out List testSet, double testFraction = 0.2)
{
var rnd = new Random();
var shuffled = data.OrderBy(_ => rnd.Next()).ToList();
int testCount = (int)(data.Count * testFraction);
testSet = shuffled.Take(testCount).ToList();
trainSet = shuffled.Skip(testCount).ToList();
}
///
/// Видаляє викиди з набору даних продажів за правилами:
/// - дуже малі кількості (менше 10)
/// - значне відхилення від локального середнього (в 5 разів менше або більше)
///
public static IEnumerable FilterOutliers(IEnumerable rawData)
{
var cleaned = new List();
var sorted = rawData.OrderBy(x => x.DateOfMonday).ToList();
for (int i = 0; i < sorted.Count; i++)
{
var current = sorted[i];
if (current.Quantity < 10)
continue;
// Візьмемо сусідні значення: два попередні і сам поточний (макс. 3 елементи)
var neighbors = sorted
.Skip(Math.Max(0, i - 2))
.Take(5)
.Select(x => x.Quantity)
.ToList();
var avg = neighbors.Except(new List() { current.Quantity }).Average();
// Пропускаємо, якщо значення суттєво відрізняється від локального середнього
if (current.Quantity < 0.2 * avg || current.Quantity > 5 * avg)
continue;
cleaned.Add(current);
}
return cleaned;
}
private readonly ConcurrentDictionary _models = new();
private SalesNet GetModel(long goodsItemId)
{
return _models.GetOrAdd(goodsItemId, _ => new SalesNet()) as SalesNet;
}
public void TrainPerItem(List sales)
{
var grouped = sales.GroupBy(s => s.GoodsItemId);
Parallel.ForEach(grouped, group =>
{
var goodsItemId = group.Key;
var data = group.OrderBy(qr => qr.DateOfMonday).ToList();
Console.WriteLine($"[Train] Item {goodsItemId} — {data.Count} records");
var model = GetModel(goodsItemId);
TrainModel(model, data, epochs: 500, batchSize: 2048, patience: 30);
});
}
private void TrainModel(SalesNet model, List sales, int epochs, int batchSize, int patience)
{
var optimizer = optim.SGD(model.parameters(), 0.01);
var device = torch.CPU;
var xData = new List();
var yData = new List();
var inputProperties = SalesData.InputProperties.OrderBy(p => p.Name).ToArray();
foreach (var s in sales)
{
foreach (var p in inputProperties)
{
xData.Add(Convert.ToSingle(p.GetValue(s)));
}
yData.Add(s.LogQuantity);
}
var xAll = torch.tensor(xData.ToArray(), dtype: ScalarType.Float32).view(-1, SalesNet.InputSize).to(device);
var yAll = torch.tensor(yData.ToArray(), dtype: ScalarType.Float32).view(-1, 1).to(device);
int sampleCount = sales.Count;
int numBatches = (int)Math.Ceiling(sampleCount / (double)batchSize);
float bestLoss = float.MaxValue;
int epochsWithoutImprovement = 0;
var bestWeights = model.state_dict();
for (int epoch = 0; epoch < epochs; epoch++)
{
model.train();
float epochLoss = 0;
for (int i = 0; i < numBatches; i++)
{
int start = i * batchSize;
int end = Math.Min(start + batchSize, sampleCount);
var xBatch = xAll[start..end];
var yBatch = yAll[start..end];
var output = model.Forward(xBatch);
var loss = nn.functional.mse_loss(output, yBatch);
optimizer.zero_grad();
loss.backward();
optimizer.step();
epochLoss += loss.item();
}
float avgLoss = epochLoss / numBatches;
if (avgLoss < bestLoss - 1e-5)
{
bestLoss = avgLoss;
bestWeights = model.state_dict();
epochsWithoutImprovement = 0;
}
else
{
epochsWithoutImprovement++;
if (epochsWithoutImprovement >= patience)
{
break;
}
}
}
model.load_state_dict(bestWeights);
}
// Метод прогнозу сумарного попиту на заданий період для одного товару
///
/// Прогнозує загальну кількість продажів одного товару у вказаному діапазоні дат.
///
public float PredictPeriodQuantity(long goodsItemId, DateTime from, DateTime to)
{
float total = 0;
for (DateTime date = from.Date; date <= to.Date; date = date.AddDays(1))
{
var sample = new SalesData
{
GoodsItemId = goodsItemId,
DateOfMonday = date
};
total += PredictQuantity(sample);
}
return total;
}
public SalesByWeekPredictor()
{
}
private readonly SalesNet model = new SalesNet();
///
/// Повертає прогнозовану кількість продажів для одного екземпляра SalesData.
///
public float PredictLogQuantity(SalesData s)
{
//model.eval();
var inputValues = SalesData.InputProperties.Select(p => Convert.ToSingle(p.GetValue(s))).ToArray();
Tensor input = torch.tensor(inputValues, dtype: ScalarType.Float32).view(-1, SalesNet.InputSize);
var currentModel = GetModel(s.GoodsItemId);
currentModel.eval();
var output = currentModel.Forward(input);
return output[0].item();
}
public float PredictQuantity(SalesData salesData)
{
var logQuantity = PredictLogQuantity(salesData);
return MathF.Pow(10, logQuantity) - 1;
}
///
/// Зберігає ваги моделі до файлу.
///
public void SaveModel(string path)
{
model.save(path);
}
///
/// Завантажує ваги моделі з файлу.
///
public void LoadModel(string path)
{
model.load(path);
}
///
/// Автоматично визначає параметри тренування та запускає навчання на основі вхідних даних.
///
public void Train(List sales)
{
int estimatedEpochs = (int)Math.Clamp(Math.Log10(sales.Count) * 10, 10, 200);
int estimatedBatchSize = Math.Clamp(sales.Count / 100, 256, 8192);
int patience = Math.Clamp(estimatedBatchSize / 10, 5, 30);
//Train(sales, estimatedEpochs, estimatedBatchSize, patience);
Train(sales, epochs: 500, batchSize: 2048, patience: 30);
}
///
/// Основний метод навчання моделі на основі mini-batch SGD та early stopping.
///
private void Train(List sales, int epochs, int batchSize, int patience)
{
// Ініціалізуємо оптимізатор (стоходастичний градієнтний спуск) з коефіцієнтом навчання 0.01
var optimizer = optim.SGD(model.parameters(), 0.01);
// Визначаємо, який пристрій використовувати: GPU (CUDA), якщо доступний, інакше CPU
var device = torch.cuda.is_available() ? torch.CUDA : torch.CPU;
// Ініціалізуємо списки для зберігання ознак (xData) та міток (yData)
var xData = new List();
var yData = new List();
var inputProperties = SalesData.InputProperties.ToArray();
// Для кожного прикладу у вхідному наборі:
foreach (var s in sales)
{
// Витягуємо значення кожної ознаки, позначеної [Feature], і додаємо до xData
foreach (var p in inputProperties)
{
xData.Add(Convert.ToSingle(p.GetValue(s)));
}
// Додаємо цільову змінну (кількість продажів) до yData
yData.Add(s.LogQuantity);
}
// Перетворюємо xData у тензор з формою [кількість прикладів, кількість ознак]
var xAll = torch.tensor(xData.ToArray(), dtype: ScalarType.Float32).view(-1, SalesNet.InputSize).to(device);
// Перетворюємо yData у тензор з формою [кількість прикладів, 1]
var yAll = torch.tensor(yData.ToArray(), dtype: ScalarType.Float32).view(-1, 1).to(device);
int sampleCount = sales.Count;
int numBatches = (int)Math.Ceiling(sampleCount / (double)batchSize);
float bestLoss = float.MaxValue;
int epochsWithoutImprovement = 0;
var bestWeights = model.state_dict();
// Основний цикл по епохах (ітераціях навчання)
for (int epoch = 0; epoch < epochs; epoch++)
{
model.train();
float epochLoss = 0;
// Обробляємо кожну міні-партію (mini-batch)
for (int i = 0; i < numBatches; i++)
{
int start = i * batchSize;
int end = Math.Min(start + batchSize, sampleCount);
// Витягуємо поточну міні-партію ознак
var xBatch = xAll[start..end];
// Витягуємо поточну міні-партію міток
var yBatch = yAll[start..end];
// Проганяємо міні-партію через модель для отримання прогнозу
var output = model.Forward(xBatch);
// Обчислюємо середньоквадратичну помилку між прогнозом і фактичними значеннями
var loss = nn.functional.mse_loss(output, yBatch);
// Обнуляємо попередні градієнти
optimizer.zero_grad();
// Розраховуємо градієнти похибки відносно ваг
loss.backward();
// Оновлюємо ваги моделі за допомогою оптимізатора
optimizer.step();
epochLoss += loss.item();
}
float avgLoss = epochLoss / numBatches;
// Якщо поточна втрата краща за попередню — зберігаємо модель
if (avgLoss < bestLoss - 1e-5)
{
bestLoss = avgLoss;
bestWeights = model.state_dict();
epochsWithoutImprovement = 0;
}
else
{
epochsWithoutImprovement++;
// Якщо модель не покращується протягом patience епох — зупиняємо навчання
if (epochsWithoutImprovement >= patience)
{
break;
}
}
}
// Завантажуємо найкращі знайдені ваги (bestWeights) після завершення навчання
model.load_state_dict(bestWeights);
}
public void Evaluate(List testSet)
{
var sortedTestSet = testSet.OrderBy(qr => qr.GoodsItemId).ThenBy(qr => qr.DateOfMonday).ToList();
//model.eval();
var predictions = new List();
var targets = new List();
string results = "";
int maxResultsCount = 3000;
foreach (var s in sortedTestSet)
{
var predicted = PredictQuantity(s);
if (float.IsNaN(predicted) || float.IsInfinity(predicted))
{
Console.WriteLine($"[Warn] Invalid prediction for {s.GoodsItemId} at {s.DateOfMonday:yyyy-MM-dd}");
continue;
}
predictions.Add(predicted);
targets.Add(s.Quantity);
if (maxResultsCount > 0)
results += $"{s.DateOfMonday:yyyy-MM-dd}\t {s.GoodsItemId}\t {predicted:0}\t {s.Quantity:0}\n";
maxResultsCount--;
}
int n = predictions.Count;
double sumAbsError = 0, sumSquaredError = 0, sumTarget = 0, totalVar = 0;
for (int i = 0; i < n; i++)
{
double error = predictions[i] - targets[i];
sumAbsError += Math.Abs(error);
sumSquaredError += error * error;
sumTarget += targets[i];
}
double mae = sumAbsError / n;
double mse = sumSquaredError / n;
double rmse = Math.Sqrt(mse);
double meanTarget = sumTarget / n;
totalVar = targets.Sum(a => (a - meanTarget) * (a - meanTarget));
double r2 = 1 - (sumSquaredError / totalVar);
string result = $"[Eval] MAE: {mae:0.###}, MSE: {mse:0.###}, RMSE: {rmse:0.###}, R²: {r2:0.###}";
Console.WriteLine(result);
}
// Дані для тренування
///
/// Вхідна структура даних для тренування та прогнозування.
/// Містить ознаки, дату та кількість продажів.
///
public class SalesData
{
//[Feature]
public long GoodsItemId { get; set; } // Ідентифікатор товару
//[Feature(0)]
public float NormalizedGoodsItemId => GoodsItemId / 10000f * 10f; // Нормалізований ідентифікатор товару
public DateTime DateOfMonday { get; set; } // Дата продажу
[Label]
public float LogQuantity => MathF.Log10(1 + Quantity);
public float Quantity { get; set; } // Кількість проданих одиниць
[Feature(1)]
public float DayOfWeek
{
get
{
CultureInfo culture = CultureInfo.InvariantCulture;
Calendar calendar = culture.Calendar;
// ISO 8601 стиль: тиждень починається з понеділка, перший тиждень — той, що має 4+ днів у новому році
CalendarWeekRule weekRule = CalendarWeekRule.FirstFourDayWeek;
DayOfWeek firstDayOfWeek = System.DayOfWeek.Monday;
int weekNumber = calendar.GetWeekOfYear(DateOfMonday, weekRule, firstDayOfWeek);
return weekNumber / 52f; // Нормалізуємо до 0-1
}
}
[Feature(2)]
public float Month => DateOfMonday.Month / 12f;
[Feature(3)]
public float Year => (DateOfMonday.Year - 2000) / 50f; // якщо максимум 2050
///
/// Масив властивостей, позначених атрибутом [Feature], автоматично використовується в моделі.
///
public static PropertyInfo[] InputProperties => typeof(SalesData).GetProperties().Where(p => p.GetCustomAttribute() != null).OrderBy(qr => qr.GetCustomAttribute().Index).ToArray();
}
// Нейронна мережа: dynamic кількість входів → 256 нейронів → ReLU → 1 вихід (кількість)
///
/// Клас нейронної мережі SalesNet: виконує прогноз обсягу продажів на основі ознак
/// з класу SalesData. Архітектура: повнозв’язний шар → ReLU → повнозв’язний шар → вихід.
///
public class SalesNet : nn.Module
{
///
/// Перший повнозв’язний шар: приймає всі вхідні ознаки (InputSize), передає на 32 нейрони.
///
private readonly Linear linear1;
///
/// Функція активації ReLU (Rectified Linear Unit), додає нелінійність до моделі після першого шару.
///
private readonly ReLU relu;
private readonly ReLU relu1;
private readonly ReLU relu2;
private readonly ReLU relu3;
private readonly ReLU relu4;
///
/// Другий повнозв’язний шар: приймає 32 виходи з ReLU та повертає 1 значення — прогноз продажу.
///
private readonly Linear linear2;
private readonly Linear linear3;
private readonly Linear linear4;
private readonly Linear linear5;
private readonly Dropout dropout1;
private readonly Dropout dropout2;
private readonly Dropout dropout3;
///
/// Визначає кількість вхідних ознак на основі атрибутів [Feature] у класі SalesData.
///
public static int InputSize => SalesData.InputProperties.Length;
///
/// Конструктор SalesNet: створює всі шари нейронної мережі та реєструє їх у TorchSharp.
///
public SalesNet() : base("SalesNet")
{
// Створюємо перший повнозв’язний шар: приймає всі вхідні ознаки (InputSize)
// і передає сигнал до 256 нейронів у прихованому шарі.
linear1 = nn.Linear(InputSize, 256); // Перший шар нейронної мережі, який приймає вхідні дані та передає їх до 256 нейронів.
// ReLU — це функція активації, яка залишає лише додатні значення,
// додаючи нелінійність до моделі.
relu1 = nn.ReLU(); // Додаємо функцію активації ReLU після першого шару.
// Dropout — це регуляризація, яка випадково вимикає частину нейронів під час тренування,
// щоб уникнути перенавчання.
dropout1 = nn.Dropout(0.2); // Додаємо Dropout з ймовірністю 20% після першого шару.
// Другий повнозв’язний шар: приймає 256 значень з прихованого шару
// і повертає 128 значень для наступного шару.
linear2 = nn.Linear(256, 128); // Другий шар нейронної мережі, який зменшує кількість нейронів до 128.
relu2 = nn.ReLU(); // Додаємо функцію активації ReLU після другого шару.
dropout2 = nn.Dropout(0.2); // Додаємо Dropout з ймовірністю 20% після другого шару.
// Третій повнозв’язний шар: приймає 128 значень з прихованого шару
// і повертає 64 значення для наступного шару.
linear3 = nn.Linear(128, 64); // Третій шар нейронної мережі, який зменшує кількість нейронів до 64.
relu3 = nn.ReLU(); // Додаємо функцію активації ReLU після третього шару.
dropout3 = nn.Dropout(0.2); // Додаємо Dropout з ймовірністю 20% після третього шару.
// Четвертий повнозв’язний шар: приймає 64 значення з прихованого шару
// і повертає 32 значення для наступного шару.
linear4 = nn.Linear(64, 32); // Четвертий шар нейронної мережі, який зменшує кількість нейронів до 32.
relu4 = nn.ReLU(); // Додаємо функцію активації ReLU після четвертого шару.
// Останній повнозв’язний шар: приймає 32 значення з прихованого шару
// і повертає 1 значення — прогноз продажу.
linear5 = nn.Linear(32, 1); // Останній шар нейронної мережі, який повертає одне значення як результат.
RegisterComponents();
}
///
/// Метод прямого проходження (forward pass):
/// приймає матрицю ознак, проганяє її через мережу і повертає передбачене значення.
///
public Tensor Forward(Tensor input)
{
// Прямий прохід через нейронну мережу (forward pass)
// Цей метод приймає вхідний тензор, проганяє його через всі шари нейронної мережі
// і повертає передбачене значення.
var x = dropout1.forward(relu1.forward(linear1.call(input)));
// 1. Перший шар (linear1) приймає вхідні дані та передає їх через функцію активації ReLU (relu1).
// 2. Dropout (dropout1) застосовується для регуляризації, щоб уникнути перенавчання.
x = dropout2.forward(relu2.forward(linear2.call(x)));
// 3. Другий шар (linear2) приймає вихід з попереднього шару, проходить через ReLU (relu2).
// 4. Dropout (dropout2) знову застосовується для регуляризації.
x = dropout3.forward(relu3.forward(linear3.call(x)));
// 5. Третій шар (linear3) приймає вихід з другого шару, проходить через ReLU (relu3).
// 6. Dropout (dropout3) застосовується для регуляризації.
x = relu4.forward(linear4.call(x));
// 7. Четвертий шар (linear4) приймає вихід з третього шару та проходить через ReLU (relu4).
// Dropout тут не використовується, оскільки це передостанній шар.
x = linear5.call(x);
return x;
}
}
}
public class FeatureAttribute : Attribute
{
public int Index { get; set; }
public FeatureAttribute(int index)
{
Index = index;
}
}
public class LabelAttribute : Attribute
{
}
}