reconstruct
This commit is contained in:
parent
20002030ad
commit
ca8bded949
17 changed files with 1268 additions and 1110 deletions
File diff suppressed because one or more lines are too long
35
package.json
Normal file
35
package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "analytics-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"mathjs": "^14.6.0",
|
||||||
|
"swagger-ui-express": "^5.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^4.17.23",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/lodash": "^4.17.20",
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"jest": "^30.1.3",
|
||||||
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
"ts-jest": "^29.4.4",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
400
server.ts
400
server.ts
|
|
@ -6,30 +6,25 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import swaggerJsdoc from 'swagger-jsdoc';
|
import swaggerJsdoc from 'swagger-jsdoc';
|
||||||
import swaggerUi from 'swagger-ui-express';
|
import swaggerUi from 'swagger-ui-express';
|
||||||
import * as math from 'mathjs';
|
import cors from 'cors';
|
||||||
import * as _ from 'lodash';
|
|
||||||
import cors from 'cors'; // <-- 1. IMPORT THE CORS PACKAGE
|
|
||||||
|
|
||||||
// Assuming these files exist in the same directory
|
// Assuming these files exist in the same directory
|
||||||
// import { KMeans, KMeansOptions } from './kmeans';
|
// import { KMeans, KMeansOptions } from './kmeans';
|
||||||
// import { getWeekNumber, getSameWeekDayLastYear } from './time-helper';
|
// import { getWeekNumber, getSameWeekDayLastYear } from './time-helper';
|
||||||
// import { calculateLinearRegression, generateForecast, calculatePredictionIntervals, ForecastResult } from './prediction';
|
// import { calculateLinearRegression, generateForecast, calculatePredictionIntervals, ForecastResult } from './prediction';
|
||||||
import { SignalProcessor, SmoothingOptions, EdgeDetectionOptions } from './signal_processing_convolution';
|
import { SignalProcessor, SmoothingOptions, EdgeDetectionOptions } from './services/signal_processing_convolution';
|
||||||
import { TimeSeriesAnalyzer, ARIMAOptions } from './timeseries';
|
import { TimeSeriesAnalyzer, ARIMAOptions } from './services/timeseries';
|
||||||
import { AnalysisPipelines } from './analysis_pipelines';
|
import { AnalysisPipelines } from './services/analysis_pipelines';
|
||||||
import { convolve1D, convolve2D, ConvolutionKernels } from './convolution';
|
import { convolve1D, convolve2D, ConvolutionKernels } from './services/convolution';
|
||||||
|
import { DataSeries, DataMatrix, Condition, ApiResponse } from './types/index';
|
||||||
// Dummy interfaces/classes if the files are not present, to prevent compile errors
|
import { handleError, validateSeries, validateMatrix } from './services/analytics_engine';
|
||||||
interface KMeansOptions {}
|
import { ForecastResult } from './services/prediction';
|
||||||
class KMeans { constructor(p: any, n: any, o: any) {}; run = () => ({ clusters: [] }) }
|
import { analytics } from './services/analytics_engine';
|
||||||
const getWeekNumber = (d: string) => 1;
|
import { purchaseRate, liftValue, costRatio, grossMarginRate, averageSpendPerCustomer, purchaseIndex } from './services/retail_metrics';
|
||||||
const getSameWeekDayLastYear = (d: string) => new Date().toISOString();
|
import { RollingWindow } from './services/rolling_window';
|
||||||
interface ForecastResult {}
|
import { pivotTable, PivotOptions } from './services/pivot_table';
|
||||||
const calculateLinearRegression = (v: any) => ({slope: 1, intercept: 0});
|
|
||||||
const generateForecast = (m: any, l: any, p: any) => [];
|
|
||||||
const calculatePredictionIntervals = (v: any, m: any, f: any) => [];
|
|
||||||
|
|
||||||
|
|
||||||
|
// Initialize Express app
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(cors()); // <-- 2. ENABLE CORS FOR ALL ROUTES
|
app.use(cors()); // <-- 2. ENABLE CORS FOR ALL ROUTES
|
||||||
|
|
@ -56,301 +51,6 @@ const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
||||||
|
|
||||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// TYPE DEFINITIONS
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface DataSeries {
|
|
||||||
values: number[];
|
|
||||||
labels?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DataMatrix {
|
|
||||||
data: number[][];
|
|
||||||
columns?: string[];
|
|
||||||
rows?: string[];
|
|
||||||
}
|
|
||||||
interface Condition {
|
|
||||||
field: string;
|
|
||||||
operator: '>' | '<' | '=' | '>=' | '<=' | '!=';
|
|
||||||
value: number | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
|
||||||
success: boolean;
|
|
||||||
data?: T;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// HELPER FUNCTIONS
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
const handleError = (error: unknown): string => {
|
|
||||||
return error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
};
|
|
||||||
const validateSeries = (series: DataSeries): void => {
|
|
||||||
if (!series || !Array.isArray(series.values) || series.values.length === 0) {
|
|
||||||
throw new Error('Series must contain at least one value');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateMatrix = (matrix: DataMatrix): void => {
|
|
||||||
if (!matrix || !Array.isArray(matrix.data) || matrix.data.length === 0) {
|
|
||||||
throw new Error('Matrix must contain at least one row');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A helper class to provide a fluent API for rolling window calculations.
|
|
||||||
*/
|
|
||||||
class RollingWindow {
|
|
||||||
private windows: number[][];
|
|
||||||
|
|
||||||
constructor(windows: number[][]) {
|
|
||||||
this.windows = windows;
|
|
||||||
}
|
|
||||||
|
|
||||||
mean(): number[] {
|
|
||||||
return this.windows.map(window => Number(math.mean(window)));
|
|
||||||
}
|
|
||||||
|
|
||||||
sum(): number[] {
|
|
||||||
return this.windows.map(window => _.sum(window));
|
|
||||||
}
|
|
||||||
|
|
||||||
min(): number[] {
|
|
||||||
return this.windows.map(window => Math.min(...window));
|
|
||||||
}
|
|
||||||
|
|
||||||
max(): number[] {
|
|
||||||
return this.windows.map(window => Math.max(...window));
|
|
||||||
}
|
|
||||||
|
|
||||||
toArray(): number[][] {
|
|
||||||
return this.windows;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ANALYTICS ENGINE (Simplified)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
class AnalyticsEngine {
|
|
||||||
|
|
||||||
private applyConditions(series: DataSeries, conditions: Condition[] = []): number[] {
|
|
||||||
if (conditions.length === 0) return series.values;
|
|
||||||
return series.values; // TODO: Implement filtering
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic statistical functions
|
|
||||||
unique(series: DataSeries): number[] {
|
|
||||||
validateSeries(series);
|
|
||||||
return _.uniq(series.values);
|
|
||||||
}
|
|
||||||
|
|
||||||
mean(series: DataSeries, conditions: Condition[] = []): number {
|
|
||||||
validateSeries(series);
|
|
||||||
const filteredValues = this.applyConditions(series, conditions);
|
|
||||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
|
||||||
return Number(math.mean(filteredValues));
|
|
||||||
}
|
|
||||||
|
|
||||||
count(series: DataSeries, conditions: Condition[] = []): number {
|
|
||||||
validateSeries(series);
|
|
||||||
const filteredValues = this.applyConditions(series, conditions);
|
|
||||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
|
||||||
return filteredValues.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
variance(series: DataSeries, conditions: Condition[] = []): number {
|
|
||||||
validateSeries(series);
|
|
||||||
const filteredValues = this.applyConditions(series, conditions);
|
|
||||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
|
||||||
return Number(math.variance(filteredValues));
|
|
||||||
}
|
|
||||||
|
|
||||||
standardDeviation(series: DataSeries, conditions: Condition[] = []): number {
|
|
||||||
validateSeries(series);
|
|
||||||
const filteredValues = this.applyConditions(series, conditions);
|
|
||||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
|
||||||
return Number(math.std(filteredValues));
|
|
||||||
}
|
|
||||||
|
|
||||||
percentile(series: DataSeries, percent: number, ascending: boolean = true, conditions: Condition[] = []): number {
|
|
||||||
validateSeries(series);
|
|
||||||
const filteredValues = this.applyConditions(series, conditions);
|
|
||||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
|
||||||
|
|
||||||
const sorted = ascending ? _.sortBy(filteredValues) : _.sortBy(filteredValues).reverse();
|
|
||||||
const index = (percent / 100) * (sorted.length - 1);
|
|
||||||
const lower = Math.floor(index);
|
|
||||||
const upper = Math.ceil(index);
|
|
||||||
const weight = index % 1;
|
|
||||||
|
|
||||||
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
|
||||||
}
|
|
||||||
|
|
||||||
median(series: DataSeries, conditions: Condition[] = []): number {
|
|
||||||
return this.percentile(series, 50, true, conditions);
|
|
||||||
}
|
|
||||||
|
|
||||||
mode(series: DataSeries, conditions: Condition[] = []): number[] {
|
|
||||||
validateSeries(series);
|
|
||||||
const filteredValues = this.applyConditions(series, conditions);
|
|
||||||
const frequency = _.countBy(filteredValues);
|
|
||||||
const maxFreq = Math.max(...Object.values(frequency));
|
|
||||||
|
|
||||||
return Object.keys(frequency)
|
|
||||||
.filter(key => frequency[key] === maxFreq)
|
|
||||||
.map(Number);
|
|
||||||
}
|
|
||||||
|
|
||||||
max(series: DataSeries, conditions: Condition[] = []): number {
|
|
||||||
validateSeries(series);
|
|
||||||
const filteredValues = this.applyConditions(series, conditions);
|
|
||||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
|
||||||
return Math.max(...filteredValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
min(series: DataSeries, conditions: Condition[] = []): number {
|
|
||||||
validateSeries(series);
|
|
||||||
const filteredValues = this.applyConditions(series, conditions);
|
|
||||||
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
|
||||||
return Math.min(...filteredValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
correlation(series1: DataSeries, series2: DataSeries): number {
|
|
||||||
validateSeries(series1);
|
|
||||||
validateSeries(series2);
|
|
||||||
|
|
||||||
if (series1.values.length !== series2.values.length) {
|
|
||||||
throw new Error('Series must have same length for correlation');
|
|
||||||
}
|
|
||||||
|
|
||||||
const x = series1.values;
|
|
||||||
const y = series2.values;
|
|
||||||
const n = x.length;
|
|
||||||
|
|
||||||
const sumX = _.sum(x);
|
|
||||||
const sumY = _.sum(y);
|
|
||||||
const sumXY = _.sum(x.map((xi, i) => xi * y[i]));
|
|
||||||
const sumX2 = _.sum(x.map(xi => xi * xi));
|
|
||||||
const sumY2 = _.sum(y.map(yi => yi * yi));
|
|
||||||
|
|
||||||
const numerator = n * sumXY - sumX * sumY;
|
|
||||||
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
|
|
||||||
|
|
||||||
return numerator / denominator;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rolling window functions
|
|
||||||
rolling(series: DataSeries, windowSize: number): RollingWindow {
|
|
||||||
validateSeries(series);
|
|
||||||
if (windowSize <= 0) {
|
|
||||||
throw new Error('Window size must be a positive number.');
|
|
||||||
}
|
|
||||||
if (series.values.length < windowSize) {
|
|
||||||
return new RollingWindow([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const windows: number[][] = [];
|
|
||||||
for (let i = 0; i <= series.values.length - windowSize; i++) {
|
|
||||||
const window = series.values.slice(i, i + windowSize);
|
|
||||||
windows.push(window);
|
|
||||||
}
|
|
||||||
return new RollingWindow(windows);
|
|
||||||
}
|
|
||||||
|
|
||||||
movingAverage(series: DataSeries, windowSize: number): number[] {
|
|
||||||
return this.rolling(series, windowSize).mean();
|
|
||||||
}
|
|
||||||
|
|
||||||
// K-means wrapper (uses imported KMeans class)
|
|
||||||
kmeans(matrix: DataMatrix, nClusters: number, options: KMeansOptions = {}): { clusters: number[][][], centroids: number[][] } {
|
|
||||||
validateMatrix(matrix);
|
|
||||||
const points: number[][] = matrix.data;
|
|
||||||
|
|
||||||
// Use the new MiniBatchKMeans class
|
|
||||||
const kmeans = new KMeans(points, nClusters, options);
|
|
||||||
const result = kmeans.run();
|
|
||||||
|
|
||||||
const centroids = result.clusters.map(c => (c as any).centroid);
|
|
||||||
const clusters = result.clusters.map(c => (c as any).points);
|
|
||||||
|
|
||||||
return { clusters, centroids };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time helper wrapper functions
|
|
||||||
getWeekNumber(dateString: string): number {
|
|
||||||
return getWeekNumber(dateString);
|
|
||||||
}
|
|
||||||
|
|
||||||
getSameWeekDayLastYear(dateString: string): string {
|
|
||||||
return getSameWeekDayLastYear(dateString);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retail functions
|
|
||||||
purchaseRate(productPurchases: number, totalTransactions: number): number {
|
|
||||||
if (totalTransactions === 0) throw new Error('Total transactions cannot be zero');
|
|
||||||
return (productPurchases / totalTransactions) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
liftValue(jointPurchaseRate: number, productAPurchaseRate: number, productBPurchaseRate: number): number {
|
|
||||||
const expectedJointRate = productAPurchaseRate * productBPurchaseRate;
|
|
||||||
if (expectedJointRate === 0) throw new Error('Expected joint rate cannot be zero');
|
|
||||||
return jointPurchaseRate / expectedJointRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
costRatio(cost: number, salePrice: number): number {
|
|
||||||
if (salePrice === 0) throw new Error('Sale price cannot be zero');
|
|
||||||
return cost / salePrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
grossMarginRate(salePrice: number, cost: number): number {
|
|
||||||
if (salePrice === 0) throw new Error('Sale price cannot be zero');
|
|
||||||
return (salePrice - cost) / salePrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
averageSpendPerCustomer(totalRevenue: number, numberOfCustomers: number): number {
|
|
||||||
if (numberOfCustomers === 0) {
|
|
||||||
throw new Error('Number of customers cannot be zero');
|
|
||||||
}
|
|
||||||
return totalRevenue / numberOfCustomers;
|
|
||||||
}
|
|
||||||
|
|
||||||
purchaseIndex(totalItemsSold: number, numberOfCustomers: number): number {
|
|
||||||
if (numberOfCustomers === 0) {
|
|
||||||
throw new Error('Number of customers cannot be zero');
|
|
||||||
}
|
|
||||||
return (totalItemsSold / numberOfCustomers) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Prediction functions
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
timeSeriesForecast(series: DataSeries, forecastPeriods: number): ForecastResult {
|
|
||||||
validateSeries(series);
|
|
||||||
|
|
||||||
const model = calculateLinearRegression(series.values);
|
|
||||||
const forecast = generateForecast(model, series.values.length, forecastPeriods);
|
|
||||||
const predictionIntervals = calculatePredictionIntervals(series.values, model, forecast);
|
|
||||||
|
|
||||||
return {
|
|
||||||
forecast,
|
|
||||||
predictionIntervals,
|
|
||||||
modelParameters: {
|
|
||||||
slope: model.slope,
|
|
||||||
intercept: model.intercept,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize analytics engine
|
|
||||||
const analytics = new AnalyticsEngine();
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// API ROUTES
|
// API ROUTES
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -779,6 +479,45 @@ app.post('/api/correlation', (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/pivot-table:
|
||||||
|
* post:
|
||||||
|
* summary: Generate a pivot table from records
|
||||||
|
* description: Returns a pivot table based on the provided data and options
|
||||||
|
* tags: [Data Transformation]
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* data:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* description: Array of records to pivot
|
||||||
|
* options:
|
||||||
|
* $ref: '#/components/schemas/PivotOptions'
|
||||||
|
* responses:
|
||||||
|
* '200':
|
||||||
|
* description: Pivot table generated successfully
|
||||||
|
* '400':
|
||||||
|
* description: Invalid input data
|
||||||
|
*/
|
||||||
|
app.post('/api/pivot-table', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { data, options } = req.body;
|
||||||
|
// You can pass analytics.mean, analytics.count, etc. as options.aggFunc if needed
|
||||||
|
const result = pivotTable(data, options);
|
||||||
|
res.status(200).json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = handleError(error);
|
||||||
|
res.status(400).json({ success: false, error: errorMessage });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /api/series/moving-average:
|
* /api/series/moving-average:
|
||||||
|
|
@ -1150,7 +889,7 @@ app.post('/api/time/same-day-last-year', (req, res) => {
|
||||||
*/
|
*/
|
||||||
app.post('/api/retail/purchase-rate', (req, res) => {
|
app.post('/api/retail/purchase-rate', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = analytics.purchaseRate(req.body.productPurchases, req.body.totalTransactions);
|
const result = purchaseRate(req.body.productPurchases, req.body.totalTransactions);
|
||||||
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = handleError(error);
|
const errorMessage = handleError(error);
|
||||||
|
|
@ -1192,7 +931,7 @@ app.post('/api/retail/purchase-rate', (req, res) => {
|
||||||
*/
|
*/
|
||||||
app.post('/api/retail/lift-value', (req, res) => {
|
app.post('/api/retail/lift-value', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = analytics.liftValue(req.body.jointPurchaseRate, req.body.productAPurchaseRate, req.body.productBPurchaseRate);
|
const result = liftValue(req.body.jointPurchaseRate, req.body.productAPurchaseRate, req.body.productBPurchaseRate);
|
||||||
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = handleError(error);
|
const errorMessage = handleError(error);
|
||||||
|
|
@ -1230,7 +969,7 @@ app.post('/api/retail/lift-value', (req, res) => {
|
||||||
*/
|
*/
|
||||||
app.post('/api/retail/cost-ratio', (req, res) => {
|
app.post('/api/retail/cost-ratio', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = analytics.costRatio(req.body.cost, req.body.salePrice);
|
const result = costRatio(req.body.cost, req.body.salePrice);
|
||||||
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = handleError(error);
|
const errorMessage = handleError(error);
|
||||||
|
|
@ -1268,7 +1007,7 @@ app.post('/api/retail/cost-ratio', (req, res) => {
|
||||||
*/
|
*/
|
||||||
app.post('/api/retail/gross-margin', (req, res) => {
|
app.post('/api/retail/gross-margin', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = analytics.grossMarginRate(req.body.salePrice, req.body.cost);
|
const result = grossMarginRate(req.body.salePrice, req.body.cost);
|
||||||
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = handleError(error);
|
const errorMessage = handleError(error);
|
||||||
|
|
@ -1307,7 +1046,7 @@ app.post('/api/retail/gross-margin', (req, res) => {
|
||||||
app.post('/api/retail/average-spend', (req, res) => {
|
app.post('/api/retail/average-spend', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { totalRevenue, numberOfCustomers } = req.body;
|
const { totalRevenue, numberOfCustomers } = req.body;
|
||||||
const result = analytics.averageSpendPerCustomer(totalRevenue, numberOfCustomers);
|
const result = averageSpendPerCustomer(totalRevenue, numberOfCustomers);
|
||||||
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = handleError(error);
|
const errorMessage = handleError(error);
|
||||||
|
|
@ -1346,7 +1085,7 @@ app.post('/api/retail/average-spend', (req, res) => {
|
||||||
app.post('/api/retail/purchase-index', (req, res) => {
|
app.post('/api/retail/purchase-index', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { totalItemsSold, numberOfCustomers } = req.body;
|
const { totalItemsSold, numberOfCustomers } = req.body;
|
||||||
const result = analytics.purchaseIndex(totalItemsSold, numberOfCustomers);
|
const result = purchaseIndex(totalItemsSold, numberOfCustomers);
|
||||||
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = handleError(error);
|
const errorMessage = handleError(error);
|
||||||
|
|
@ -1826,6 +1565,29 @@ app.get('/api/kernels/:name', (req, res) => {
|
||||||
* s:
|
* s:
|
||||||
* type: integer
|
* type: integer
|
||||||
* description: The seasonal period length (e.g., 7 for weekly).
|
* description: The seasonal period length (e.g., 7 for weekly).
|
||||||
|
* PivotOptions:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - index
|
||||||
|
* - columns
|
||||||
|
* - values
|
||||||
|
* properties:
|
||||||
|
* index:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Keys to use as row labels
|
||||||
|
* columns:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* description: Keys to use as column labels
|
||||||
|
* values:
|
||||||
|
* type: string
|
||||||
|
* description: Key to aggregate
|
||||||
|
* aggFunc:
|
||||||
|
* type: string
|
||||||
|
* description: Aggregation function name (e.g., "sum", "mean", "count")
|
||||||
* ApiResponse:
|
* ApiResponse:
|
||||||
* type: object
|
* type: object
|
||||||
* properties:
|
* properties:
|
||||||
|
|
|
||||||
|
|
@ -1,133 +1,133 @@
|
||||||
// analysis_pipelines.ts - High-level workflows for common analysis tasks.
|
// analysis_pipelines.ts - High-level workflows for common analysis tasks.
|
||||||
|
|
||||||
import { SignalProcessor } from './signal_processing_convolution';
|
import { SignalProcessor } from './signal_processing_convolution';
|
||||||
import { TimeSeriesAnalyzer, STLDecomposition } from './timeseries';
|
import { TimeSeriesAnalyzer, STLDecomposition } from './timeseries';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The comprehensive result of a denoise and detrend operation.
|
* The comprehensive result of a denoise and detrend operation.
|
||||||
*/
|
*/
|
||||||
export interface DenoiseAndDetrendResult {
|
export interface DenoiseAndDetrendResult {
|
||||||
original: number[];
|
original: number[];
|
||||||
smoothed: number[];
|
smoothed: number[];
|
||||||
decomposition: STLDecomposition;
|
decomposition: STLDecomposition;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The result of an automatic SARIMA parameter search.
|
* The result of an automatic SARIMA parameter search.
|
||||||
*/
|
*/
|
||||||
export interface AutoArimaResult {
|
export interface AutoArimaResult {
|
||||||
bestModel: {
|
bestModel: {
|
||||||
p: number;
|
p: number;
|
||||||
d: number;
|
d: number;
|
||||||
q: number;
|
q: number;
|
||||||
P: number;
|
P: number;
|
||||||
D: number;
|
D: number;
|
||||||
Q: number;
|
Q: number;
|
||||||
s: number; // Correctly included
|
s: number;
|
||||||
aic: number;
|
aic: number;
|
||||||
};
|
};
|
||||||
searchLog: { p: number; d: number; q: number; P: number; D: number; Q: number; s: number; aic: number }[];
|
searchLog: { p: number; d: number; q: number; P: number; D: number; Q: number; s: number; aic: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A class containing high-level analysis pipelines that combine
|
* A class containing high-level analysis pipelines that combine
|
||||||
* functions from various processing libraries.
|
* functions from various processing libraries.
|
||||||
*/
|
*/
|
||||||
export class AnalysisPipelines {
|
export class AnalysisPipelines {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A full pipeline to take a raw signal, smooth it to remove noise,
|
* A full pipeline to take a raw signal, smooth it to remove noise,
|
||||||
* and then decompose it into trend, seasonal, and residual components.
|
* and then decompose it into trend, seasonal, and residual components.
|
||||||
* @param series The original time series data.
|
* @param series The original time series data.
|
||||||
* @param period The seasonal period for STL decomposition.
|
* @param period The seasonal period for STL decomposition.
|
||||||
* @param smoothWindow The window size for the initial smoothing (denoising) pass.
|
* @param smoothWindow The window size for the initial smoothing (denoising) pass.
|
||||||
* @returns An object containing the original, smoothed, and decomposed series.
|
* @returns An object containing the original, smoothed, and decomposed series.
|
||||||
*/
|
*/
|
||||||
static denoiseAndDetrend(series: number[], period: number, smoothWindow: number = 5): DenoiseAndDetrendResult {
|
static denoiseAndDetrend(series: number[], period: number, smoothWindow: number = 5): DenoiseAndDetrendResult {
|
||||||
// Ensure window is odd for symmetry
|
// Ensure window is odd for symmetry
|
||||||
if (smoothWindow > 1 && smoothWindow % 2 === 0) {
|
if (smoothWindow > 1 && smoothWindow % 2 === 0) {
|
||||||
smoothWindow++;
|
smoothWindow++;
|
||||||
}
|
}
|
||||||
const smoothed = SignalProcessor.smooth(series, {
|
const smoothed = SignalProcessor.smooth(series, {
|
||||||
method: 'gaussian',
|
method: 'gaussian',
|
||||||
windowSize: smoothWindow
|
windowSize: smoothWindow
|
||||||
});
|
});
|
||||||
|
|
||||||
const decomposition = TimeSeriesAnalyzer.stlDecomposition(smoothed, period);
|
const decomposition = TimeSeriesAnalyzer.stlDecomposition(smoothed, period);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
original: series,
|
original: series,
|
||||||
smoothed: smoothed,
|
smoothed: smoothed,
|
||||||
decomposition: decomposition,
|
decomposition: decomposition,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [FINAL CORRECTED VERSION] Performs a full grid search to find the optimal SARIMA parameters.
|
* [FINAL CORRECTED VERSION] Performs a full grid search to find the optimal SARIMA parameters.
|
||||||
* This version now correctly includes 's' in the final result object.
|
* This version now correctly includes 's' in the final result object.
|
||||||
* @param series The original time series data.
|
* @param series The original time series data.
|
||||||
* @param seasonalPeriod The seasonal period of the data (e.g., 7 for weekly, 12 for monthly).
|
* @param seasonalPeriod The seasonal period of the data (e.g., 7 for weekly, 12 for monthly).
|
||||||
* @returns An object containing the best model parameters and a log of the search.
|
* @returns An object containing the best model parameters and a log of the search.
|
||||||
*/
|
*/
|
||||||
static findBestArimaParameters(
|
static findBestArimaParameters(
|
||||||
series: number[],
|
series: number[],
|
||||||
seasonalPeriod: number,
|
seasonalPeriod: number,
|
||||||
maxD: number = 1,
|
maxD: number = 1,
|
||||||
maxP: number = 2,
|
maxP: number = 2,
|
||||||
maxQ: number = 2,
|
maxQ: number = 2,
|
||||||
maxSeasonalD: number = 1,
|
maxSeasonalD: number = 1,
|
||||||
maxSeasonalP: number = 2,
|
maxSeasonalP: number = 2,
|
||||||
maxSeasonalQ: number = 2
|
maxSeasonalQ: number = 2
|
||||||
): AutoArimaResult {
|
): AutoArimaResult {
|
||||||
|
|
||||||
const searchLog: any[] = [];
|
const searchLog: any[] = [];
|
||||||
let bestModel: any = { aic: Infinity };
|
let bestModel: any = { aic: Infinity };
|
||||||
|
|
||||||
const calculateAIC = (residuals: number[], numParams: number): number => {
|
const calculateAIC = (residuals: number[], numParams: number): number => {
|
||||||
const n = residuals.length;
|
const n = residuals.length;
|
||||||
if (n === 0) return Infinity;
|
if (n === 0) return Infinity;
|
||||||
const sse = residuals.reduce((sum, r) => sum + r * r, 0);
|
const sse = residuals.reduce((sum, r) => sum + r * r, 0);
|
||||||
if (sse < 1e-9) return -Infinity; // Perfect fit
|
if (sse < 1e-9) return -Infinity; // Perfect fit
|
||||||
const logLikelihood = -n / 2 * (Math.log(2 * Math.PI) + Math.log(sse / n)) - n / 2;
|
const logLikelihood = -n / 2 * (Math.log(2 * Math.PI) + Math.log(sse / n)) - n / 2;
|
||||||
return 2 * numParams - 2 * logLikelihood;
|
return 2 * numParams - 2 * logLikelihood;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Grid search over all parameter combinations
|
// Grid search over all parameter combinations
|
||||||
for (let d = 0; d <= maxD; d++) {
|
for (let d = 0; d <= maxD; d++) {
|
||||||
for (let p = 0; p <= maxP; p++) {
|
for (let p = 0; p <= maxP; p++) {
|
||||||
for (let q = 0; q <= maxQ; q++) {
|
for (let q = 0; q <= maxQ; q++) {
|
||||||
for (let D = 0; D <= maxSeasonalD; D++) {
|
for (let D = 0; D <= maxSeasonalD; D++) {
|
||||||
for (let P = 0; P <= maxSeasonalP; P++) {
|
for (let P = 0; P <= maxSeasonalP; P++) {
|
||||||
for (let Q = 0; Q <= maxSeasonalQ; Q++) {
|
for (let Q = 0; Q <= maxSeasonalQ; Q++) {
|
||||||
// Skip trivial models where nothing is done
|
// Skip trivial models where nothing is done
|
||||||
if (p === 0 && d === 0 && q === 0 && P === 0 && D === 0 && Q === 0) continue;
|
if (p === 0 && d === 0 && q === 0 && P === 0 && D === 0 && Q === 0) continue;
|
||||||
|
|
||||||
const options = { p, d, q, P, D, Q, s: seasonalPeriod };
|
const options = { p, d, q, P, D, Q, s: seasonalPeriod };
|
||||||
try {
|
try {
|
||||||
const { residuals } = TimeSeriesAnalyzer.arimaForecast(series, options, 0);
|
const { residuals } = TimeSeriesAnalyzer.arimaForecast(series, options, 0);
|
||||||
const numParams = p + q + P + Q;
|
const numParams = p + q + P + Q;
|
||||||
const aic = calculateAIC(residuals, numParams);
|
const aic = calculateAIC(residuals, numParams);
|
||||||
|
|
||||||
// Construct the full model info object, ensuring 's' is included
|
// Construct the full model info object, ensuring 's' is included
|
||||||
const modelInfo = { p, d, q, P, D, Q, s: seasonalPeriod, aic };
|
const modelInfo = { p, d, q, P, D, Q, s: seasonalPeriod, aic };
|
||||||
searchLog.push(modelInfo);
|
searchLog.push(modelInfo);
|
||||||
|
|
||||||
if (modelInfo.aic < bestModel.aic) {
|
if (modelInfo.aic < bestModel.aic) {
|
||||||
bestModel = modelInfo;
|
bestModel = modelInfo;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Skip invalid parameter combinations that cause errors
|
// Skip invalid parameter combinations that cause errors
|
||||||
}
|
}
|
||||||
} } } } } }
|
} } } } } }
|
||||||
|
|
||||||
if (bestModel.aic === Infinity) {
|
if (bestModel.aic === Infinity) {
|
||||||
throw new Error("Could not find a suitable SARIMA model. The data may be too short or complex.");
|
throw new Error("Could not find a suitable SARIMA model. The data may be too short or complex.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort the log by AIC for easier reading
|
// Sort the log by AIC for easier reading
|
||||||
searchLog.sort((a, b) => a.aic - b.aic);
|
searchLog.sort((a, b) => a.aic - b.aic);
|
||||||
|
|
||||||
return { bestModel, searchLog };
|
return { bestModel, searchLog };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
208
services/analytics_engine.ts
Normal file
208
services/analytics_engine.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import * as math from 'mathjs';
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
import { DataSeries, DataMatrix, Condition, ApiResponse } from '../types/index';
|
||||||
|
import { RollingWindow } from './rolling_window';
|
||||||
|
import { KMeans, KMeansOptions } from './kmeans';
|
||||||
|
import { getWeekNumber, getSameWeekDayLastYear } from './time-helper';
|
||||||
|
import { calculateLinearRegression, generateForecast, calculatePredictionIntervals, ForecastResult } from './prediction';
|
||||||
|
|
||||||
|
export const handleError = (error: unknown): string => {
|
||||||
|
return error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
};
|
||||||
|
export const validateSeries = (series: DataSeries): void => {
|
||||||
|
if (!series || !Array.isArray(series.values) || series.values.length === 0) {
|
||||||
|
throw new Error('Series must contain at least one value');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateMatrix = (matrix: DataMatrix): void => {
|
||||||
|
if (!matrix || !Array.isArray(matrix.data) || matrix.data.length === 0) {
|
||||||
|
throw new Error('Matrix must contain at least one row');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AnalyticsEngine {
|
||||||
|
|
||||||
|
private applyConditions(series: DataSeries, conditions: Condition[] = []): number[] {
|
||||||
|
if (conditions.length === 0) return series.values;
|
||||||
|
return series.values; // TODO: Implement filtering
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic statistical functions
|
||||||
|
unique(series: DataSeries): number[] {
|
||||||
|
validateSeries(series);
|
||||||
|
return _.uniq(series.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
mean(series: DataSeries, conditions: Condition[] = []): number {
|
||||||
|
validateSeries(series);
|
||||||
|
const filteredValues = this.applyConditions(series, conditions);
|
||||||
|
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||||
|
return Number(math.mean(filteredValues));
|
||||||
|
}
|
||||||
|
|
||||||
|
count(series: DataSeries, conditions: Condition[] = []): number {
|
||||||
|
validateSeries(series);
|
||||||
|
const filteredValues = this.applyConditions(series, conditions);
|
||||||
|
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||||
|
return filteredValues.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
distinctCount(series: DataSeries, conditions: Condition[] = []): number {
|
||||||
|
validateSeries(series);
|
||||||
|
const filteredValues = this.applyConditions(series, conditions);
|
||||||
|
const uniqueValues = _.uniq(filteredValues);
|
||||||
|
return uniqueValues.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
variance(series: DataSeries, conditions: Condition[] = []): number {
|
||||||
|
validateSeries(series);
|
||||||
|
const filteredValues = this.applyConditions(series, conditions);
|
||||||
|
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||||
|
return Number(math.variance(filteredValues));
|
||||||
|
}
|
||||||
|
|
||||||
|
standardDeviation(series: DataSeries, conditions: Condition[] = []): number {
|
||||||
|
validateSeries(series);
|
||||||
|
const filteredValues = this.applyConditions(series, conditions);
|
||||||
|
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||||
|
return Number(math.std(filteredValues));
|
||||||
|
}
|
||||||
|
|
||||||
|
percentile(series: DataSeries, percent: number, ascending: boolean = true, conditions: Condition[] = []): number {
|
||||||
|
validateSeries(series);
|
||||||
|
const filteredValues = this.applyConditions(series, conditions);
|
||||||
|
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||||
|
|
||||||
|
const sorted = ascending ? _.sortBy(filteredValues) : _.sortBy(filteredValues).reverse();
|
||||||
|
const index = (percent / 100) * (sorted.length - 1);
|
||||||
|
const lower = Math.floor(index);
|
||||||
|
const upper = Math.ceil(index);
|
||||||
|
const weight = index % 1;
|
||||||
|
|
||||||
|
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
median(series: DataSeries, conditions: Condition[] = []): number {
|
||||||
|
return this.percentile(series, 50, true, conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
mode(series: DataSeries, conditions: Condition[] = []): number[] {
|
||||||
|
validateSeries(series);
|
||||||
|
const filteredValues = this.applyConditions(series, conditions);
|
||||||
|
const frequency = _.countBy(filteredValues);
|
||||||
|
const maxFreq = Math.max(...Object.values(frequency));
|
||||||
|
|
||||||
|
return Object.keys(frequency)
|
||||||
|
.filter(key => frequency[key] === maxFreq)
|
||||||
|
.map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
max(series: DataSeries, conditions: Condition[] = []): number {
|
||||||
|
validateSeries(series);
|
||||||
|
const filteredValues = this.applyConditions(series, conditions);
|
||||||
|
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||||
|
return Math.max(...filteredValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
min(series: DataSeries, conditions: Condition[] = []): number {
|
||||||
|
validateSeries(series);
|
||||||
|
const filteredValues = this.applyConditions(series, conditions);
|
||||||
|
if (filteredValues.length === 0) throw new Error('No data points match conditions');
|
||||||
|
return Math.min(...filteredValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
correlation(series1: DataSeries, series2: DataSeries): number {
|
||||||
|
validateSeries(series1);
|
||||||
|
validateSeries(series2);
|
||||||
|
|
||||||
|
if (series1.values.length !== series2.values.length) {
|
||||||
|
throw new Error('Series must have same length for correlation');
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = series1.values;
|
||||||
|
const y = series2.values;
|
||||||
|
const n = x.length;
|
||||||
|
|
||||||
|
const sumX = _.sum(x);
|
||||||
|
const sumY = _.sum(y);
|
||||||
|
const sumXY = _.sum(x.map((xi, i) => xi * y[i]));
|
||||||
|
const sumX2 = _.sum(x.map(xi => xi * xi));
|
||||||
|
const sumY2 = _.sum(y.map(yi => yi * yi));
|
||||||
|
|
||||||
|
const numerator = n * sumXY - sumX * sumY;
|
||||||
|
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
|
||||||
|
|
||||||
|
return numerator / denominator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rolling window functions
|
||||||
|
rolling(series: DataSeries, windowSize: number): RollingWindow {
|
||||||
|
validateSeries(series);
|
||||||
|
if (windowSize <= 0) {
|
||||||
|
throw new Error('Window size must be a positive number.');
|
||||||
|
}
|
||||||
|
if (series.values.length < windowSize) {
|
||||||
|
return new RollingWindow([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const windows: number[][] = [];
|
||||||
|
for (let i = 0; i <= series.values.length - windowSize; i++) {
|
||||||
|
const window = series.values.slice(i, i + windowSize);
|
||||||
|
windows.push(window);
|
||||||
|
}
|
||||||
|
return new RollingWindow(windows);
|
||||||
|
}
|
||||||
|
|
||||||
|
movingAverage(series: DataSeries, windowSize: number): number[] {
|
||||||
|
return this.rolling(series, windowSize).mean();
|
||||||
|
}
|
||||||
|
|
||||||
|
// K-means wrapper (uses imported KMeans class)
|
||||||
|
kmeans(matrix: DataMatrix, nClusters: number, options: KMeansOptions = {}): { clusters: number[][][], centroids: number[][] } {
|
||||||
|
validateMatrix(matrix);
|
||||||
|
const points: number[][] = matrix.data;
|
||||||
|
|
||||||
|
// Use the new MiniBatchKMeans class
|
||||||
|
const kmeans = new KMeans(points, nClusters, options);
|
||||||
|
const result = kmeans.run();
|
||||||
|
|
||||||
|
const centroids = result.clusters.map(c => (c as any).centroid);
|
||||||
|
const clusters = result.clusters.map(c => (c as any).points);
|
||||||
|
|
||||||
|
return { clusters, centroids };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time helper wrapper functions
|
||||||
|
getWeekNumber(dateString: string): number {
|
||||||
|
return getWeekNumber(dateString);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSameWeekDayLastYear(dateString: string): string {
|
||||||
|
return getSameWeekDayLastYear(dateString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Prediction functions
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
timeSeriesForecast(series: DataSeries, forecastPeriods: number): ForecastResult {
|
||||||
|
validateSeries(series);
|
||||||
|
|
||||||
|
const model = calculateLinearRegression(series.values);
|
||||||
|
const forecast = generateForecast(model, series.values.length, forecastPeriods);
|
||||||
|
const predictionIntervals = calculatePredictionIntervals(series.values, model, forecast);
|
||||||
|
|
||||||
|
return {
|
||||||
|
forecast,
|
||||||
|
predictionIntervals,
|
||||||
|
modelParameters: {
|
||||||
|
slope: model.slope,
|
||||||
|
intercept: model.intercept,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const analytics = new AnalyticsEngine();
|
||||||
|
|
||||||
|
|
@ -1,144 +1,144 @@
|
||||||
export type Point = number[];
|
export type Point = number[];
|
||||||
|
|
||||||
export interface Cluster {
|
export interface Cluster {
|
||||||
centroid: Point;
|
centroid: Point;
|
||||||
points: Point[];
|
points: Point[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KMeansOptions {
|
export interface KMeansOptions {
|
||||||
batchSize?: number;
|
batchSize?: number;
|
||||||
maxIterations?: number;
|
maxIterations?: number;
|
||||||
tolerance?: number;
|
tolerance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KMeansResult {
|
export interface KMeansResult {
|
||||||
clusters: Cluster[];
|
clusters: Cluster[];
|
||||||
iterations: number;
|
iterations: number;
|
||||||
converged: boolean;
|
converged: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class KMeans {
|
export class KMeans {
|
||||||
private readonly k: number;
|
private readonly k: number;
|
||||||
private readonly batchSize: number;
|
private readonly batchSize: number;
|
||||||
private readonly maxIterations: number;
|
private readonly maxIterations: number;
|
||||||
private readonly tolerance: number;
|
private readonly tolerance: number;
|
||||||
private readonly data: Point[];
|
private readonly data: Point[];
|
||||||
private centroids: Point[] = [];
|
private centroids: Point[] = [];
|
||||||
|
|
||||||
constructor(data: Point[], k: number, options: KMeansOptions = {}) {
|
constructor(data: Point[], k: number, options: KMeansOptions = {}) {
|
||||||
this.data = data;
|
this.data = data;
|
||||||
this.k = k;
|
this.k = k;
|
||||||
this.batchSize = options.batchSize ?? 32;
|
this.batchSize = options.batchSize ?? 32;
|
||||||
this.maxIterations = options.maxIterations ?? 100;
|
this.maxIterations = options.maxIterations ?? 100;
|
||||||
this.tolerance = options.tolerance ?? 0.0001;
|
this.tolerance = options.tolerance ?? 0.0001;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static euclideanDistance(p1: Point, p2: Point): number {
|
private static euclideanDistance(p1: Point, p2: Point): number {
|
||||||
return Math.sqrt(p1.reduce((sum, val, i) => sum + (val - p2[i]) ** 2, 0));
|
return Math.sqrt(p1.reduce((sum, val, i) => sum + (val - p2[i]) ** 2, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeCentroids(): void {
|
private initializeCentroids(): void {
|
||||||
const dataCopy = [...this.data];
|
const dataCopy = [...this.data];
|
||||||
for (let i = 0; i < this.k; i++) {
|
for (let i = 0; i < this.k; i++) {
|
||||||
const randomIndex = Math.floor(Math.random() * dataCopy.length);
|
const randomIndex = Math.floor(Math.random() * dataCopy.length);
|
||||||
this.centroids.push([...dataCopy[randomIndex]]);
|
this.centroids.push([...dataCopy[randomIndex]]);
|
||||||
dataCopy.splice(randomIndex, 1);
|
dataCopy.splice(randomIndex, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a random sample of the data.
|
* Creates a random sample of the data.
|
||||||
*/
|
*/
|
||||||
private createMiniBatch(): Point[] {
|
private createMiniBatch(): Point[] {
|
||||||
const miniBatch: Point[] = [];
|
const miniBatch: Point[] = [];
|
||||||
const dataCopy = [...this.data];
|
const dataCopy = [...this.data];
|
||||||
for (let i = 0; i < this.batchSize && dataCopy.length > 0; i++) {
|
for (let i = 0; i < this.batchSize && dataCopy.length > 0; i++) {
|
||||||
const randomIndex = Math.floor(Math.random() * dataCopy.length);
|
const randomIndex = Math.floor(Math.random() * dataCopy.length);
|
||||||
miniBatch.push(dataCopy[randomIndex]);
|
miniBatch.push(dataCopy[randomIndex]);
|
||||||
dataCopy.splice(randomIndex, 1);
|
dataCopy.splice(randomIndex, 1);
|
||||||
}
|
}
|
||||||
return miniBatch;
|
return miniBatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assigns all points in the full dataset to the final centroids.
|
* Assigns all points in the full dataset to the final centroids.
|
||||||
*/
|
*/
|
||||||
private assignFinalClusters(): Cluster[] {
|
private assignFinalClusters(): Cluster[] {
|
||||||
const clusters: Cluster[] = this.centroids.map(c => ({ centroid: c, points: [] }));
|
const clusters: Cluster[] = this.centroids.map(c => ({ centroid: c, points: [] }));
|
||||||
|
|
||||||
for (const point of this.data) {
|
for (const point of this.data) {
|
||||||
let minDistance = Infinity;
|
let minDistance = Infinity;
|
||||||
let closestClusterIndex = -1;
|
let closestClusterIndex = -1;
|
||||||
for (let i = 0; i < this.centroids.length; i++) {
|
for (let i = 0; i < this.centroids.length; i++) {
|
||||||
const distance = KMeans.euclideanDistance(point, this.centroids[i]);
|
const distance = KMeans.euclideanDistance(point, this.centroids[i]);
|
||||||
if (distance < minDistance) {
|
if (distance < minDistance) {
|
||||||
minDistance = distance;
|
minDistance = distance;
|
||||||
closestClusterIndex = i;
|
closestClusterIndex = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (closestClusterIndex !== -1) {
|
if (closestClusterIndex !== -1) {
|
||||||
clusters[closestClusterIndex].points.push(point);
|
clusters[closestClusterIndex].points.push(point);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return clusters;
|
return clusters;
|
||||||
}
|
}
|
||||||
|
|
||||||
public run(): KMeansResult {
|
public run(): KMeansResult {
|
||||||
this.initializeCentroids();
|
this.initializeCentroids();
|
||||||
|
|
||||||
const clusterPointCounts = new Array(this.k).fill(0);
|
const clusterPointCounts = new Array(this.k).fill(0);
|
||||||
let converged = false;
|
let converged = false;
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
|
|
||||||
for (let i = 0; i < this.maxIterations; i++) {
|
for (let i = 0; i < this.maxIterations; i++) {
|
||||||
iterations = i + 1;
|
iterations = i + 1;
|
||||||
const miniBatch = this.createMiniBatch();
|
const miniBatch = this.createMiniBatch();
|
||||||
const previousCentroids = this.centroids.map(c => [...c]);
|
const previousCentroids = this.centroids.map(c => [...c]);
|
||||||
|
|
||||||
// Assign points in the batch and update centroids gradually
|
// Assign points in the batch and update centroids gradually
|
||||||
for (const point of miniBatch) {
|
for (const point of miniBatch) {
|
||||||
let minDistance = Infinity;
|
let minDistance = Infinity;
|
||||||
let closestClusterIndex = -1;
|
let closestClusterIndex = -1;
|
||||||
|
|
||||||
for (let j = 0; j < this.k; j++) {
|
for (let j = 0; j < this.k; j++) {
|
||||||
const distance = KMeans.euclideanDistance(point, this.centroids[j]);
|
const distance = KMeans.euclideanDistance(point, this.centroids[j]);
|
||||||
if (distance < minDistance) {
|
if (distance < minDistance) {
|
||||||
minDistance = distance;
|
minDistance = distance;
|
||||||
closestClusterIndex = j;
|
closestClusterIndex = j;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (closestClusterIndex !== -1) {
|
if (closestClusterIndex !== -1) {
|
||||||
clusterPointCounts[closestClusterIndex]++;
|
clusterPointCounts[closestClusterIndex]++;
|
||||||
const learningRate = 1 / clusterPointCounts[closestClusterIndex];
|
const learningRate = 1 / clusterPointCounts[closestClusterIndex];
|
||||||
const centroidToUpdate = this.centroids[closestClusterIndex];
|
const centroidToUpdate = this.centroids[closestClusterIndex];
|
||||||
|
|
||||||
// Move the centroid slightly towards the new point
|
// Move the centroid slightly towards the new point
|
||||||
for (let dim = 0; dim < centroidToUpdate.length; dim++) {
|
for (let dim = 0; dim < centroidToUpdate.length; dim++) {
|
||||||
centroidToUpdate[dim] = (1 - learningRate) * centroidToUpdate[dim] + learningRate * point[dim];
|
centroidToUpdate[dim] = (1 - learningRate) * centroidToUpdate[dim] + learningRate * point[dim];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for convergence
|
// Check for convergence
|
||||||
let totalMovement = 0;
|
let totalMovement = 0;
|
||||||
for(let j = 0; j < this.k; j++) {
|
for(let j = 0; j < this.k; j++) {
|
||||||
totalMovement += KMeans.euclideanDistance(previousCentroids[j], this.centroids[j]);
|
totalMovement += KMeans.euclideanDistance(previousCentroids[j], this.centroids[j]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalMovement < this.tolerance) {
|
if (totalMovement < this.tolerance) {
|
||||||
converged = true;
|
converged = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After training, assign all points to the final centroids
|
// After training, assign all points to the final centroids
|
||||||
const finalClusters = this.assignFinalClusters();
|
const finalClusters = this.assignFinalClusters();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clusters: finalClusters,
|
clusters: finalClusters,
|
||||||
iterations,
|
iterations,
|
||||||
converged
|
converged
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
36
services/pivot_table.ts
Normal file
36
services/pivot_table.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { analytics } from './analytics_engine'; // Import your analytics engine
|
||||||
|
|
||||||
|
export interface PivotOptions {
|
||||||
|
index: string[];
|
||||||
|
columns: string[];
|
||||||
|
values: string;
|
||||||
|
aggFunc?: (items: number[]) => number; // Aggregation function (e.g., analytics.mean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pivotTable(
|
||||||
|
data: Record<string, any>[],
|
||||||
|
options: PivotOptions
|
||||||
|
): Record<string, Record<string, number>> {
|
||||||
|
const { index, columns, values, aggFunc = arr => arr.reduce((a, b) => a + b, 0) } = options;
|
||||||
|
const cellMap: Record<string, Record<string, number[]>> = {};
|
||||||
|
|
||||||
|
data.forEach(row => {
|
||||||
|
const rowKey = index.map(k => row[k]).join('|');
|
||||||
|
const colKey = columns.map(k => row[k]).join('|');
|
||||||
|
|
||||||
|
if (!cellMap[rowKey]) cellMap[rowKey] = {};
|
||||||
|
if (!cellMap[rowKey][colKey]) cellMap[rowKey][colKey] = [];
|
||||||
|
cellMap[rowKey][colKey].push(row[values]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply aggregation function to each cell
|
||||||
|
const result: Record<string, Record<string, number>> = {};
|
||||||
|
Object.entries(cellMap).forEach(([rowKey, cols]) => {
|
||||||
|
result[rowKey] = {};
|
||||||
|
Object.entries(cols).forEach(([colKey, valuesArr]) => {
|
||||||
|
result[rowKey][colKey] = aggFunc(valuesArr);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -1,101 +1,101 @@
|
||||||
import * as math from 'mathjs';
|
import * as math from 'mathjs';
|
||||||
|
|
||||||
// The structure for the returned regression model
|
// The structure for the returned regression model
|
||||||
export interface LinearRegressionModel {
|
export interface LinearRegressionModel {
|
||||||
slope: number;
|
slope: number;
|
||||||
intercept: number;
|
intercept: number;
|
||||||
predict: (x: number) => number;
|
predict: (x: number) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The structure for the full forecast output
|
// The structure for the full forecast output
|
||||||
export interface ForecastResult {
|
export interface ForecastResult {
|
||||||
forecast: number[];
|
forecast: number[];
|
||||||
predictionIntervals: {
|
predictionIntervals: {
|
||||||
upperBound: number[];
|
upperBound: number[];
|
||||||
lowerBound: number[];
|
lowerBound: number[];
|
||||||
};
|
};
|
||||||
modelParameters: {
|
modelParameters: {
|
||||||
slope: number;
|
slope: number;
|
||||||
intercept: number;
|
intercept: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the linear regression model from a time series.
|
* Calculates the linear regression model from a time series.
|
||||||
* @param yValues The historical data points (e.g., sales per month).
|
* @param yValues The historical data points (e.g., sales per month).
|
||||||
* @returns {LinearRegressionModel} An object containing the model's parameters and a predict function.
|
* @returns {LinearRegressionModel} An object containing the model's parameters and a predict function.
|
||||||
*/
|
*/
|
||||||
export function calculateLinearRegression(yValues: number[]): LinearRegressionModel {
|
export function calculateLinearRegression(yValues: number[]): LinearRegressionModel {
|
||||||
if (yValues.length < 2) {
|
if (yValues.length < 2) {
|
||||||
throw new Error('At least two data points are required for linear regression.');
|
throw new Error('At least two data points are required for linear regression.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const xValues = Array.from({ length: yValues.length }, (_, i) => i);
|
const xValues = Array.from({ length: yValues.length }, (_, i) => i);
|
||||||
|
|
||||||
const meanX = Number(math.mean(xValues));
|
const meanX = Number(math.mean(xValues));
|
||||||
const meanY = Number(math.mean(yValues));
|
const meanY = Number(math.mean(yValues));
|
||||||
const stdDevX = Number(math.std(xValues, 'uncorrected'));
|
const stdDevX = Number(math.std(xValues, 'uncorrected'));
|
||||||
const stdDevY = Number(math.std(yValues, 'uncorrected'));
|
const stdDevY = Number(math.std(yValues, 'uncorrected'));
|
||||||
|
|
||||||
// Ensure stdDevX is not zero to avoid division by zero
|
// Ensure stdDevX is not zero to avoid division by zero
|
||||||
if (stdDevX === 0) {
|
if (stdDevX === 0) {
|
||||||
// This happens if all xValues are the same, which is impossible in this time series context,
|
// This happens if all xValues are the same, which is impossible in this time series context,
|
||||||
// but it's good practice to handle. A vertical line has an infinite slope.
|
// but it's good practice to handle. A vertical line has an infinite slope.
|
||||||
// For simplicity, we can return a model with zero slope.
|
// For simplicity, we can return a model with zero slope.
|
||||||
return { slope: 0, intercept: meanY, predict: (x: number) => meanY };
|
return { slope: 0, intercept: meanY, predict: (x: number) => meanY };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cast the result of math.sum to a Number
|
// Cast the result of math.sum to a Number
|
||||||
const correlationNumerator = Number(math.sum(xValues.map((x, i) => (x - meanX) * (yValues[i] - meanY))));
|
const correlationNumerator = Number(math.sum(xValues.map((x, i) => (x - meanX) * (yValues[i] - meanY))));
|
||||||
|
|
||||||
const correlation = correlationNumerator / ((xValues.length - 1) * stdDevX * stdDevY);
|
const correlation = correlationNumerator / ((xValues.length) * stdDevX * stdDevY);
|
||||||
|
|
||||||
const slope = correlation * (stdDevY / stdDevX);
|
const slope = correlation * (stdDevY / stdDevX);
|
||||||
const intercept = meanY - slope * meanX;
|
const intercept = meanY - slope * meanX;
|
||||||
|
|
||||||
const predict = (x: number): number => slope * x + intercept;
|
const predict = (x: number): number => slope * x + intercept;
|
||||||
|
|
||||||
return { slope, intercept, predict };
|
return { slope, intercept, predict };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a forecast for a specified number of future periods.
|
* Generates a forecast for a specified number of future periods.
|
||||||
* @param model The calculated linear regression model.
|
* @param model The calculated linear regression model.
|
||||||
* @param historicalDataLength The number of historical data points.
|
* @param historicalDataLength The number of historical data points.
|
||||||
* @param forecastPeriods The number of future periods to predict.
|
* @param forecastPeriods The number of future periods to predict.
|
||||||
* @returns {number[]} An array of forecasted values.
|
* @returns {number[]} An array of forecasted values.
|
||||||
*/
|
*/
|
||||||
export function generateForecast(model: LinearRegressionModel, historicalDataLength: number, forecastPeriods: number): number[] {
|
export function generateForecast(model: LinearRegressionModel, historicalDataLength: number, forecastPeriods: number): number[] {
|
||||||
const forecast: number[] = [];
|
const forecast: number[] = [];
|
||||||
const startPeriod = historicalDataLength;
|
const startPeriod = historicalDataLength;
|
||||||
|
|
||||||
for (let i = 0; i < forecastPeriods; i++) {
|
for (let i = 0; i < forecastPeriods; i++) {
|
||||||
const futureX = startPeriod + i;
|
const futureX = startPeriod + i;
|
||||||
forecast.push(model.predict(futureX));
|
forecast.push(model.predict(futureX));
|
||||||
}
|
}
|
||||||
return forecast;
|
return forecast;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates prediction intervals to show the range of uncertainty.
|
* Calculates prediction intervals to show the range of uncertainty.
|
||||||
* @param yValues The original historical data.
|
* @param yValues The original historical data.
|
||||||
* @param model The calculated linear regression model.
|
* @param model The calculated linear regression model.
|
||||||
* @param forecast The array of forecasted values.
|
* @param forecast The array of forecasted values.
|
||||||
* @returns An object with upperBound and lowerBound arrays.
|
* @returns An object with upperBound and lowerBound arrays.
|
||||||
*/
|
*/
|
||||||
export function calculatePredictionIntervals(yValues: number[], model: LinearRegressionModel, forecast: number[]) {
|
export function calculatePredictionIntervals(yValues: number[], model: LinearRegressionModel, forecast: number[]) {
|
||||||
const n = yValues.length;
|
const n = yValues.length;
|
||||||
const residualsSquaredSum = yValues.reduce((sum, y, i) => {
|
const residualsSquaredSum = yValues.reduce((sum, y, i) => {
|
||||||
const predictedY = model.predict(i);
|
const predictedY = model.predict(i);
|
||||||
return sum + (y - predictedY) ** 2;
|
return sum + (y - predictedY) ** 2;
|
||||||
}, 0);
|
}, 0);
|
||||||
const stdError = Math.sqrt(residualsSquaredSum / (n - 2));
|
const stdError = Math.sqrt(residualsSquaredSum / (n - 2));
|
||||||
|
|
||||||
const zScore = 1.96; // For a 95% confidence level
|
const zScore = 1.96; // For a 95% confidence level
|
||||||
const marginOfError = zScore * stdError;
|
const marginOfError = zScore * stdError;
|
||||||
|
|
||||||
const upperBound = forecast.map(val => val + marginOfError);
|
const upperBound = forecast.map(val => val + marginOfError);
|
||||||
const lowerBound = forecast.map(val => val - marginOfError);
|
const lowerBound = forecast.map(val => val - marginOfError);
|
||||||
|
|
||||||
return { upperBound, lowerBound };
|
return { upperBound, lowerBound };
|
||||||
}
|
}
|
||||||
77
services/retail_metrics.ts
Normal file
77
services/retail_metrics.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
export function purchaseIndex(totalItemsSold: number, numberOfCustomers: number): number {
|
||||||
|
if (numberOfCustomers === 0) {
|
||||||
|
throw new Error('Number of customers cannot be zero');
|
||||||
|
}
|
||||||
|
return (totalItemsSold / numberOfCustomers) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function purchaseRate(productPurchases: number, totalTransactions: number): number;
|
||||||
|
export function purchaseRate(productPurchases: number[], totalTransactions: number[]): number[];
|
||||||
|
export function purchaseRate(productPurchases: number | number[], totalTransactions: number | number[]): number | number[] {
|
||||||
|
if (Array.isArray(productPurchases) && Array.isArray(totalTransactions)) {
|
||||||
|
if (productPurchases.length !== totalTransactions.length) throw new Error('Arrays must have the same length');
|
||||||
|
return productPurchases.map((pp, i) => purchaseRate(pp, totalTransactions[i]));
|
||||||
|
}
|
||||||
|
if (typeof productPurchases === 'number' && typeof totalTransactions === 'number') {
|
||||||
|
if (totalTransactions === 0) throw new Error('Total transactions cannot be zero');
|
||||||
|
return (productPurchases / totalTransactions) * 100;
|
||||||
|
}
|
||||||
|
throw new Error('Input types must match');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liftValue(jointPurchaseRate: number, productAPurchaseRate: number, productBPurchaseRate: number): number;
|
||||||
|
export function liftValue(jointPurchaseRate: number[], productAPurchaseRate: number[], productBPurchaseRate: number[]): number[];
|
||||||
|
export function liftValue(jointPurchaseRate: number | number[], productAPurchaseRate: number | number[], productBPurchaseRate: number | number[]): number | number[] {
|
||||||
|
if (Array.isArray(jointPurchaseRate) && Array.isArray(productAPurchaseRate) && Array.isArray(productBPurchaseRate)) {
|
||||||
|
if (jointPurchaseRate.length !== productAPurchaseRate.length || jointPurchaseRate.length !== productBPurchaseRate.length) throw new Error('Arrays must have the same length');
|
||||||
|
return jointPurchaseRate.map((jpr, i) => liftValue(jpr, productAPurchaseRate[i], productBPurchaseRate[i]));
|
||||||
|
}
|
||||||
|
if (typeof jointPurchaseRate === 'number' && typeof productAPurchaseRate === 'number' && typeof productBPurchaseRate === 'number') {
|
||||||
|
const expectedJointRate = productAPurchaseRate * productBPurchaseRate;
|
||||||
|
if (expectedJointRate === 0) throw new Error('Expected joint rate cannot be zero');
|
||||||
|
return jointPurchaseRate / expectedJointRate;
|
||||||
|
}
|
||||||
|
throw new Error('Input types must match');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function costRatio(cost: number, salePrice: number): number;
|
||||||
|
export function costRatio(cost: number[], salePrice: number[]): number[];
|
||||||
|
export function costRatio(cost: number | number[], salePrice: number | number[]): number | number[] {
|
||||||
|
if (Array.isArray(cost) && Array.isArray(salePrice)) {
|
||||||
|
if (cost.length !== salePrice.length) throw new Error('Arrays must have the same length');
|
||||||
|
return cost.map((c, i) => costRatio(c, salePrice[i]));
|
||||||
|
}
|
||||||
|
if (typeof cost === 'number' && typeof salePrice === 'number') {
|
||||||
|
if (salePrice === 0) throw new Error('Sale price cannot be zero');
|
||||||
|
return cost / salePrice;
|
||||||
|
}
|
||||||
|
throw new Error('Input types must match');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function grossMarginRate(salePrice: number, cost: number): number;
|
||||||
|
export function grossMarginRate(salePrice: number[], cost: number[]): number[];
|
||||||
|
export function grossMarginRate(salePrice: number | number[], cost: number | number[]): number | number[] {
|
||||||
|
if (Array.isArray(salePrice) && Array.isArray(cost)) {
|
||||||
|
if (salePrice.length !== cost.length) throw new Error('Arrays must have the same length');
|
||||||
|
return salePrice.map((sp, i) => grossMarginRate(sp, cost[i]));
|
||||||
|
}
|
||||||
|
if (typeof salePrice === 'number' && typeof cost === 'number') {
|
||||||
|
if (salePrice === 0) throw new Error('Sale price cannot be zero');
|
||||||
|
return (salePrice - cost) / salePrice;
|
||||||
|
}
|
||||||
|
throw new Error('Input types must match');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function averageSpendPerCustomer(totalRevenue: number, numberOfCustomers: number): number;
|
||||||
|
export function averageSpendPerCustomer(totalRevenue: number[], numberOfCustomers: number[]): number[];
|
||||||
|
export function averageSpendPerCustomer(totalRevenue: number | number[], numberOfCustomers: number | number[]): number | number[] {
|
||||||
|
if (Array.isArray(totalRevenue) && Array.isArray(numberOfCustomers)) {
|
||||||
|
if (totalRevenue.length !== numberOfCustomers.length) throw new Error('Arrays must have the same length');
|
||||||
|
return totalRevenue.map((tr, i) => averageSpendPerCustomer(tr, numberOfCustomers[i]));
|
||||||
|
}
|
||||||
|
if (typeof totalRevenue === 'number' && typeof numberOfCustomers === 'number') {
|
||||||
|
if (numberOfCustomers === 0) throw new Error('Number of customers cannot be zero');
|
||||||
|
return totalRevenue / numberOfCustomers;
|
||||||
|
}
|
||||||
|
throw new Error('Input types must match');
|
||||||
|
}
|
||||||
30
services/rolling_window.ts
Normal file
30
services/rolling_window.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import * as math from 'mathjs';
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
|
||||||
|
export class RollingWindow {
|
||||||
|
private windows: number[][];
|
||||||
|
|
||||||
|
constructor(windows: number[][]) {
|
||||||
|
this.windows = windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
mean(): number[] {
|
||||||
|
return this.windows.map(window => Number(math.mean(window)));
|
||||||
|
}
|
||||||
|
|
||||||
|
sum(): number[] {
|
||||||
|
return this.windows.map(window => _.sum(window));
|
||||||
|
}
|
||||||
|
|
||||||
|
min(): number[] {
|
||||||
|
return this.windows.map(window => Math.min(...window));
|
||||||
|
}
|
||||||
|
|
||||||
|
max(): number[] {
|
||||||
|
return this.windows.map(window => Math.max(...window));
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray(): number[][] {
|
||||||
|
return this.windows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,22 @@
|
||||||
// time-helpers.ts - Date and time utility functions
|
import { getISOWeek, getISODay, subYears, setISOWeek, setISODay, isValid } from 'date-fns';
|
||||||
|
|
||||||
import { getISOWeek, getISODay, subYears, setISOWeek, setISODay, isValid } from 'date-fns';
|
export const getWeekNumber = (dateString: string): number => {
|
||||||
|
const date = new Date(dateString);
|
||||||
export const getWeekNumber = (dateString: string): number => {
|
if (!isValid(date)) {
|
||||||
const date = new Date(dateString);
|
throw new Error('Invalid date string provided.');
|
||||||
if (!isValid(date)) {
|
}
|
||||||
throw new Error('Invalid date string provided.');
|
return getISOWeek(date);
|
||||||
}
|
};
|
||||||
return getISOWeek(date);
|
|
||||||
};
|
export const getSameWeekDayLastYear = (dateString: string): string => {
|
||||||
|
const baseDate = new Date(dateString);
|
||||||
export const getSameWeekDayLastYear = (dateString: string): string => {
|
if (!isValid(baseDate)) {
|
||||||
const baseDate = new Date(dateString);
|
throw new Error('Invalid date string provided.');
|
||||||
if (!isValid(baseDate)) {
|
}
|
||||||
throw new Error('Invalid date string provided.');
|
const originalWeek = getISOWeek(baseDate);
|
||||||
}
|
const originalDayOfWeek = getISODay(baseDate);
|
||||||
const originalWeek = getISOWeek(baseDate);
|
const lastYearDate = subYears(baseDate, 1);
|
||||||
const originalDayOfWeek = getISODay(baseDate);
|
const dateWithWeekSet = setISOWeek(lastYearDate, originalWeek);
|
||||||
const lastYearDate = subYears(baseDate, 1);
|
const finalDate = setISODay(dateWithWeekSet, originalDayOfWeek);
|
||||||
const dateWithWeekSet = setISOWeek(lastYearDate, originalWeek);
|
return finalDate.toISOString().split('T')[0]; // Return as YYYY-MM-DD
|
||||||
const finalDate = setISODay(dateWithWeekSet, originalDayOfWeek);
|
|
||||||
return finalDate.toISOString().split('T')[0]; // Return as YYYY-MM-DD
|
|
||||||
};
|
};
|
||||||
|
|
@ -1,346 +1,346 @@
|
||||||
// timeseries.ts - A library for time series analysis, focusing on ARIMA.
|
// timeseries.ts - A library for time series analysis, focusing on ARIMA.
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// TYPE DEFINITIONS
|
// TYPE DEFINITIONS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the parameters for an ARIMA model.
|
* Defines the parameters for an ARIMA model.
|
||||||
* (p, d, q) are the non-seasonal components.
|
* (p, d, q) are the non-seasonal components.
|
||||||
* (P, D, Q, s) are the optional seasonal components for SARIMA.
|
* (P, D, Q, s) are the optional seasonal components for SARIMA.
|
||||||
*/
|
*/
|
||||||
export interface ARIMAOptions {
|
export interface ARIMAOptions {
|
||||||
p: number; // AutoRegressive (AR) order
|
p: number; // AutoRegressive (AR) order
|
||||||
d: number; // Differencing (I) order
|
d: number; // Differencing (I) order
|
||||||
q: number; // Moving Average (MA) order
|
q: number; // Moving Average (MA) order
|
||||||
P?: number; // Seasonal AR order
|
P?: number; // Seasonal AR order
|
||||||
D?: number; // Seasonal Differencing order
|
D?: number; // Seasonal Differencing order
|
||||||
Q?: number; // Seasonal MA order
|
Q?: number; // Seasonal MA order
|
||||||
s?: number; // Seasonal period length
|
s?: number; // Seasonal period length
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The result object from an ARIMA forecast.
|
* The result object from an ARIMA forecast.
|
||||||
*/
|
*/
|
||||||
export interface ARIMAForecastResult {
|
export interface ARIMAForecastResult {
|
||||||
forecast: number[]; // The predicted future values
|
forecast: number[]; // The predicted future values
|
||||||
residuals: number[]; // The errors of the model fit on the original data
|
residuals: number[]; // The errors of the model fit on the original data
|
||||||
model: ARIMAOptions; // The model parameters used
|
model: ARIMAOptions; // The model parameters used
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The result object from an STL decomposition.
|
* The result object from an STL decomposition.
|
||||||
*/
|
*/
|
||||||
export interface STLDecomposition {
|
export interface STLDecomposition {
|
||||||
seasonal: number[]; // The seasonal component of the series
|
seasonal: number[]; // The seasonal component of the series
|
||||||
trend: number[]; // The trend component of the series
|
trend: number[]; // The trend component of the series
|
||||||
residual: number[]; // The remainder/residual component
|
residual: number[]; // The remainder/residual component
|
||||||
original: number[]; // The original series, for comparison
|
original: number[]; // The original series, for comparison
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A class for performing time series analysis, including identification and forecasting.
|
* A class for performing time series analysis, including identification and forecasting.
|
||||||
*/
|
*/
|
||||||
export class TimeSeriesAnalyzer {
|
export class TimeSeriesAnalyzer {
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 1. IDENTIFICATION METHODS
|
// 1. IDENTIFICATION METHODS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the difference of a time series.
|
* Calculates the difference of a time series.
|
||||||
* This is the 'I' (Integrated) part of ARIMA, used to make a series stationary.
|
* This is the 'I' (Integrated) part of ARIMA, used to make a series stationary.
|
||||||
* @param series The input data series.
|
* @param series The input data series.
|
||||||
* @param lag The lag to difference by (usually 1).
|
* @param lag The lag to difference by (usually 1).
|
||||||
* @returns A new, differenced time series.
|
* @returns A new, differenced time series.
|
||||||
*/
|
*/
|
||||||
static difference(series: number[], lag: number = 1): number[] {
|
static difference(series: number[], lag: number = 1): number[] {
|
||||||
if (lag < 1 || !Number.isInteger(lag)) {
|
if (lag < 1 || !Number.isInteger(lag)) {
|
||||||
throw new Error('Lag must be a positive integer.');
|
throw new Error('Lag must be a positive integer.');
|
||||||
}
|
}
|
||||||
if (series.length <= lag) {
|
if (series.length <= lag) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const differenced: number[] = [];
|
const differenced: number[] = [];
|
||||||
for (let i = lag; i < series.length; i++) {
|
for (let i = lag; i < series.length; i++) {
|
||||||
differenced.push(series[i] - series[i - lag]);
|
differenced.push(series[i] - series[i - lag]);
|
||||||
}
|
}
|
||||||
return differenced;
|
return differenced;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to calculate the autocovariance of a series at a given lag.
|
* Helper function to calculate the autocovariance of a series at a given lag.
|
||||||
*/
|
*/
|
||||||
private static autocovariance(series: number[], lag: number): number {
|
private static autocovariance(series: number[], lag: number): number {
|
||||||
const n = series.length;
|
const n = series.length;
|
||||||
if (lag >= n) return 0;
|
if (lag >= n) return 0;
|
||||||
const mean = series.reduce((a, b) => a + b) / n;
|
const mean = series.reduce((a, b) => a + b) / n;
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (let i = lag; i < n; i++) {
|
for (let i = lag; i < n; i++) {
|
||||||
sum += (series[i] - mean) * (series[i - lag] - mean);
|
sum += (series[i] - mean) * (series[i - lag] - mean);
|
||||||
}
|
}
|
||||||
return sum / n;
|
return sum / n;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the Autocorrelation Function (ACF) for a time series.
|
* Calculates the Autocorrelation Function (ACF) for a time series.
|
||||||
* ACF helps in determining the 'q' parameter for an ARIMA model.
|
* ACF helps in determining the 'q' parameter for an ARIMA model.
|
||||||
* @param series The input data series.
|
* @param series The input data series.
|
||||||
* @param maxLag The maximum number of lags to calculate.
|
* @param maxLag The maximum number of lags to calculate.
|
||||||
* @returns An array of correlation values from lag 1 to maxLag.
|
* @returns An array of correlation values from lag 1 to maxLag.
|
||||||
*/
|
*/
|
||||||
static calculateACF(series: number[], maxLag: number): number[] {
|
static calculateACF(series: number[], maxLag: number): number[] {
|
||||||
if (series.length < 2) return [];
|
if (series.length < 2) return [];
|
||||||
|
|
||||||
const variance = this.autocovariance(series, 0);
|
const variance = this.autocovariance(series, 0);
|
||||||
if (variance === 0) {
|
if (variance === 0) {
|
||||||
return new Array(maxLag).fill(1);
|
return new Array(maxLag).fill(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const acf: number[] = [];
|
const acf: number[] = [];
|
||||||
for (let lag = 1; lag <= maxLag; lag++) {
|
for (let lag = 1; lag <= maxLag; lag++) {
|
||||||
acf.push(this.autocovariance(series, lag) / variance);
|
acf.push(this.autocovariance(series, lag) / variance);
|
||||||
}
|
}
|
||||||
return acf;
|
return acf;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the Partial Autocorrelation Function (PACF) for a time series.
|
* Calculates the Partial Autocorrelation Function (PACF) for a time series.
|
||||||
* This now uses the Durbin-Levinson algorithm for an accurate calculation.
|
* This now uses the Durbin-Levinson algorithm for an accurate calculation.
|
||||||
* PACF helps in determining the 'p' parameter for an ARIMA model.
|
* PACF helps in determining the 'p' parameter for an ARIMA model.
|
||||||
* @param series The input data series.
|
* @param series The input data series.
|
||||||
* @param maxLag The maximum number of lags to calculate.
|
* @param maxLag The maximum number of lags to calculate.
|
||||||
* @returns An array of partial correlation values from lag 1 to maxLag.
|
* @returns An array of partial correlation values from lag 1 to maxLag.
|
||||||
*/
|
*/
|
||||||
static calculatePACF(series: number[], maxLag: number): number[] {
|
static calculatePACF(series: number[], maxLag: number): number[] {
|
||||||
const acf = this.calculateACF(series, maxLag);
|
const acf = this.calculateACF(series, maxLag);
|
||||||
const pacf: number[] = [];
|
const pacf: number[] = [];
|
||||||
|
|
||||||
if (acf.length === 0) return [];
|
if (acf.length === 0) return [];
|
||||||
|
|
||||||
pacf.push(acf[0]); // PACF at lag 1 is the same as ACF at lag 1
|
pacf.push(acf[0]); // PACF at lag 1 is the same as ACF at lag 1
|
||||||
|
|
||||||
for (let k = 2; k <= maxLag; k++) {
|
for (let k = 2; k <= maxLag; k++) {
|
||||||
let numerator = acf[k - 1];
|
let numerator = acf[k - 1];
|
||||||
let denominator = 1;
|
let denominator = 1;
|
||||||
|
|
||||||
const phi = new Array(k + 1).fill(0).map(() => new Array(k + 1).fill(0));
|
const phi = new Array(k + 1).fill(0).map(() => new Array(k + 1).fill(0));
|
||||||
|
|
||||||
for(let i=1; i<=k; i++) {
|
for(let i=1; i<=k; i++) {
|
||||||
phi[i][i] = acf[i-1];
|
phi[i][i] = acf[i-1];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let j = 1; j < k; j++) {
|
for (let j = 1; j < k; j++) {
|
||||||
const factor = pacf[j - 1];
|
const factor = pacf[j - 1];
|
||||||
numerator -= factor * acf[k - j - 1];
|
numerator -= factor * acf[k - j - 1];
|
||||||
denominator -= factor * acf[j - 1];
|
denominator -= factor * acf[j - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.abs(denominator) < 1e-9) { // Avoid division by zero
|
if (Math.abs(denominator) < 1e-9) { // Avoid division by zero
|
||||||
pacf.push(0);
|
pacf.push(0);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pacf_k = numerator / denominator;
|
const pacf_k = numerator / denominator;
|
||||||
pacf.push(pacf_k);
|
pacf.push(pacf_k);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pacf;
|
return pacf;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decomposes a time series using the robust Classical Additive method.
|
* Decomposes a time series using the robust Classical Additive method.
|
||||||
* This version correctly isolates trend, seasonal, and residual components.
|
* This version correctly isolates trend, seasonal, and residual components.
|
||||||
* @param series The input data series.
|
* @param series The input data series.
|
||||||
* @param period The seasonal period (e.g., 7 for daily data with a weekly cycle).
|
* @param period The seasonal period (e.g., 7 for daily data with a weekly cycle).
|
||||||
* @returns An object containing the seasonal, trend, and residual series.
|
* @returns An object containing the seasonal, trend, and residual series.
|
||||||
*/
|
*/
|
||||||
static stlDecomposition(series: number[], period: number): STLDecomposition {
|
static stlDecomposition(series: number[], period: number): STLDecomposition {
|
||||||
if (series.length < 2 * period) {
|
if (series.length < 2 * period) {
|
||||||
throw new Error("Series must be at least twice the length of the seasonal period.");
|
throw new Error("Series must be at least twice the length of the seasonal period.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper for a centered moving average
|
// Helper for a centered moving average
|
||||||
const movingAverage = (data: number[], window: number) => {
|
const movingAverage = (data: number[], window: number) => {
|
||||||
const result = [];
|
const result = [];
|
||||||
const halfWindow = Math.floor(window / 2);
|
const halfWindow = Math.floor(window / 2);
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
const start = Math.max(0, i - halfWindow);
|
const start = Math.max(0, i - halfWindow);
|
||||||
const end = Math.min(data.length, i + halfWindow + 1);
|
const end = Math.min(data.length, i + halfWindow + 1);
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (let j = start; j < end; j++) {
|
for (let j = start; j < end; j++) {
|
||||||
sum += data[j];
|
sum += data[j];
|
||||||
}
|
}
|
||||||
result.push(sum / (end - start));
|
result.push(sum / (end - start));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step 1: Calculate the trend using a centered moving average.
|
// Step 1: Calculate the trend using a centered moving average.
|
||||||
// If period is even, we use a 2x-MA to center it correctly.
|
// If period is even, we use a 2x-MA to center it correctly.
|
||||||
let trend: number[];
|
let trend: number[];
|
||||||
if (period % 2 === 0) {
|
if (period % 2 === 0) {
|
||||||
const intermediate = movingAverage(series, period);
|
const intermediate = movingAverage(series, period);
|
||||||
trend = movingAverage(intermediate, 2);
|
trend = movingAverage(intermediate, 2);
|
||||||
} else {
|
} else {
|
||||||
trend = movingAverage(series, period);
|
trend = movingAverage(series, period);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Detrend the series
|
// Step 2: Detrend the series
|
||||||
const detrended = series.map((val, i) => val - trend[i]);
|
const detrended = series.map((val, i) => val - trend[i]);
|
||||||
|
|
||||||
// Step 3: Calculate the seasonal component by averaging the detrended values for each period
|
// Step 3: Calculate the seasonal component by averaging the detrended values for each period
|
||||||
const seasonalAverages = new Array(period).fill(0);
|
const seasonalAverages = new Array(period).fill(0);
|
||||||
const seasonalCounts = new Array(period).fill(0);
|
const seasonalCounts = new Array(period).fill(0);
|
||||||
for (let i = 0; i < series.length; i++) {
|
for (let i = 0; i < series.length; i++) {
|
||||||
if (!isNaN(detrended[i])) {
|
if (!isNaN(detrended[i])) {
|
||||||
const seasonIndex = i % period;
|
const seasonIndex = i % period;
|
||||||
seasonalAverages[seasonIndex] += detrended[i];
|
seasonalAverages[seasonIndex] += detrended[i];
|
||||||
seasonalCounts[seasonIndex]++;
|
seasonalCounts[seasonIndex]++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < period; i++) {
|
for (let i = 0; i < period; i++) {
|
||||||
seasonalAverages[i] /= seasonalCounts[i];
|
seasonalAverages[i] /= seasonalCounts[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Center the seasonal component to have a mean of zero
|
// Center the seasonal component to have a mean of zero
|
||||||
const seasonalMean = seasonalAverages.reduce((a, b) => a + b, 0) / period;
|
const seasonalMean = seasonalAverages.reduce((a, b) => a + b, 0) / period;
|
||||||
const centeredSeasonalAverages = seasonalAverages.map(avg => avg - seasonalMean);
|
const centeredSeasonalAverages = seasonalAverages.map(avg => avg - seasonalMean);
|
||||||
|
|
||||||
const seasonal = new Array(series.length).fill(0);
|
const seasonal = new Array(series.length).fill(0);
|
||||||
for (let i = 0; i < series.length; i++) {
|
for (let i = 0; i < series.length; i++) {
|
||||||
seasonal[i] = centeredSeasonalAverages[i % period];
|
seasonal[i] = centeredSeasonalAverages[i % period];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Calculate the residual component
|
// Step 4: Calculate the residual component
|
||||||
const residual = detrended.map((val, i) => val - seasonal[i]);
|
const residual = detrended.map((val, i) => val - seasonal[i]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
original: series,
|
original: series,
|
||||||
seasonal,
|
seasonal,
|
||||||
trend,
|
trend,
|
||||||
residual,
|
residual,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 2. FORECASTING METHODS
|
// 2. FORECASTING METHODS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [UPGRADED] Generates a forecast using a simplified SARIMA model.
|
* [UPGRADED] Generates a forecast using a simplified SARIMA model.
|
||||||
* This implementation now handles both non-seasonal (p,d,q) and seasonal (P,D,Q,s) components.
|
* This implementation now handles both non-seasonal (p,d,q) and seasonal (P,D,Q,s) components.
|
||||||
* @param series The input time series data.
|
* @param series The input time series data.
|
||||||
* @param options The SARIMA parameters.
|
* @param options The SARIMA parameters.
|
||||||
* @param forecastSteps The number of future steps to predict.
|
* @param forecastSteps The number of future steps to predict.
|
||||||
* @returns An object containing the forecast and model residuals.
|
* @returns An object containing the forecast and model residuals.
|
||||||
*/
|
*/
|
||||||
static arimaForecast(series: number[], options: ARIMAOptions, forecastSteps: number): ARIMAForecastResult {
|
static arimaForecast(series: number[], options: ARIMAOptions, forecastSteps: number): ARIMAForecastResult {
|
||||||
const { p, d, q, P = 0, D = 0, Q = 0, s = 0 } = options;
|
const { p, d, q, P = 0, D = 0, Q = 0, s = 0 } = options;
|
||||||
|
|
||||||
if (series.length < p + d + (P + D) * s + q + Q * s) {
|
if (series.length < p + d + (P + D) * s + q + Q * s) {
|
||||||
throw new Error("Data series is too short for the specified SARIMA order.");
|
throw new Error("Data series is too short for the specified SARIMA order.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalSeries = [...series];
|
const originalSeries = [...series];
|
||||||
let differencedSeries = [...series];
|
let differencedSeries = [...series];
|
||||||
const diffLog: { lag: number, values: number[] }[] = [];
|
const diffLog: { lag: number, values: number[] }[] = [];
|
||||||
|
|
||||||
// Step 1: Apply seasonal differencing 'D' times
|
// Step 1: Apply seasonal differencing 'D' times
|
||||||
for (let i = 0; i < D; i++) {
|
for (let i = 0; i < D; i++) {
|
||||||
diffLog.push({ lag: s, values: differencedSeries.slice(-s) });
|
diffLog.push({ lag: s, values: differencedSeries.slice(-s) });
|
||||||
differencedSeries = this.difference(differencedSeries, s);
|
differencedSeries = this.difference(differencedSeries, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Apply non-seasonal differencing 'd' times
|
// Step 2: Apply non-seasonal differencing 'd' times
|
||||||
for (let i = 0; i < d; i++) {
|
for (let i = 0; i < d; i++) {
|
||||||
diffLog.push({ lag: 1, values: differencedSeries.slice(-1) });
|
diffLog.push({ lag: 1, values: differencedSeries.slice(-1) });
|
||||||
differencedSeries = this.difference(differencedSeries, 1);
|
differencedSeries = this.difference(differencedSeries, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const n = differencedSeries.length;
|
const n = differencedSeries.length;
|
||||||
// Simplified coefficients
|
// Simplified coefficients
|
||||||
const arCoeffs = p > 0 ? new Array(p).fill(1 / p) : [];
|
const arCoeffs = p > 0 ? new Array(p).fill(1 / p) : [];
|
||||||
const maCoeffs = q > 0 ? new Array(q).fill(1 / q) : [];
|
const maCoeffs = q > 0 ? new Array(q).fill(1 / q) : [];
|
||||||
const sarCoeffs = P > 0 ? new Array(P).fill(1 / P) : [];
|
const sarCoeffs = P > 0 ? new Array(P).fill(1 / P) : [];
|
||||||
const smaCoeffs = Q > 0 ? new Array(Q).fill(1 / Q) : [];
|
const smaCoeffs = Q > 0 ? new Array(Q).fill(1 / Q) : [];
|
||||||
|
|
||||||
const residuals: number[] = new Array(n).fill(0);
|
const residuals: number[] = new Array(n).fill(0);
|
||||||
const fitted: number[] = new Array(n).fill(0);
|
const fitted: number[] = new Array(n).fill(0);
|
||||||
|
|
||||||
// Step 3: Fit the model
|
// Step 3: Fit the model
|
||||||
const startIdx = Math.max(p, q, P * s, Q * s);
|
const startIdx = Math.max(p, q, P * s, Q * s);
|
||||||
for (let t = startIdx; t < n; t++) {
|
for (let t = startIdx; t < n; t++) {
|
||||||
// Non-seasonal AR
|
// Non-seasonal AR
|
||||||
let arVal = 0;
|
let arVal = 0;
|
||||||
for (let i = 0; i < p; i++) arVal += arCoeffs[i] * differencedSeries[t - 1 - i];
|
for (let i = 0; i < p; i++) arVal += arCoeffs[i] * differencedSeries[t - 1 - i];
|
||||||
|
|
||||||
// Non-seasonal MA
|
// Non-seasonal MA
|
||||||
let maVal = 0;
|
let maVal = 0;
|
||||||
for (let i = 0; i < q; i++) maVal += maCoeffs[i] * residuals[t - 1 - i];
|
for (let i = 0; i < q; i++) maVal += maCoeffs[i] * residuals[t - 1 - i];
|
||||||
|
|
||||||
// Seasonal AR
|
// Seasonal AR
|
||||||
let sarVal = 0;
|
let sarVal = 0;
|
||||||
for (let i = 0; i < P; i++) sarVal += sarCoeffs[i] * differencedSeries[t - s * (i + 1)];
|
for (let i = 0; i < P; i++) sarVal += sarCoeffs[i] * differencedSeries[t - s * (i + 1)];
|
||||||
|
|
||||||
// Seasonal MA
|
// Seasonal MA
|
||||||
let smaVal = 0;
|
let smaVal = 0;
|
||||||
for (let i = 0; i < Q; i++) smaVal += smaCoeffs[i] * residuals[t - s * (i + 1)];
|
for (let i = 0; i < Q; i++) smaVal += smaCoeffs[i] * residuals[t - s * (i + 1)];
|
||||||
|
|
||||||
fitted[t] = arVal + maVal + sarVal + smaVal;
|
fitted[t] = arVal + maVal + sarVal + smaVal;
|
||||||
residuals[t] = differencedSeries[t] - fitted[t];
|
residuals[t] = differencedSeries[t] - fitted[t];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Generate the forecast
|
// Step 4: Generate the forecast
|
||||||
const forecastDifferenced: number[] = [];
|
const forecastDifferenced: number[] = [];
|
||||||
const extendedSeries = [...differencedSeries];
|
const extendedSeries = [...differencedSeries];
|
||||||
const extendedResiduals = [...residuals];
|
const extendedResiduals = [...residuals];
|
||||||
|
|
||||||
for (let f = 0; f < forecastSteps; f++) {
|
for (let f = 0; f < forecastSteps; f++) {
|
||||||
const t = n + f;
|
const t = n + f;
|
||||||
let nextForecast = 0;
|
let nextForecast = 0;
|
||||||
|
|
||||||
// AR
|
// AR
|
||||||
for (let i = 0; i < p; i++) nextForecast += arCoeffs[i] * extendedSeries[t - 1 - i];
|
for (let i = 0; i < p; i++) nextForecast += arCoeffs[i] * extendedSeries[t - 1 - i];
|
||||||
// MA (future residuals are 0)
|
// MA (future residuals are 0)
|
||||||
for (let i = 0; i < q; i++) nextForecast += maCoeffs[i] * extendedResiduals[t - 1 - i];
|
for (let i = 0; i < q; i++) nextForecast += maCoeffs[i] * extendedResiduals[t - 1 - i];
|
||||||
// SAR
|
// SAR
|
||||||
for (let i = 0; i < P; i++) nextForecast += sarCoeffs[i] * extendedSeries[t - s * (i + 1)];
|
for (let i = 0; i < P; i++) nextForecast += sarCoeffs[i] * extendedSeries[t - s * (i + 1)];
|
||||||
// SMA
|
// SMA
|
||||||
for (let i = 0; i < Q; i++) nextForecast += smaCoeffs[i] * extendedResiduals[t - s * (i + 1)];
|
for (let i = 0; i < Q; i++) nextForecast += smaCoeffs[i] * extendedResiduals[t - s * (i + 1)];
|
||||||
|
|
||||||
forecastDifferenced.push(nextForecast);
|
forecastDifferenced.push(nextForecast);
|
||||||
extendedSeries.push(nextForecast);
|
extendedSeries.push(nextForecast);
|
||||||
extendedResiduals.push(0);
|
extendedResiduals.push(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Invert the differencing
|
// Step 5: Invert the differencing
|
||||||
let forecast = [...forecastDifferenced];
|
let forecast = [...forecastDifferenced];
|
||||||
for (let i = diffLog.length - 1; i >= 0; i--) {
|
for (let i = diffLog.length - 1; i >= 0; i--) {
|
||||||
const { lag, values } = diffLog[i];
|
const { lag, values } = diffLog[i];
|
||||||
const inverted = [];
|
const inverted = [];
|
||||||
const fullHistory = [...originalSeries, ...forecast]; // Need a temporary full history for inversion
|
const fullHistory = [...originalSeries, ...forecast]; // Need a temporary full history for inversion
|
||||||
|
|
||||||
// A simpler inversion method for forecasting
|
// A simpler inversion method for forecasting
|
||||||
let history = [...series];
|
let history = [...series];
|
||||||
for (const forecastVal of forecast) {
|
for (const forecastVal of forecast) {
|
||||||
const lastSeasonalVal = history[history.length - lag];
|
const lastSeasonalVal = history[history.length - lag];
|
||||||
const invertedVal = forecastVal + lastSeasonalVal;
|
const invertedVal = forecastVal + lastSeasonalVal;
|
||||||
inverted.push(invertedVal);
|
inverted.push(invertedVal);
|
||||||
history.push(invertedVal);
|
history.push(invertedVal);
|
||||||
}
|
}
|
||||||
forecast = inverted;
|
forecast = inverted;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
forecast,
|
forecast,
|
||||||
residuals,
|
residuals,
|
||||||
model: options,
|
model: options,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
21
tests/analyticsEngine.test.ts
Normal file
21
tests/analyticsEngine.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { analytics } from '../services/analytics_engine';
|
||||||
|
|
||||||
|
describe('AnalyticsEngine', () => {
|
||||||
|
test('mean returns correct average', () => {
|
||||||
|
const series = { values: [1, 2, 3, 4, 5] };
|
||||||
|
const result = analytics.mean(series);
|
||||||
|
expect(result).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('max returns correct maximum', () => {
|
||||||
|
const series = { values: [1, 2, 3, 4, 5] };
|
||||||
|
const result = analytics.max(series);
|
||||||
|
expect(result).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('min returns correct minimum', () => {
|
||||||
|
const series = { values: [1, 2, 3, 4, 5] };
|
||||||
|
const result = analytics.min(series);
|
||||||
|
expect(result).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./"
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
22
types/index.ts
Normal file
22
types/index.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
export interface DataSeries {
|
||||||
|
values: number[];
|
||||||
|
labels?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataMatrix {
|
||||||
|
data: number[][];
|
||||||
|
columns?: string[];
|
||||||
|
rows?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Condition {
|
||||||
|
field: string;
|
||||||
|
operator: '>' | '<' | '=' | '>=' | '<=' | '!=';
|
||||||
|
value: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue