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 { } }