modify the kmeans to mini-batch k-means "ml/kmeans" add PI値 "retail/purchase-index", 平均客単価 "retail/average-spend"
410 lines
No EOL
14 KiB
TypeScript
410 lines
No EOL
14 KiB
TypeScript
// server.ts - Simplified main server file
|
|
// package.json dependencies needed:
|
|
// npm install express mathjs lodash date-fns
|
|
// npm install -D @types/express @types/node @types/lodash typescript ts-node
|
|
|
|
import express from 'express';
|
|
import * as math from 'mathjs';
|
|
import * as _ from 'lodash';
|
|
import { KMeans, KMeansOptions } from './kmeans';
|
|
import { getWeekNumber, getSameWeekDayLastYear } from './time-helper';
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
|
|
// ========================================
|
|
// 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);
|
|
return this.applyConditions(series, conditions).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.centroid);
|
|
const clusters = result.clusters.map(c => c.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;
|
|
}
|
|
}
|
|
|
|
// Initialize analytics engine
|
|
const analytics = new AnalyticsEngine();
|
|
|
|
// ========================================
|
|
// ROUTE HELPER FUNCTION
|
|
// ========================================
|
|
|
|
const createRoute = <T>(
|
|
app: express.Application,
|
|
method: 'get' | 'post' | 'put' | 'delete',
|
|
path: string,
|
|
handler: (req: express.Request) => T
|
|
) => {
|
|
app[method](path, (req, res) => {
|
|
try {
|
|
const result = handler(req);
|
|
res.status(200).json({ success: true, data: result } as ApiResponse<T>);
|
|
} catch (error) {
|
|
const errorMessage = handleError(error);
|
|
res.status(400).json({ success: false, error: errorMessage } as ApiResponse<T>);
|
|
}
|
|
});
|
|
};
|
|
|
|
// ========================================
|
|
// API ROUTES
|
|
// ========================================
|
|
|
|
app.get('/api/health', (req, res) => {
|
|
res.status(200).json({ status: 'OK', timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
// Statistical function routes
|
|
createRoute(app, 'post', '/api/unique', (req) => analytics.unique(req.body.series));
|
|
createRoute(app, 'post', '/api/mean', (req) => analytics.mean(req.body.series, req.body.conditions));
|
|
createRoute(app, 'post', '/api/count', (req) => analytics.count(req.body.series, req.body.conditions));
|
|
createRoute(app, 'post', '/api/variance', (req) => analytics.variance(req.body.series, req.body.conditions));
|
|
createRoute(app, 'post', '/api/std', (req) => analytics.standardDeviation(req.body.series, req.body.conditions));
|
|
createRoute(app, 'post', '/api/percentile', (req) => analytics.percentile(req.body.series, req.body.percent, req.body.ascending, req.body.conditions));
|
|
createRoute(app, 'post', '/api/median', (req) => analytics.median(req.body.series, req.body.conditions));
|
|
createRoute(app, 'post', '/api/mode', (req) => analytics.mode(req.body.series, req.body.conditions));
|
|
createRoute(app, 'post', '/api/max', (req) => analytics.max(req.body.series, req.body.conditions));
|
|
createRoute(app, 'post', '/api/min', (req) => analytics.min(req.body.series, req.body.conditions));
|
|
createRoute(app, 'post', '/api/correlation', (req) => analytics.correlation(req.body.series1, req.body.series2));
|
|
|
|
// Time series routes
|
|
createRoute(app, 'post', '/api/series/moving-average', (req) => {
|
|
const { series, windowSize } = req.body;
|
|
return analytics.movingAverage(series, windowSize);
|
|
});
|
|
|
|
createRoute(app, 'post', '/api/series/rolling', (req) => {
|
|
const { series, windowSize } = req.body;
|
|
return analytics.rolling(series, windowSize).toArray();
|
|
});
|
|
|
|
// Machine learning routes
|
|
createRoute(app, 'post', '/api/ml/kmeans', (req) => {
|
|
return analytics.kmeans(req.body.matrix, req.body.nClusters, req.body.options);
|
|
});
|
|
|
|
// Time helper routes
|
|
createRoute(app, 'post', '/api/time/week-number', (req) => {
|
|
const { date } = req.body;
|
|
return analytics.getWeekNumber(date);
|
|
});
|
|
|
|
createRoute(app, 'post', '/api/time/same-day-last-year', (req) => {
|
|
const { date } = req.body;
|
|
return analytics.getSameWeekDayLastYear(date);
|
|
});
|
|
|
|
// Retail analytics routes
|
|
createRoute(app, 'post', '/api/retail/purchase-rate', (req) => analytics.purchaseRate(req.body.productPurchases, req.body.totalTransactions));
|
|
createRoute(app, 'post', '/api/retail/lift-value', (req) => analytics.liftValue(req.body.jointPurchaseRate, req.body.productAPurchaseRate, req.body.productBPurchaseRate));
|
|
createRoute(app, 'post', '/api/retail/cost-ratio', (req) => analytics.costRatio(req.body.cost, req.body.salePrice));
|
|
createRoute(app, 'post', '/api/retail/gross-margin', (req) => analytics.grossMarginRate(req.body.salePrice, req.body.cost));
|
|
|
|
createRoute(app, 'post', '/api/retail/average-spend', (req) => {
|
|
const { totalRevenue, numberOfCustomers } = req.body;
|
|
return analytics.averageSpendPerCustomer(totalRevenue, numberOfCustomers);
|
|
});
|
|
|
|
createRoute(app, 'post', '/api/retail/purchase-index', (req) => {
|
|
const { totalItemsSold, numberOfCustomers } = req.body;
|
|
return analytics.purchaseIndex(totalItemsSold, numberOfCustomers);
|
|
});
|
|
|
|
// ========================================
|
|
// ERROR HANDLING
|
|
// ========================================
|
|
|
|
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
console.error(err.stack);
|
|
res.status(500).json({ success: false, error: 'Internal server error' } as ApiResponse<any>);
|
|
});
|
|
|
|
// app.use('*', (req, res) => {
|
|
// res.status(404).json({ success: false, error: 'Endpoint not found' } as ApiResponse<any>);
|
|
// });
|
|
|
|
app.use('*', (req, res) => {
|
|
res.status(404).json({ success: false, error: 'Endpoint not found' });
|
|
});
|
|
|
|
// ========================================
|
|
// SERVER STARTUP
|
|
// ========================================
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
app.listen(PORT, () => {
|
|
console.log(`Analytics API server running on port ${PORT}`);
|
|
console.log(`Health check: http://localhost:${PORT}/api/health`);
|
|
console.log('\n=== Available Endpoints ===');
|
|
console.log('GET /api/health');
|
|
console.log('POST /api/mean');
|
|
console.log('POST /api/variance');
|
|
console.log('POST /api/ml/kmeans <-- uses external kmeans.ts');
|
|
console.log('POST /api/time/week-number <-- uses external time-helper.ts');
|
|
console.log('POST /api/time/same-day-last-year');
|
|
console.log('POST /api/series/moving-average');
|
|
console.log('... and more');
|
|
});
|
|
|
|
export default app; |