|
|
@@ -0,0 +1,1319 @@
|
|
|
+package com.dtm.salesforecast.service.impl;
|
|
|
+
|
|
|
+import com.dtm.salesforecast.domain.SalesForecastDailySales;
|
|
|
+import com.dtm.salesforecast.mapper.SalesForecastMapper;
|
|
|
+import com.dtm.salesforecast.service.ISalesForecastService;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.math.RoundingMode;
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.time.ZoneId;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Comparator;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.LinkedHashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Random;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class SalesForecastServiceImpl implements ISalesForecastService
|
|
|
+{
|
|
|
+ private static final int DEFAULT_LOOKBACK_DAYS = 730;
|
|
|
+
|
|
|
+ private static final int MIN_NEURAL_DAYS = 8;
|
|
|
+
|
|
|
+ private static final String MODEL_TYPE = "neural_network_mlp";
|
|
|
+
|
|
|
+ private static final List<String> FEATURE_NAMES = Arrays.asList(
|
|
|
+ "前1日销量",
|
|
|
+ "前7日销量",
|
|
|
+ "前14日销量",
|
|
|
+ "近7日平均销量",
|
|
|
+ "近14日平均销量",
|
|
|
+ "近7日销量波动",
|
|
|
+ "平均成交价",
|
|
|
+ "促销力度",
|
|
|
+ "退款率",
|
|
|
+ "星期周期-正弦",
|
|
|
+ "星期周期-余弦",
|
|
|
+ "长期时间趋势"
|
|
|
+ );
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private SalesForecastMapper salesForecastMapper;
|
|
|
+
|
|
|
+ private volatile Map<String, Object> lastTrendResults;
|
|
|
+
|
|
|
+ private volatile Map<String, Object> lastOverviewResults;
|
|
|
+
|
|
|
+ private volatile Map<String, Object> lastEffectResults;
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> analyzeTrend(Map<String, Object> params)
|
|
|
+ {
|
|
|
+ List<SalesForecastDailySales> rows = loadRows(params);
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
+ result.put("summary", buildSummary(rows));
|
|
|
+ result.put("categories", buildGroupedSeries(rows, "category"));
|
|
|
+ result.put("category_list", new ArrayList<String>(collectValues(rows, "category")));
|
|
|
+ result.put("category_skus", buildCategorySkus(rows));
|
|
|
+ result.put("data", buildGroupedSeries(rows, "sku"));
|
|
|
+ result.put("sku_list", new ArrayList<String>(collectValues(rows, "sku")));
|
|
|
+ result.put("trends", buildTrendData(buildDailySeries(rows)));
|
|
|
+ result.put("seasonality", buildSeasonality(buildDailySeries(rows)));
|
|
|
+ result.put("feature_importance", calculateFeatureImportance(buildDailySeries(rows), true));
|
|
|
+ lastTrendResults = result;
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> predictTrend(Map<String, Object> params)
|
|
|
+ {
|
|
|
+ int predictDays = clampInt(intParam(params, "predict_days", 30), 1, 180);
|
|
|
+ Controls controls = Controls.from(params);
|
|
|
+ List<SalesForecastDailySales> rows = loadRows(params);
|
|
|
+ Map<String, Object> analysis = analyzeTrend(params);
|
|
|
+
|
|
|
+ Map<String, Object> overall = forecast(buildDailySeries(rows), predictDays, controls, true);
|
|
|
+ Map<String, Object> featureImportance = asMap(overall.remove("feature_importance"));
|
|
|
+
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
+ result.put("summary", analysis.get("summary"));
|
|
|
+ result.put("overall_prediction", overall);
|
|
|
+ result.put("category_predictions", buildPredictionByGroup(rows, "category", predictDays, controls));
|
|
|
+ result.put("sku_predictions", buildPredictionByGroup(rows, "sku", predictDays, controls));
|
|
|
+ result.put("feature_importance", featureImportance);
|
|
|
+ result.put("predict_days", predictDays);
|
|
|
+ result.put("control_params", controls.toMap());
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> backtestTrend(Map<String, Object> params)
|
|
|
+ {
|
|
|
+ int window = clampInt(intParam(params, "window", 30), 1, 180);
|
|
|
+ Controls controls = Controls.from(params);
|
|
|
+ DailySeries daily = buildDailySeries(loadRows(params));
|
|
|
+ if (daily.quantities.size() < MIN_NEURAL_DAYS + 3)
|
|
|
+ {
|
|
|
+ throw new IllegalArgumentException("神经网络回测至少需要 " + (MIN_NEURAL_DAYS + 3) + " 个自然日的销售数据");
|
|
|
+ }
|
|
|
+
|
|
|
+ window = Math.min(window, daily.quantities.size() - MIN_NEURAL_DAYS);
|
|
|
+ DailySeries train = daily.slice(0, daily.quantities.size() - window);
|
|
|
+ DailySeries actual = daily.slice(daily.quantities.size() - window, daily.quantities.size());
|
|
|
+ FitResult fit = fitModel(train, true);
|
|
|
+ if (fit.model == null)
|
|
|
+ {
|
|
|
+ throw new IllegalArgumentException("训练数据不足,无法执行神经网络回测");
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Double> history = new ArrayList<Double>(train.quantities);
|
|
|
+ List<Double> prices = new ArrayList<Double>(train.avgPrices);
|
|
|
+ List<Double> promos = new ArrayList<Double>(train.promotions);
|
|
|
+ List<Double> refunds = new ArrayList<Double>(train.refundRates);
|
|
|
+ List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
|
|
|
+ for (int i = 0; i < actual.dates.size(); i++)
|
|
|
+ {
|
|
|
+ LocalDate date = actual.dates.get(i);
|
|
|
+ double predicted = Math.max(0, fit.model.predict(featureRow(history, date, prices, promos, refunds, history.size(), daily.quantities.size())) * controls.adjustment());
|
|
|
+ double real = actual.quantities.get(i);
|
|
|
+ double abs = Math.abs(real - predicted);
|
|
|
+ double rel = real > 0 ? abs / real * 100 : (predicted > 0 ? 100 : 0);
|
|
|
+ Map<String, Object> row = new LinkedHashMap<String, Object>();
|
|
|
+ row.put("date", date.toString());
|
|
|
+ row.put("actual", round(real, 2));
|
|
|
+ row.put("predicted", round(predicted, 2));
|
|
|
+ row.put("absError", round(abs, 2));
|
|
|
+ row.put("relativeError", round(rel, 2));
|
|
|
+ row.put("status", rel > 20 ? "异常" : "正常");
|
|
|
+ rows.add(row);
|
|
|
+
|
|
|
+ history.add(real);
|
|
|
+ prices.add(actual.avgPrices.get(i));
|
|
|
+ promos.add(actual.promotions.get(i));
|
|
|
+ refunds.add(actual.refundRates.get(i));
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> result = new LinkedHashMap<String, Object>();
|
|
|
+ result.put("window", window);
|
|
|
+ result.put("train_days", train.quantities.size());
|
|
|
+ result.put("rows", rows);
|
|
|
+ result.put("metrics", buildMetrics(rows));
|
|
|
+ result.put("control_params", controls.toMap());
|
|
|
+ result.put("model_type", MODEL_TYPE);
|
|
|
+ result.put("feature_importance", fit.importance);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> analyzeOverview(Map<String, Object> params)
|
|
|
+ {
|
|
|
+ List<SalesForecastDailySales> rows = loadRows(params);
|
|
|
+ DailySeries daily = buildDailySeries(rows);
|
|
|
+ Map<String, Object> result = new LinkedHashMap<String, Object>();
|
|
|
+ result.put("summary", buildSummary(rows));
|
|
|
+ result.put("daily_trend", buildTrendData(daily));
|
|
|
+ result.put("category_analysis", buildCategoryOverview(rows));
|
|
|
+ result.put("top_skus", buildTopSku(rows));
|
|
|
+ result.put("generated_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
|
|
|
+ lastOverviewResults = result;
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> analyzeEffect(Map<String, Object> params)
|
|
|
+ {
|
|
|
+ List<SalesForecastDailySales> rows = loadRows(params);
|
|
|
+ Map<String, Object> result = new LinkedHashMap<String, Object>();
|
|
|
+ result.put("summary", buildEffectSummary(rows));
|
|
|
+ result.put("category_analysis", buildCategoryOverview(rows));
|
|
|
+ result.put("promotion_trend", buildPromotionTrend(buildDailySeries(rows)));
|
|
|
+ result.put("sku_analysis", buildTopSku(rows));
|
|
|
+ lastEffectResults = result;
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> getLastTrendResults()
|
|
|
+ {
|
|
|
+ return lastTrendResults == null ? analyzeTrend(new HashMap<String, Object>()) : lastTrendResults;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> getLastOverviewResults()
|
|
|
+ {
|
|
|
+ return lastOverviewResults == null ? analyzeOverview(new HashMap<String, Object>()) : lastOverviewResults;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> getLastEffectResults()
|
|
|
+ {
|
|
|
+ return lastEffectResults == null ? analyzeEffect(new HashMap<String, Object>()) : lastEffectResults;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<SalesForecastDailySales> loadRows(Map<String, Object> params)
|
|
|
+ {
|
|
|
+ String startDate = strParam(params, "start_date", strParam(params, "date_start", strParam(params, "startDate", null)));
|
|
|
+ String endDate = strParam(params, "end_date", strParam(params, "date_end", strParam(params, "endDate", null)));
|
|
|
+ String sku = strParam(params, "sku", null);
|
|
|
+ String category = strParam(params, "category", null);
|
|
|
+ Integer lookbackDays = (isBlank(startDate) && isBlank(endDate)) ? DEFAULT_LOOKBACK_DAYS : null;
|
|
|
+ List<SalesForecastDailySales> rows = salesForecastMapper.selectDailySales(startDate, endDate, sku, category, lookbackDays);
|
|
|
+ if (rows == null || rows.isEmpty())
|
|
|
+ {
|
|
|
+ throw new IllegalArgumentException("数据库中没有找到符合条件的销售订单数据");
|
|
|
+ }
|
|
|
+ return rows;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildSummary(List<SalesForecastDailySales> rows)
|
|
|
+ {
|
|
|
+ Set<String> skus = new LinkedHashSet<String>();
|
|
|
+ Set<String> categories = new LinkedHashSet<String>();
|
|
|
+ long quantity = 0L;
|
|
|
+ double revenue = 0;
|
|
|
+ LocalDate min = null;
|
|
|
+ LocalDate max = null;
|
|
|
+ for (SalesForecastDailySales row : rows)
|
|
|
+ {
|
|
|
+ skus.add(blankToOther(row.getSku()));
|
|
|
+ categories.add(blankToOther(row.getCategory()));
|
|
|
+ quantity += longValue(row.getQuantity());
|
|
|
+ revenue += doubleValue(row.getRevenue());
|
|
|
+ LocalDate date = toLocalDate(row.getSaleDate());
|
|
|
+ if (date != null)
|
|
|
+ {
|
|
|
+ min = min == null || date.isBefore(min) ? date : min;
|
|
|
+ max = max == null || date.isAfter(max) ? date : max;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ long days = min == null || max == null ? 0 : java.time.temporal.ChronoUnit.DAYS.between(min, max) + 1;
|
|
|
+ Map<String, Object> summary = new LinkedHashMap<String, Object>();
|
|
|
+ summary.put("total_quantity", quantity);
|
|
|
+ summary.put("total_revenue", round(revenue, 2));
|
|
|
+ summary.put("avg_daily_quantity", days <= 0 ? 0 : round(quantity * 1.0 / days, 2));
|
|
|
+ summary.put("sku_count", skus.size());
|
|
|
+ summary.put("category_count", categories.size());
|
|
|
+ summary.put("date_range", Arrays.asList(min == null ? "" : min.toString(), max == null ? "" : max.toString()));
|
|
|
+ summary.put("data_source", "database");
|
|
|
+ return summary;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildGroupedSeries(List<SalesForecastDailySales> rows, String groupType)
|
|
|
+ {
|
|
|
+ Map<String, List<SalesForecastDailySales>> groups = groupRows(rows, groupType);
|
|
|
+ Map<String, Object> result = new LinkedHashMap<String, Object>();
|
|
|
+ for (Map.Entry<String, List<SalesForecastDailySales>> entry : groups.entrySet())
|
|
|
+ {
|
|
|
+ result.put(entry.getKey(), buildTrendData(buildDailySeries(entry.getValue())));
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildPredictionByGroup(List<SalesForecastDailySales> rows, String groupType, int days, Controls controls)
|
|
|
+ {
|
|
|
+ Map<String, Object> result = new LinkedHashMap<String, Object>();
|
|
|
+ for (Map.Entry<String, List<SalesForecastDailySales>> entry : groupRows(rows, groupType).entrySet())
|
|
|
+ {
|
|
|
+ Map<String, Object> prediction = forecast(buildDailySeries(entry.getValue()), days, controls, false);
|
|
|
+ prediction.remove("feature_importance");
|
|
|
+ result.put(entry.getKey(), prediction);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildTrendData(DailySeries daily)
|
|
|
+ {
|
|
|
+ Map<String, Object> data = new LinkedHashMap<String, Object>();
|
|
|
+ List<String> dates = new ArrayList<String>();
|
|
|
+ List<Double> quantity = new ArrayList<Double>();
|
|
|
+ List<Double> revenue = new ArrayList<Double>();
|
|
|
+ for (int i = 0; i < daily.dates.size(); i++)
|
|
|
+ {
|
|
|
+ dates.add(daily.dates.get(i).toString());
|
|
|
+ quantity.add(round(daily.quantities.get(i), 2));
|
|
|
+ revenue.add(round(daily.revenues.get(i), 2));
|
|
|
+ }
|
|
|
+ data.put("date_series", dates);
|
|
|
+ data.put("quantity_series", quantity);
|
|
|
+ data.put("revenue_series", revenue);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildSeasonality(DailySeries daily)
|
|
|
+ {
|
|
|
+ double[] sum = new double[7];
|
|
|
+ int[] count = new int[7];
|
|
|
+ for (int i = 0; i < daily.dates.size(); i++)
|
|
|
+ {
|
|
|
+ int idx = daily.dates.get(i).getDayOfWeek().getValue() - 1;
|
|
|
+ sum[idx] += daily.quantities.get(i);
|
|
|
+ count[idx]++;
|
|
|
+ }
|
|
|
+ List<String> labels = Arrays.asList("周一", "周二", "周三", "周四", "周五", "周六", "周日");
|
|
|
+ List<Double> values = new ArrayList<Double>();
|
|
|
+ for (int i = 0; i < 7; i++)
|
|
|
+ {
|
|
|
+ values.add(count[i] == 0 ? 0 : round(sum[i] / count[i], 2));
|
|
|
+ }
|
|
|
+ Map<String, Object> data = new LinkedHashMap<String, Object>();
|
|
|
+ data.put("labels", labels);
|
|
|
+ data.put("values", values);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> forecast(DailySeries daily, int predictDays, Controls controls, boolean importance)
|
|
|
+ {
|
|
|
+ FitResult fit = fitModel(daily, importance);
|
|
|
+ List<Double> quantities = new ArrayList<Double>(daily.quantities);
|
|
|
+ List<Double> prices = new ArrayList<Double>(daily.avgPrices);
|
|
|
+ List<Double> promos = new ArrayList<Double>(daily.promotions);
|
|
|
+ List<Double> refunds = new ArrayList<Double>(daily.refundRates);
|
|
|
+ double recentPrice = recent(prices, 14, 0);
|
|
|
+ double recentPromo = recent(promos, 14, 0);
|
|
|
+ double recentRefund = recent(refunds, 14, 0);
|
|
|
+ LocalDate lastDate = daily.dates.get(daily.dates.size() - 1);
|
|
|
+
|
|
|
+ List<String> dates = new ArrayList<String>();
|
|
|
+ List<Double> predictions = new ArrayList<Double>();
|
|
|
+ List<Double> revenues = new ArrayList<Double>();
|
|
|
+ List<Double> lower = new ArrayList<Double>();
|
|
|
+ List<Double> upper = new ArrayList<Double>();
|
|
|
+ double z = inverseNormal(0.5 + controls.confidence / 200.0);
|
|
|
+
|
|
|
+ for (int i = 1; i <= predictDays; i++)
|
|
|
+ {
|
|
|
+ LocalDate date = lastDate.plusDays(i);
|
|
|
+ double base = fit.model == null ? recent(quantities, Math.min(7, quantities.size()), 0)
|
|
|
+ : fit.model.predict(featureRow(quantities, date, prices, promos, refunds, quantities.size(), daily.quantities.size() + predictDays));
|
|
|
+ double predicted = Math.max(0, base * controls.adjustment());
|
|
|
+ double band = fit.residualStd > 0 ? z * fit.residualStd * Math.sqrt(i) : 0;
|
|
|
+ dates.add(date.toString());
|
|
|
+ predictions.add(round(predicted, 2));
|
|
|
+ revenues.add(round(predicted * recentPrice, 2));
|
|
|
+ lower.add(round(Math.max(0, predicted - band), 2));
|
|
|
+ upper.add(round(Math.max(0, predicted + band), 2));
|
|
|
+ quantities.add(predicted);
|
|
|
+ prices.add(recentPrice);
|
|
|
+ promos.add(recentPromo);
|
|
|
+ refunds.add(recentRefund);
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> result = new LinkedHashMap<String, Object>();
|
|
|
+ result.put("date_series", dates);
|
|
|
+ result.put("quantity_series", predictions);
|
|
|
+ result.put("revenue_series", revenues);
|
|
|
+ result.put("confidence_lower_series", lower);
|
|
|
+ result.put("confidence_upper_series", upper);
|
|
|
+ Map<String, Object> modelParams = controls.toMap();
|
|
|
+ modelParams.put("model_type", fit.model == null ? "insufficient_history_fallback" : MODEL_TYPE);
|
|
|
+ modelParams.put("training_days", daily.quantities.size());
|
|
|
+ modelParams.put("training_samples", intValue(fit.importance.get("training_samples")));
|
|
|
+ modelParams.put("validation_mae", fit.importance.get("validation_mae"));
|
|
|
+ modelParams.put("residual_std", round(fit.residualStd, 4));
|
|
|
+ result.put("model_params", modelParams);
|
|
|
+ result.put("feature_importance", fit.importance);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private FitResult fitModel(DailySeries daily, boolean calculateImportance)
|
|
|
+ {
|
|
|
+ Dataset dataset = buildDataset(daily);
|
|
|
+ double[][] corr = correlationMatrix(dataset.x, FEATURE_NAMES.size());
|
|
|
+ if (dataset.y.length < MIN_NEURAL_DAYS - 1)
|
|
|
+ {
|
|
|
+ return new FitResult(null, buildImportance(new double[FEATURE_NAMES.size()], corr, "insufficient_history", null, dataset.y.length), 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ int split = Math.max(4, (int) Math.floor(dataset.y.length * 0.8));
|
|
|
+ split = Math.min(split, dataset.y.length - 1);
|
|
|
+ NeuralNet validationModel = new NeuralNet(FEATURE_NAMES.size(), dataset.y.length >= 60 ? 16 : 10, 42);
|
|
|
+ validationModel.fit(slice(dataset.x, 0, split), slice(dataset.y, 0, split), 600);
|
|
|
+ double[] validationPredictions = validationModel.predict(slice(dataset.x, split, dataset.y.length));
|
|
|
+ double[] validationActual = slice(dataset.y, split, dataset.y.length);
|
|
|
+ double mae = meanAbsoluteError(validationActual, validationPredictions);
|
|
|
+ double residualStd = std(diff(validationActual, validationPredictions));
|
|
|
+ double[] rawImportance = calculateImportance ? permutationImportance(validationModel, slice(dataset.x, split, dataset.y.length), validationActual, mae) : new double[FEATURE_NAMES.size()];
|
|
|
+
|
|
|
+ NeuralNet finalModel = new NeuralNet(FEATURE_NAMES.size(), dataset.y.length >= 60 ? 16 : 10, 42);
|
|
|
+ finalModel.fit(dataset.x, dataset.y, 700);
|
|
|
+ return new FitResult(finalModel, buildImportance(rawImportance, corr, "permutation_importance", mae, dataset.y.length), residualStd);
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> calculateFeatureImportance(DailySeries daily, boolean calculateImportance)
|
|
|
+ {
|
|
|
+ return fitModel(daily, calculateImportance).importance;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Dataset buildDataset(DailySeries daily)
|
|
|
+ {
|
|
|
+ List<double[]> rows = new ArrayList<double[]>();
|
|
|
+ List<Double> targets = new ArrayList<Double>();
|
|
|
+ for (int i = 1; i < daily.quantities.size(); i++)
|
|
|
+ {
|
|
|
+ rows.add(featureRow(daily.quantities.subList(0, i), daily.dates.get(i), daily.avgPrices.subList(0, i),
|
|
|
+ daily.promotions.subList(0, i), daily.refundRates.subList(0, i), i, daily.quantities.size()));
|
|
|
+ targets.add(daily.quantities.get(i));
|
|
|
+ }
|
|
|
+ double[][] x = rows.toArray(new double[rows.size()][]);
|
|
|
+ double[] y = new double[targets.size()];
|
|
|
+ for (int i = 0; i < targets.size(); i++)
|
|
|
+ {
|
|
|
+ y[i] = targets.get(i);
|
|
|
+ }
|
|
|
+ return new Dataset(x, y);
|
|
|
+ }
|
|
|
+
|
|
|
+ private double[] featureRow(List<Double> history, LocalDate date, List<Double> prices, List<Double> promos, List<Double> refunds, int trendIndex, int trendScale)
|
|
|
+ {
|
|
|
+ double angle = 2 * Math.PI * (date.getDayOfWeek().getValue() - 1) / 7.0;
|
|
|
+ return new double[] {
|
|
|
+ lag(history, 1),
|
|
|
+ lag(history, 7),
|
|
|
+ lag(history, 14),
|
|
|
+ recent(history, 7, 0),
|
|
|
+ recent(history, 14, 0),
|
|
|
+ recentStd(history, 7),
|
|
|
+ recent(prices, 7, 0),
|
|
|
+ recent(promos, 7, 0),
|
|
|
+ recent(refunds, 7, 0),
|
|
|
+ Math.sin(angle),
|
|
|
+ Math.cos(angle),
|
|
|
+ trendIndex * 1.0 / Math.max(1, trendScale)
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private DailySeries buildDailySeries(List<SalesForecastDailySales> rows)
|
|
|
+ {
|
|
|
+ Map<LocalDate, DailyPoint> map = new LinkedHashMap<LocalDate, DailyPoint>();
|
|
|
+ LocalDate min = null;
|
|
|
+ LocalDate max = null;
|
|
|
+ for (SalesForecastDailySales row : rows)
|
|
|
+ {
|
|
|
+ LocalDate date = toLocalDate(row.getSaleDate());
|
|
|
+ if (date == null)
|
|
|
+ {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ DailyPoint point = map.get(date);
|
|
|
+ if (point == null)
|
|
|
+ {
|
|
|
+ point = new DailyPoint();
|
|
|
+ map.put(date, point);
|
|
|
+ }
|
|
|
+ point.quantity += longValue(row.getQuantity());
|
|
|
+ point.revenue += doubleValue(row.getRevenue());
|
|
|
+ point.payable += doubleValue(row.getPayableAmount());
|
|
|
+ point.refund += doubleValue(row.getRefundAmount());
|
|
|
+ point.promotionTotal += doubleValue(row.getPromotionStrength());
|
|
|
+ point.promotionCount++;
|
|
|
+ min = min == null || date.isBefore(min) ? date : min;
|
|
|
+ max = max == null || date.isAfter(max) ? date : max;
|
|
|
+ }
|
|
|
+ if (min == null || max == null)
|
|
|
+ {
|
|
|
+ throw new IllegalArgumentException("销售数据中没有有效日期");
|
|
|
+ }
|
|
|
+
|
|
|
+ DailySeries daily = new DailySeries();
|
|
|
+ double lastPrice = 0;
|
|
|
+ for (LocalDate date = min; !date.isAfter(max); date = date.plusDays(1))
|
|
|
+ {
|
|
|
+ DailyPoint point = map.get(date);
|
|
|
+ if (point == null)
|
|
|
+ {
|
|
|
+ point = new DailyPoint();
|
|
|
+ }
|
|
|
+ double avgPrice = point.quantity > 0 ? point.revenue / point.quantity : lastPrice;
|
|
|
+ if (avgPrice > 0)
|
|
|
+ {
|
|
|
+ lastPrice = avgPrice;
|
|
|
+ }
|
|
|
+ daily.dates.add(date);
|
|
|
+ daily.quantities.add(point.quantity);
|
|
|
+ daily.revenues.add(point.revenue);
|
|
|
+ daily.avgPrices.add(avgPrice);
|
|
|
+ daily.promotions.add(point.promotionCount == 0 ? 0 : point.promotionTotal / point.promotionCount);
|
|
|
+ daily.refundRates.add(point.payable > 0 ? Math.max(0, Math.min(1, point.refund / point.payable)) : 0);
|
|
|
+ }
|
|
|
+ backfillPrices(daily.avgPrices);
|
|
|
+ return daily;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, List<SalesForecastDailySales>> groupRows(List<SalesForecastDailySales> rows, String groupType)
|
|
|
+ {
|
|
|
+ Map<String, List<SalesForecastDailySales>> groups = new LinkedHashMap<String, List<SalesForecastDailySales>>();
|
|
|
+ for (SalesForecastDailySales row : rows)
|
|
|
+ {
|
|
|
+ String key = "sku".equals(groupType) ? row.getSku() : row.getCategory();
|
|
|
+ key = blankToOther(key);
|
|
|
+ List<SalesForecastDailySales> list = groups.get(key);
|
|
|
+ if (list == null)
|
|
|
+ {
|
|
|
+ list = new ArrayList<SalesForecastDailySales>();
|
|
|
+ groups.put(key, list);
|
|
|
+ }
|
|
|
+ list.add(row);
|
|
|
+ }
|
|
|
+ return groups;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Set<String> collectValues(List<SalesForecastDailySales> rows, String type)
|
|
|
+ {
|
|
|
+ Set<String> values = new LinkedHashSet<String>();
|
|
|
+ for (SalesForecastDailySales row : rows)
|
|
|
+ {
|
|
|
+ values.add(blankToOther("sku".equals(type) ? row.getSku() : row.getCategory()));
|
|
|
+ }
|
|
|
+ return values;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildCategorySkus(List<SalesForecastDailySales> rows)
|
|
|
+ {
|
|
|
+ Map<String, Set<String>> temp = new LinkedHashMap<String, Set<String>>();
|
|
|
+ for (SalesForecastDailySales row : rows)
|
|
|
+ {
|
|
|
+ String category = blankToOther(row.getCategory());
|
|
|
+ if (!temp.containsKey(category))
|
|
|
+ {
|
|
|
+ temp.put(category, new LinkedHashSet<String>());
|
|
|
+ }
|
|
|
+ temp.get(category).add(blankToOther(row.getSku()));
|
|
|
+ }
|
|
|
+ Map<String, Object> result = new LinkedHashMap<String, Object>();
|
|
|
+ for (Map.Entry<String, Set<String>> entry : temp.entrySet())
|
|
|
+ {
|
|
|
+ result.put(entry.getKey(), new ArrayList<String>(entry.getValue()));
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<Map<String, Object>> buildCategoryOverview(List<SalesForecastDailySales> rows)
|
|
|
+ {
|
|
|
+ List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
|
|
|
+ for (Map.Entry<String, List<SalesForecastDailySales>> entry : groupRows(rows, "category").entrySet())
|
|
|
+ {
|
|
|
+ result.add(buildAggregateRow(entry.getKey(), entry.getValue()));
|
|
|
+ }
|
|
|
+ Collections.sort(result, aggregateComparator());
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<Map<String, Object>> buildTopSku(List<SalesForecastDailySales> rows)
|
|
|
+ {
|
|
|
+ List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
|
|
|
+ for (Map.Entry<String, List<SalesForecastDailySales>> entry : groupRows(rows, "sku").entrySet())
|
|
|
+ {
|
|
|
+ result.add(buildAggregateRow(entry.getKey(), entry.getValue()));
|
|
|
+ }
|
|
|
+ Collections.sort(result, aggregateComparator());
|
|
|
+ return result.size() > 20 ? result.subList(0, 20) : result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildAggregateRow(String name, List<SalesForecastDailySales> rows)
|
|
|
+ {
|
|
|
+ long quantity = 0;
|
|
|
+ double revenue = 0;
|
|
|
+ for (SalesForecastDailySales row : rows)
|
|
|
+ {
|
|
|
+ quantity += longValue(row.getQuantity());
|
|
|
+ revenue += doubleValue(row.getRevenue());
|
|
|
+ }
|
|
|
+ Map<String, Object> data = new LinkedHashMap<String, Object>();
|
|
|
+ data.put("name", name);
|
|
|
+ data.put("quantity", quantity);
|
|
|
+ data.put("revenue", round(revenue, 2));
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildEffectSummary(List<SalesForecastDailySales> rows)
|
|
|
+ {
|
|
|
+ double avgPromo = 0;
|
|
|
+ int count = 0;
|
|
|
+ double revenue = 0;
|
|
|
+ long quantity = 0;
|
|
|
+ for (SalesForecastDailySales row : rows)
|
|
|
+ {
|
|
|
+ avgPromo += doubleValue(row.getPromotionStrength());
|
|
|
+ revenue += doubleValue(row.getRevenue());
|
|
|
+ quantity += longValue(row.getQuantity());
|
|
|
+ count++;
|
|
|
+ }
|
|
|
+ Map<String, Object> summary = new LinkedHashMap<String, Object>();
|
|
|
+ summary.put("avg_promotion_strength", count == 0 ? 0 : round(avgPromo / count * 100, 2));
|
|
|
+ summary.put("total_quantity", quantity);
|
|
|
+ summary.put("total_revenue", round(revenue, 2));
|
|
|
+ summary.put("effect_score", round(Math.min(100, Math.max(0, (count == 0 ? 0 : avgPromo / count) * 60 + Math.log10(Math.max(1, quantity)) * 10)), 2));
|
|
|
+ return summary;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildPromotionTrend(DailySeries daily)
|
|
|
+ {
|
|
|
+ Map<String, Object> data = new LinkedHashMap<String, Object>();
|
|
|
+ List<String> dates = new ArrayList<String>();
|
|
|
+ List<Double> values = new ArrayList<Double>();
|
|
|
+ for (int i = 0; i < daily.dates.size(); i++)
|
|
|
+ {
|
|
|
+ dates.add(daily.dates.get(i).toString());
|
|
|
+ values.add(round(daily.promotions.get(i) * 100, 2));
|
|
|
+ }
|
|
|
+ data.put("date_series", dates);
|
|
|
+ data.put("promotion_series", values);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildMetrics(List<Map<String, Object>> rows)
|
|
|
+ {
|
|
|
+ List<Double> actual = new ArrayList<Double>();
|
|
|
+ List<Double> predicted = new ArrayList<Double>();
|
|
|
+ for (Map<String, Object> row : rows)
|
|
|
+ {
|
|
|
+ actual.add(doubleParam(row, "actual", 0));
|
|
|
+ predicted.add(doubleParam(row, "predicted", 0));
|
|
|
+ }
|
|
|
+ double mae = 0;
|
|
|
+ double rmse = 0;
|
|
|
+ double mape = 0;
|
|
|
+ int nonZero = 0;
|
|
|
+ double mean = mean(actual);
|
|
|
+ double ssRes = 0;
|
|
|
+ double ssTot = 0;
|
|
|
+ for (int i = 0; i < actual.size(); i++)
|
|
|
+ {
|
|
|
+ double err = Math.abs(actual.get(i) - predicted.get(i));
|
|
|
+ mae += err;
|
|
|
+ rmse += err * err;
|
|
|
+ ssRes += Math.pow(actual.get(i) - predicted.get(i), 2);
|
|
|
+ ssTot += Math.pow(actual.get(i) - mean, 2);
|
|
|
+ if (actual.get(i) > 0)
|
|
|
+ {
|
|
|
+ mape += err / actual.get(i) * 100;
|
|
|
+ nonZero++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ int n = Math.max(1, actual.size());
|
|
|
+ Map<String, Object> metrics = new LinkedHashMap<String, Object>();
|
|
|
+ metrics.put("mape", round(nonZero == 0 ? 0 : mape / nonZero, 2));
|
|
|
+ metrics.put("mae", round(mae / n, 2));
|
|
|
+ metrics.put("rmse", round(Math.sqrt(rmse / n), 2));
|
|
|
+ metrics.put("r2", round(ssTot > 0 ? 1 - ssRes / ssTot : 0, 4));
|
|
|
+ return metrics;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildImportance(double[] raw, double[][] corr, String method, Double mae, int samples)
|
|
|
+ {
|
|
|
+ double sum = 0;
|
|
|
+ for (double value : raw)
|
|
|
+ {
|
|
|
+ sum += Math.max(0, value);
|
|
|
+ }
|
|
|
+ List<Double> percentages = new ArrayList<Double>();
|
|
|
+ List<Double> rawValues = new ArrayList<Double>();
|
|
|
+ for (double value : raw)
|
|
|
+ {
|
|
|
+ rawValues.add(round(Math.max(0, value), 6));
|
|
|
+ percentages.add(sum > 0 ? round(Math.max(0, value) / sum * 100, 2) : 0);
|
|
|
+ }
|
|
|
+ List<List<Double>> matrix = new ArrayList<List<Double>>();
|
|
|
+ for (double[] line : corr)
|
|
|
+ {
|
|
|
+ List<Double> row = new ArrayList<Double>();
|
|
|
+ for (double value : line)
|
|
|
+ {
|
|
|
+ row.add(round(value, 4));
|
|
|
+ }
|
|
|
+ matrix.add(row);
|
|
|
+ }
|
|
|
+ Map<String, Object> importance = new LinkedHashMap<String, Object>();
|
|
|
+ importance.put("features", FEATURE_NAMES);
|
|
|
+ importance.put("importance", percentages);
|
|
|
+ importance.put("raw_importance", rawValues);
|
|
|
+ importance.put("correlation_matrix", matrix);
|
|
|
+ importance.put("method", method);
|
|
|
+ importance.put("validation_mae", mae == null ? null : round(mae, 4));
|
|
|
+ importance.put("training_samples", samples);
|
|
|
+ return importance;
|
|
|
+ }
|
|
|
+
|
|
|
+ private double[] permutationImportance(NeuralNet model, double[][] x, double[] y, double baselineMae)
|
|
|
+ {
|
|
|
+ double[] result = new double[FEATURE_NAMES.size()];
|
|
|
+ if (x.length < 2)
|
|
|
+ {
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ Random random = new Random(42);
|
|
|
+ for (int feature = 0; feature < FEATURE_NAMES.size(); feature++)
|
|
|
+ {
|
|
|
+ double total = 0;
|
|
|
+ for (int repeat = 0; repeat < 6; repeat++)
|
|
|
+ {
|
|
|
+ double[][] shuffled = copyMatrix(x);
|
|
|
+ for (int i = shuffled.length - 1; i > 0; i--)
|
|
|
+ {
|
|
|
+ int j = random.nextInt(i + 1);
|
|
|
+ double tmp = shuffled[i][feature];
|
|
|
+ shuffled[i][feature] = shuffled[j][feature];
|
|
|
+ shuffled[j][feature] = tmp;
|
|
|
+ }
|
|
|
+ total += Math.max(0, meanAbsoluteError(y, model.predict(shuffled)) - baselineMae);
|
|
|
+ }
|
|
|
+ result[feature] = total / 6.0;
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static class NeuralNet
|
|
|
+ {
|
|
|
+ private final int input;
|
|
|
+
|
|
|
+ private final int hidden;
|
|
|
+
|
|
|
+ private final double[][] w1;
|
|
|
+
|
|
|
+ private final double[] b1;
|
|
|
+
|
|
|
+ private final double[] w2;
|
|
|
+
|
|
|
+ private double b2;
|
|
|
+
|
|
|
+ private double[] meanX;
|
|
|
+
|
|
|
+ private double[] stdX;
|
|
|
+
|
|
|
+ private double meanY;
|
|
|
+
|
|
|
+ private double stdY;
|
|
|
+
|
|
|
+ NeuralNet(int input, int hidden, long seed)
|
|
|
+ {
|
|
|
+ this.input = input;
|
|
|
+ this.hidden = hidden;
|
|
|
+ this.w1 = new double[input][hidden];
|
|
|
+ this.b1 = new double[hidden];
|
|
|
+ this.w2 = new double[hidden];
|
|
|
+ Random random = new Random(seed);
|
|
|
+ for (int i = 0; i < input; i++)
|
|
|
+ {
|
|
|
+ for (int j = 0; j < hidden; j++)
|
|
|
+ {
|
|
|
+ w1[i][j] = (random.nextDouble() - 0.5) * 0.2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (int j = 0; j < hidden; j++)
|
|
|
+ {
|
|
|
+ w2[j] = (random.nextDouble() - 0.5) * 0.2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ void fit(double[][] x, double[] y, int epochs)
|
|
|
+ {
|
|
|
+ if (x.length == 0)
|
|
|
+ {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ fitScalers(x, y);
|
|
|
+ double learningRate = 0.01;
|
|
|
+ for (int epoch = 0; epoch < epochs; epoch++)
|
|
|
+ {
|
|
|
+ for (int row = 0; row < x.length; row++)
|
|
|
+ {
|
|
|
+ double[] sx = scaleX(x[row]);
|
|
|
+ double target = (y[row] - meanY) / stdY;
|
|
|
+ double[] hiddenValues = new double[hidden];
|
|
|
+ for (int j = 0; j < hidden; j++)
|
|
|
+ {
|
|
|
+ double z = b1[j];
|
|
|
+ for (int i = 0; i < input; i++)
|
|
|
+ {
|
|
|
+ z += sx[i] * w1[i][j];
|
|
|
+ }
|
|
|
+ hiddenValues[j] = Math.max(0, z);
|
|
|
+ }
|
|
|
+ double output = b2;
|
|
|
+ for (int j = 0; j < hidden; j++)
|
|
|
+ {
|
|
|
+ output += hiddenValues[j] * w2[j];
|
|
|
+ }
|
|
|
+ double error = output - target;
|
|
|
+ for (int j = 0; j < hidden; j++)
|
|
|
+ {
|
|
|
+ double gradW2 = error * hiddenValues[j] + 0.001 * w2[j];
|
|
|
+ double hiddenGrad = hiddenValues[j] > 0 ? error * w2[j] : 0;
|
|
|
+ w2[j] -= learningRate * gradW2;
|
|
|
+ b1[j] -= learningRate * hiddenGrad;
|
|
|
+ for (int i = 0; i < input; i++)
|
|
|
+ {
|
|
|
+ w1[i][j] -= learningRate * (hiddenGrad * sx[i] + 0.001 * w1[i][j]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ b2 -= learningRate * error;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ double predict(double[] x)
|
|
|
+ {
|
|
|
+ double[] sx = scaleX(x);
|
|
|
+ double output = b2;
|
|
|
+ for (int j = 0; j < hidden; j++)
|
|
|
+ {
|
|
|
+ double z = b1[j];
|
|
|
+ for (int i = 0; i < input; i++)
|
|
|
+ {
|
|
|
+ z += sx[i] * w1[i][j];
|
|
|
+ }
|
|
|
+ output += Math.max(0, z) * w2[j];
|
|
|
+ }
|
|
|
+ return Math.max(0, output * stdY + meanY);
|
|
|
+ }
|
|
|
+
|
|
|
+ double[] predict(double[][] x)
|
|
|
+ {
|
|
|
+ double[] result = new double[x.length];
|
|
|
+ for (int i = 0; i < x.length; i++)
|
|
|
+ {
|
|
|
+ result[i] = predict(x[i]);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void fitScalers(double[][] x, double[] y)
|
|
|
+ {
|
|
|
+ meanX = new double[input];
|
|
|
+ stdX = new double[input];
|
|
|
+ for (int i = 0; i < input; i++)
|
|
|
+ {
|
|
|
+ for (double[] row : x)
|
|
|
+ {
|
|
|
+ meanX[i] += row[i];
|
|
|
+ }
|
|
|
+ meanX[i] /= x.length;
|
|
|
+ for (double[] row : x)
|
|
|
+ {
|
|
|
+ stdX[i] += Math.pow(row[i] - meanX[i], 2);
|
|
|
+ }
|
|
|
+ stdX[i] = Math.sqrt(stdX[i] / x.length);
|
|
|
+ if (stdX[i] == 0)
|
|
|
+ {
|
|
|
+ stdX[i] = 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (double value : y)
|
|
|
+ {
|
|
|
+ meanY += value;
|
|
|
+ }
|
|
|
+ meanY /= y.length;
|
|
|
+ for (double value : y)
|
|
|
+ {
|
|
|
+ stdY += Math.pow(value - meanY, 2);
|
|
|
+ }
|
|
|
+ stdY = Math.sqrt(stdY / y.length);
|
|
|
+ if (stdY == 0)
|
|
|
+ {
|
|
|
+ stdY = 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private double[] scaleX(double[] x)
|
|
|
+ {
|
|
|
+ double[] scaled = new double[input];
|
|
|
+ for (int i = 0; i < input; i++)
|
|
|
+ {
|
|
|
+ scaled[i] = (x[i] - meanX[i]) / stdX[i];
|
|
|
+ }
|
|
|
+ return scaled;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static class DailySeries
|
|
|
+ {
|
|
|
+ private final List<LocalDate> dates = new ArrayList<LocalDate>();
|
|
|
+ private final List<Double> quantities = new ArrayList<Double>();
|
|
|
+ private final List<Double> revenues = new ArrayList<Double>();
|
|
|
+ private final List<Double> avgPrices = new ArrayList<Double>();
|
|
|
+ private final List<Double> promotions = new ArrayList<Double>();
|
|
|
+ private final List<Double> refundRates = new ArrayList<Double>();
|
|
|
+
|
|
|
+ DailySeries slice(int start, int end)
|
|
|
+ {
|
|
|
+ DailySeries result = new DailySeries();
|
|
|
+ result.dates.addAll(dates.subList(start, end));
|
|
|
+ result.quantities.addAll(quantities.subList(start, end));
|
|
|
+ result.revenues.addAll(revenues.subList(start, end));
|
|
|
+ result.avgPrices.addAll(avgPrices.subList(start, end));
|
|
|
+ result.promotions.addAll(promotions.subList(start, end));
|
|
|
+ result.refundRates.addAll(refundRates.subList(start, end));
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static class DailyPoint
|
|
|
+ {
|
|
|
+ private double quantity;
|
|
|
+ private double revenue;
|
|
|
+ private double payable;
|
|
|
+ private double refund;
|
|
|
+ private double promotionTotal;
|
|
|
+ private int promotionCount;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static class Controls
|
|
|
+ {
|
|
|
+ private double growth;
|
|
|
+ private double season;
|
|
|
+ private double promo;
|
|
|
+ private double confidence;
|
|
|
+
|
|
|
+ static Controls from(Map<String, Object> params)
|
|
|
+ {
|
|
|
+ Controls controls = new Controls();
|
|
|
+ controls.growth = clamp(doubleParam(params, "growth", 1.0), 0.2, 3.0);
|
|
|
+ controls.season = clamp(doubleParam(params, "season", 1.0), 0.2, 3.0);
|
|
|
+ controls.promo = clamp(doubleParam(params, "promo", 1.0), 0.2, 3.0);
|
|
|
+ controls.confidence = clamp(doubleParam(params, "confidence", 90), 50, 99);
|
|
|
+ return controls;
|
|
|
+ }
|
|
|
+
|
|
|
+ double adjustment()
|
|
|
+ {
|
|
|
+ return growth * season * promo;
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> toMap()
|
|
|
+ {
|
|
|
+ Map<String, Object> map = new LinkedHashMap<String, Object>();
|
|
|
+ map.put("growth", growth);
|
|
|
+ map.put("season", season);
|
|
|
+ map.put("promo", promo);
|
|
|
+ map.put("confidence", confidence);
|
|
|
+ return map;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static class Dataset
|
|
|
+ {
|
|
|
+ private final double[][] x;
|
|
|
+ private final double[] y;
|
|
|
+
|
|
|
+ Dataset(double[][] x, double[] y)
|
|
|
+ {
|
|
|
+ this.x = x;
|
|
|
+ this.y = y;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static class FitResult
|
|
|
+ {
|
|
|
+ private final NeuralNet model;
|
|
|
+ private final Map<String, Object> importance;
|
|
|
+ private final double residualStd;
|
|
|
+
|
|
|
+ FitResult(NeuralNet model, Map<String, Object> importance, double residualStd)
|
|
|
+ {
|
|
|
+ this.model = model;
|
|
|
+ this.importance = importance;
|
|
|
+ this.residualStd = residualStd;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Comparator<Map<String, Object>> aggregateComparator()
|
|
|
+ {
|
|
|
+ return new Comparator<Map<String, Object>>()
|
|
|
+ {
|
|
|
+ @Override
|
|
|
+ public int compare(Map<String, Object> a, Map<String, Object> b)
|
|
|
+ {
|
|
|
+ return Double.compare(doubleParam(b, "revenue", 0), doubleParam(a, "revenue", 0));
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double lag(List<Double> values, int days)
|
|
|
+ {
|
|
|
+ if (values == null || values.isEmpty())
|
|
|
+ {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ if (values.size() >= days)
|
|
|
+ {
|
|
|
+ return values.get(values.size() - days);
|
|
|
+ }
|
|
|
+ return mean(values);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double recent(List<Double> values, int size, double fallback)
|
|
|
+ {
|
|
|
+ if (values == null || values.isEmpty())
|
|
|
+ {
|
|
|
+ return fallback;
|
|
|
+ }
|
|
|
+ int start = Math.max(0, values.size() - size);
|
|
|
+ return mean(values.subList(start, values.size()));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double recentStd(List<Double> values, int size)
|
|
|
+ {
|
|
|
+ if (values == null || values.isEmpty())
|
|
|
+ {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ int start = Math.max(0, values.size() - size);
|
|
|
+ List<Double> sub = values.subList(start, values.size());
|
|
|
+ double avg = mean(sub);
|
|
|
+ double total = 0;
|
|
|
+ for (Double value : sub)
|
|
|
+ {
|
|
|
+ total += Math.pow(value - avg, 2);
|
|
|
+ }
|
|
|
+ return Math.sqrt(total / Math.max(1, sub.size()));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double mean(List<Double> values)
|
|
|
+ {
|
|
|
+ if (values == null || values.isEmpty())
|
|
|
+ {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ double total = 0;
|
|
|
+ for (Double value : values)
|
|
|
+ {
|
|
|
+ total += value == null ? 0 : value;
|
|
|
+ }
|
|
|
+ return total / values.size();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double std(double[] values)
|
|
|
+ {
|
|
|
+ if (values.length == 0)
|
|
|
+ {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ double avg = 0;
|
|
|
+ for (double value : values)
|
|
|
+ {
|
|
|
+ avg += value;
|
|
|
+ }
|
|
|
+ avg /= values.length;
|
|
|
+ double total = 0;
|
|
|
+ for (double value : values)
|
|
|
+ {
|
|
|
+ total += Math.pow(value - avg, 2);
|
|
|
+ }
|
|
|
+ return Math.sqrt(total / values.length);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double meanAbsoluteError(double[] actual, double[] predicted)
|
|
|
+ {
|
|
|
+ if (actual.length == 0)
|
|
|
+ {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ double total = 0;
|
|
|
+ for (int i = 0; i < actual.length; i++)
|
|
|
+ {
|
|
|
+ total += Math.abs(actual[i] - predicted[i]);
|
|
|
+ }
|
|
|
+ return total / actual.length;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double[] diff(double[] actual, double[] predicted)
|
|
|
+ {
|
|
|
+ double[] result = new double[actual.length];
|
|
|
+ for (int i = 0; i < actual.length; i++)
|
|
|
+ {
|
|
|
+ result[i] = actual[i] - predicted[i];
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double[][] correlationMatrix(double[][] x, int size)
|
|
|
+ {
|
|
|
+ double[][] matrix = new double[size][size];
|
|
|
+ for (int i = 0; i < size; i++)
|
|
|
+ {
|
|
|
+ for (int j = 0; j < size; j++)
|
|
|
+ {
|
|
|
+ matrix[i][j] = i == j ? 1 : Math.abs(correlation(column(x, i), column(x, j)));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return matrix;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double correlation(double[] a, double[] b)
|
|
|
+ {
|
|
|
+ if (a.length < 2)
|
|
|
+ {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ double avgA = 0;
|
|
|
+ double avgB = 0;
|
|
|
+ for (int i = 0; i < a.length; i++)
|
|
|
+ {
|
|
|
+ avgA += a[i];
|
|
|
+ avgB += b[i];
|
|
|
+ }
|
|
|
+ avgA /= a.length;
|
|
|
+ avgB /= b.length;
|
|
|
+ double numerator = 0;
|
|
|
+ double denA = 0;
|
|
|
+ double denB = 0;
|
|
|
+ for (int i = 0; i < a.length; i++)
|
|
|
+ {
|
|
|
+ numerator += (a[i] - avgA) * (b[i] - avgB);
|
|
|
+ denA += Math.pow(a[i] - avgA, 2);
|
|
|
+ denB += Math.pow(b[i] - avgB, 2);
|
|
|
+ }
|
|
|
+ double den = Math.sqrt(denA * denB);
|
|
|
+ return den == 0 ? 0 : numerator / den;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double[] column(double[][] x, int col)
|
|
|
+ {
|
|
|
+ double[] result = new double[x.length];
|
|
|
+ for (int i = 0; i < x.length; i++)
|
|
|
+ {
|
|
|
+ result[i] = x[i][col];
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double[][] slice(double[][] x, int start, int end)
|
|
|
+ {
|
|
|
+ double[][] result = new double[Math.max(0, end - start)][];
|
|
|
+ for (int i = start; i < end; i++)
|
|
|
+ {
|
|
|
+ result[i - start] = Arrays.copyOf(x[i], x[i].length);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double[] slice(double[] x, int start, int end)
|
|
|
+ {
|
|
|
+ return Arrays.copyOfRange(x, start, end);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double[][] copyMatrix(double[][] x)
|
|
|
+ {
|
|
|
+ double[][] result = new double[x.length][];
|
|
|
+ for (int i = 0; i < x.length; i++)
|
|
|
+ {
|
|
|
+ result[i] = Arrays.copyOf(x[i], x[i].length);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void backfillPrices(List<Double> prices)
|
|
|
+ {
|
|
|
+ double first = 0;
|
|
|
+ for (Double price : prices)
|
|
|
+ {
|
|
|
+ if (price != null && price > 0)
|
|
|
+ {
|
|
|
+ first = price;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ double last = first;
|
|
|
+ for (int i = 0; i < prices.size(); i++)
|
|
|
+ {
|
|
|
+ if (prices.get(i) == null || prices.get(i) <= 0)
|
|
|
+ {
|
|
|
+ prices.set(i, last);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ last = prices.get(i);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double inverseNormal(double p)
|
|
|
+ {
|
|
|
+ if (p <= 0 || p >= 1)
|
|
|
+ {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ double a1 = -39.6968302866538, a2 = 220.946098424521, a3 = -275.928510446969;
|
|
|
+ double a4 = 138.357751867269, a5 = -30.6647980661472, a6 = 2.50662827745924;
|
|
|
+ double b1 = -54.4760987982241, b2 = 161.585836858041, b3 = -155.698979859887;
|
|
|
+ double b4 = 66.8013118877197, b5 = -13.2806815528857;
|
|
|
+ double c1 = -0.00778489400243029, c2 = -0.322396458041136, c3 = -2.40075827716184;
|
|
|
+ double c4 = -2.54973253934373, c5 = 4.37466414146497, c6 = 2.93816398269878;
|
|
|
+ double d1 = 0.00778469570904146, d2 = 0.32246712907004, d3 = 2.445134137143, d4 = 3.75440866190742;
|
|
|
+ double plow = 0.02425;
|
|
|
+ double phigh = 1 - plow;
|
|
|
+ double q;
|
|
|
+ if (p < plow)
|
|
|
+ {
|
|
|
+ q = Math.sqrt(-2 * Math.log(p));
|
|
|
+ return (((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6)
|
|
|
+ / ((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
|
|
|
+ }
|
|
|
+ if (phigh < p)
|
|
|
+ {
|
|
|
+ q = Math.sqrt(-2 * Math.log(1 - p));
|
|
|
+ return -(((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6)
|
|
|
+ / ((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
|
|
|
+ }
|
|
|
+ q = p - 0.5;
|
|
|
+ double r = q * q;
|
|
|
+ return (((((a1 * r + a2) * r + a3) * r + a4) * r + a5) * r + a6) * q
|
|
|
+ / (((((b1 * r + b2) * r + b3) * r + b4) * r + b5) * r + 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static LocalDate toLocalDate(Date date)
|
|
|
+ {
|
|
|
+ if (date == null)
|
|
|
+ {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (date instanceof java.sql.Date)
|
|
|
+ {
|
|
|
+ return ((java.sql.Date) date).toLocalDate();
|
|
|
+ }
|
|
|
+ return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double doubleValue(BigDecimal value)
|
|
|
+ {
|
|
|
+ return value == null ? 0 : value.doubleValue();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static long longValue(Long value)
|
|
|
+ {
|
|
|
+ return value == null ? 0L : value;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String strParam(Map<String, Object> params, String key, String defaultValue)
|
|
|
+ {
|
|
|
+ if (params == null || params.get(key) == null)
|
|
|
+ {
|
|
|
+ return defaultValue;
|
|
|
+ }
|
|
|
+ String value = String.valueOf(params.get(key)).trim();
|
|
|
+ return value.isEmpty() ? defaultValue : value;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int intParam(Map<String, Object> params, String key, int defaultValue)
|
|
|
+ {
|
|
|
+ if (params == null || params.get(key) == null)
|
|
|
+ {
|
|
|
+ return defaultValue;
|
|
|
+ }
|
|
|
+ try
|
|
|
+ {
|
|
|
+ return Integer.parseInt(String.valueOf(params.get(key)));
|
|
|
+ }
|
|
|
+ catch (Exception ignored)
|
|
|
+ {
|
|
|
+ return defaultValue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int intValue(Object value)
|
|
|
+ {
|
|
|
+ if (value instanceof Number)
|
|
|
+ {
|
|
|
+ return ((Number) value).intValue();
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double doubleParam(Map<String, Object> params, String key, double defaultValue)
|
|
|
+ {
|
|
|
+ if (params == null || params.get(key) == null)
|
|
|
+ {
|
|
|
+ return defaultValue;
|
|
|
+ }
|
|
|
+ try
|
|
|
+ {
|
|
|
+ return Double.parseDouble(String.valueOf(params.get(key)));
|
|
|
+ }
|
|
|
+ catch (Exception ignored)
|
|
|
+ {
|
|
|
+ return defaultValue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int clampInt(int value, int min, int max)
|
|
|
+ {
|
|
|
+ return Math.max(min, Math.min(max, value));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double clamp(double value, double min, double max)
|
|
|
+ {
|
|
|
+ return Math.max(min, Math.min(max, value));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean isBlank(String value)
|
|
|
+ {
|
|
|
+ return value == null || value.trim().isEmpty();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String blankToOther(String value)
|
|
|
+ {
|
|
|
+ return isBlank(value) ? "其他" : value;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static double round(double value, int scale)
|
|
|
+ {
|
|
|
+ return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).doubleValue();
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private static Map<String, Object> asMap(Object value)
|
|
|
+ {
|
|
|
+ return value instanceof Map ? (Map<String, Object>) value : new LinkedHashMap<String, Object>();
|
|
|
+ }
|
|
|
+}
|