ModelMuse02/AI_Sales_Forecasting
0
1/**2 * Forecasting Module3 * Implements time-series forecasting with trend and seasonality decomposition4 * Uses a Prophet-like approach adapted for browser execution5 */6 7import { MonthlyAggregation, ForecastPoint, ForecastResult } from '../types';8 9/**10 * Simple linear regression11 */12function linearRegression(x: number[], y: number[]): { slope: number; intercept: number; r2: number } {13 const n = x.length;14 if (n === 0) return { slope: 0, intercept: 0, r2: 0 };15 16 const sumX = x.reduce((a, b) => a + b, 0);17 const sumY = y.reduce((a, b) => a + b, 0);18 const sumXY = x.reduce((acc, xi, i) => acc + xi * y[i], 0);19 const sumXX = x.reduce((acc, xi) => acc + xi * xi, 0);20 21 const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);22 const intercept = (sumY - slope * sumX) / n;23 24 // Calculate R²25 const meanY = sumY / n;26 const ssTotal = y.reduce((acc, yi) => acc + Math.pow(yi - meanY, 2), 0);27 const ssResidual = y.reduce((acc, yi, i) => acc + Math.pow(yi - (slope * x[i] + intercept), 2), 0);28 const r2 = ssTotal > 0 ? 1 - (ssResidual / ssTotal) : 0;29 30 return { slope, intercept, r2 };31}32 33/**34 * Extract seasonality pattern using monthly averages35 */36function extractSeasonality(data: MonthlyAggregation[]): { pattern: { [month: number]: number }; peakMonth: number; troughMonth: number } {37 const monthlyValues = new Map<number, number[]>();38 39 for (const d of data) {40 const month = d.date.getMonth();41 if (!monthlyValues.has(month)) {42 monthlyValues.set(month, []);43 }44 monthlyValues.get(month)!.push(d.totalRevenue);45 }46 47 const pattern: { [month: number]: number } = {};48 let peakMonth = 0;49 let troughMonth = 0;50 let maxFactor = -Infinity;51 let minFactor = Infinity;52 53 // Calculate overall mean54 const allValues = data.map(d => d.totalRevenue);55 const overallMean = allValues.reduce((a, b) => a + b, 0) / allValues.length;56 57 for (let month = 0; month < 12; month++) {58 const values = monthlyValues.get(month);59 if (values && values.length > 0) {60 const monthMean = values.reduce((a, b) => a + b, 0) / values.length;61 pattern[month] = overallMean > 0 ? monthMean / overallMean : 1;62 63 if (pattern[month] > maxFactor) {64 maxFactor = pattern[month];65 peakMonth = month;66 }67 if (pattern[month] < minFactor) {68 minFactor = pattern[month];69 troughMonth = month;70 }71 } else {72 pattern[month] = 1;73 }74 }75 76 return { pattern, peakMonth, troughMonth };77}78 79/**80 * Calculate forecast error metrics81 */82function calculateMetrics(actual: number[], predicted: number[]): { mae: number; rmse: number; mape: number } {83 if (actual.length === 0) return { mae: 0, rmse: 0, mape: 0 };84 85 let sumAbsError = 0;86 let sumSquaredError = 0;87 let sumPercentError = 0;88 let validCount = 0;89 90 for (let i = 0; i < actual.length; i++) {91 const error = Math.abs(actual[i] - predicted[i]);92 sumAbsError += error;93 sumSquaredError += error * error;94 95 if (actual[i] !== 0) {96 sumPercentError += error / actual[i];97 validCount++;98 }99 }100 101 return {102 mae: sumAbsError / actual.length,103 rmse: Math.sqrt(sumSquaredError / actual.length),104 mape: validCount > 0 ? (sumPercentError / validCount) * 100 : 0,105 };106}107 108/**109 * Generate confidence intervals based on prediction error110 */111function generateConfidenceInterval(112 predicted: number,113 errorStd: number,114 stepsAhead: number,115 confidenceLevel: number = 0.95116): { lower: number; upper: number } {117 // Z-score for 95% confidence118 const zScore = confidenceLevel === 0.95 ? 1.96 : 1.645;119 120 // Widen confidence interval as we forecast further121 const widthFactor = 1 + (stepsAhead * 0.1);122 const margin = zScore * errorStd * widthFactor;123 124 return {125 lower: Math.max(0, predicted - margin),126 upper: predicted + margin,127 };128}129 130/**131 * Prophet-like forecasting implementation132 * Decomposes time series into trend + seasonality + residual133 */134export function generateForecast(135 monthlyData: MonthlyAggregation[],136 forecastMonths: number = 12137): ForecastResult {138 if (monthlyData.length < 3) {139 // Not enough data for meaningful forecast140 return {141 historicalData: [],142 forecastData: [],143 combinedData: [],144 metrics: { mae: 0, rmse: 0, mape: 0, r2: 0 },145 trend: { direction: 'Stable', slope: 0, intercept: 0 },146 seasonality: { detected: false, pattern: {}, peakMonth: 0, troughMonth: 0 },147 forecastSummary: {148 nextMonthPrediction: 0,149 sixMonthPrediction: 0,150 yearEndPrediction: 0,151 expectedGrowth: 0,152 },153 };154 }155 156 // Prepare data157 const revenues = monthlyData.map(d => d.totalRevenue);158 159 // Train-test split (80-20)160 const splitIndex = Math.floor(monthlyData.length * 0.8);161 const trainData = monthlyData.slice(0, splitIndex);162 const testData = monthlyData.slice(splitIndex);163 164 // Use full data if test set is too small165 const effectiveTrainData = testData.length < 3 ? monthlyData : trainData;166 const effectiveTestData = testData.length < 3 ? [] : testData;167 168 // Extract trend using linear regression169 const trainRevenues = effectiveTrainData.map(d => d.totalRevenue);170 const trainIndices = effectiveTrainData.map((_, i) => i);171 const regression = linearRegression(trainIndices, trainRevenues);172 173 // Extract seasonality174 const seasonality = extractSeasonality(effectiveTrainData);175 const seasonalityDetected = Object.values(seasonality.pattern).some(176 v => Math.abs(v - 1) > 0.1177 );178 179 // Calculate residuals and error standard deviation180 const trainPredictions = trainIndices.map(i => {181 const trendValue = regression.slope * i + regression.intercept;182 const month = effectiveTrainData[i].date.getMonth();183 return trendValue * (seasonality.pattern[month] || 1);184 });185 186 const residuals = trainRevenues.map((actual, i) => actual - trainPredictions[i]);187 const errorStd = Math.sqrt(188 residuals.reduce((sum, r) => sum + r * r, 0) / residuals.length189 );190 191 // Evaluate on test set192 let testMetrics = { mae: 0, rmse: 0, mape: 0 };193 if (effectiveTestData.length > 0) {194 const testPredictions = effectiveTestData.map((d, i) => {195 const idx = splitIndex + i;196 const trendValue = regression.slope * idx + regression.intercept;197 const month = d.date.getMonth();198 return trendValue * (seasonality.pattern[month] || 1);199 });200 const testActuals = effectiveTestData.map(d => d.totalRevenue);201 testMetrics = calculateMetrics(testActuals, testPredictions);202 }203 204 // Generate historical data points205 const historicalData: ForecastPoint[] = monthlyData.map((d, i) => {206 const trendValue = regression.slope * i + regression.intercept;207 const month = d.date.getMonth();208 const predicted = trendValue * (seasonality.pattern[month] || 1);209 const ci = generateConfidenceInterval(predicted, errorStd, 0);210 211 return {212 date: d.date,213 dateStr: d.period,214 predicted,215 lowerBound: ci.lower,216 upperBound: ci.upper,217 isHistorical: true,218 actual: d.totalRevenue,219 };220 });221 222 // Generate forecast data points223 const forecastData: ForecastPoint[] = [];224 const lastDate = monthlyData[monthlyData.length - 1].date;225 const lastIndex = monthlyData.length - 1;226 227 for (let i = 1; i <= forecastMonths; i++) {228 const futureDate = new Date(lastDate);229 futureDate.setMonth(futureDate.getMonth() + i);230 231 const idx = lastIndex + i;232 const trendValue = regression.slope * idx + regression.intercept;233 const month = futureDate.getMonth();234 const predicted = Math.max(0, trendValue * (seasonality.pattern[month] || 1));235 const ci = generateConfidenceInterval(predicted, errorStd, i);236 237 const period = `${futureDate.getFullYear()}-${String(futureDate.getMonth() + 1).padStart(2, '0')}`;238 239 forecastData.push({240 date: futureDate,241 dateStr: period,242 predicted,243 lowerBound: ci.lower,244 upperBound: ci.upper,245 isHistorical: false,246 });247 }248 249 // Determine trend direction250 let trendDirection: 'Increasing' | 'Decreasing' | 'Stable';251 const monthlyGrowthRate = (regression.slope / (revenues.reduce((a, b) => a + b, 0) / revenues.length)) * 100;252 253 if (monthlyGrowthRate > 2) {254 trendDirection = 'Increasing';255 } else if (monthlyGrowthRate < -2) {256 trendDirection = 'Decreasing';257 } else {258 trendDirection = 'Stable';259 }260 261 // Calculate forecast summary262 const lastActualRevenue = revenues[revenues.length - 1];263 const nextMonthPrediction = forecastData[0]?.predicted || 0;264 const sixMonthPrediction = forecastData[5]?.predicted || forecastData[forecastData.length - 1]?.predicted || 0;265 const yearEndPrediction = forecastData[forecastData.length - 1]?.predicted || 0;266 267 const expectedGrowth = lastActualRevenue > 0268 ? ((yearEndPrediction - lastActualRevenue) / lastActualRevenue) * 100269 : 0;270 271 return {272 historicalData,273 forecastData,274 combinedData: [...historicalData, ...forecastData],275 metrics: {276 mae: testMetrics.mae,277 rmse: testMetrics.rmse,278 mape: testMetrics.mape,279 r2: regression.r2,280 },281 trend: {282 direction: trendDirection,283 slope: regression.slope,284 intercept: regression.intercept,285 },286 seasonality: {287 detected: seasonalityDetected,288 pattern: seasonality.pattern,289 peakMonth: seasonality.peakMonth,290 troughMonth: seasonality.troughMonth,291 },292 forecastSummary: {293 nextMonthPrediction,294 sixMonthPrediction,295 yearEndPrediction,296 expectedGrowth,297 },298 };299}300 301/**302 * Get month name from index303 */304export function getMonthName(monthIndex: number): string {305 const months = ['January', 'February', 'March', 'April', 'May', 'June',306 'July', 'August', 'September', 'October', 'November', 'December'];307 return months[monthIndex] || '';308}309 