Compare commits
5 commits
timeseries
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ca8bded949 | |||
| 20002030ad | |||
| f558be337f | |||
| 0aa6248a9b | |||
| 1b49ae20fe |
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