ts-arima-forecast
Version:
TypeScript library for ARIMA, SARIMA (soon), and SARIMAX (soon) forecasting
441 lines (357 loc) • 13.2 kB
text/typescript
import { ARIMA } from "../models/arima";
interface ForecastResult {
forecasts: number[];
confidenceIntervals: { lower: number[]; upper: number[] };
}
export interface StepwiseForecastResult extends ForecastResult {
updatedModels: ARIMA[];
errors: number[];
actualValues: number[];
}
export interface RealTimeForecastResult extends ForecastResult {
errors?: number[];
actualValues?: number[];
}
export abstract class ForecastStrategy {
protected baseModel: ARIMA;
protected initialData: number[];
constructor(baseModel: ARIMA, initialData: number[]) {
this.baseModel = baseModel;
this.initialData = [...initialData];
}
abstract forecast(steps: number): ForecastResult;
abstract forecastWithRealTimeData(actualObservations: number[]): RealTimeForecastResult;
abstract addObservationAndForecast(newObservation: number): { forecast: number; lowerBound: number; upperBound: number; error?: number };
}
export class Stepwise extends ForecastStrategy {
private refitModel: boolean;
private verbose: boolean;
private currentData: number[];
private currentModel: ARIMA;
private lastForecast: { forecast: number; lowerBound: number; upperBound: number } | null = null;
constructor(
baseModel: ARIMA,
initialData: number[],
options: {
refitModel?: boolean;
verbose?: boolean;
} = {}
) {
super(baseModel, initialData);
this.refitModel = options.refitModel ?? true;
this.verbose = options.verbose ?? false;
this.currentData = [...initialData];
this.currentModel = baseModel;
}
forecast(steps: number): ForecastResult {
const result = this.currentModel.forecast(steps);
return {
forecasts: result.forecast,
confidenceIntervals: {
lower: result.lowerBound,
upper: result.upperBound
}
};
}
forecastWithRealTimeData(actualObservations: number[]): StepwiseForecastResult {
const steps = actualObservations.length;
const forecasts: number[] = [];
const lowerBounds: number[] = [];
const upperBounds: number[] = [];
const updatedModels: ARIMA[] = [];
const errors: number[] = [];
let workingData = [...this.currentData];
let workingModel = this.currentModel;
for (let step = 0; step < steps; step++) {
const oneStepForecast = workingModel.forecast(1);
const forecastValue = oneStepForecast.forecast[0];
const lowerBound = oneStepForecast.lowerBound[0];
const upperBound = oneStepForecast.upperBound[0];
forecasts.push(forecastValue);
lowerBounds.push(lowerBound);
upperBounds.push(upperBound);
const actualObservation = actualObservations[step];
const error = Math.abs(forecastValue - actualObservation);
errors.push(error);
workingData.push(actualObservation);
// refit model if enabled
if (this.refitModel) {
const updatedModel = new ARIMA(workingModel.getParams());
updatedModel.fit(workingData);
workingModel = updatedModel;
updatedModels.push(updatedModel);
}
if (this.verbose) {
console.log(`Step ${step + 1}: Forecast=${forecastValue.toFixed(3)}, Actual=${actualObservation.toFixed(3)}, Error=${error.toFixed(3)}`);
}
}
this.currentData = workingData;
this.currentModel = workingModel;
return {
forecasts,
confidenceIntervals: { lower: lowerBounds, upper: upperBounds },
updatedModels,
errors,
actualValues: actualObservations
};
}
addObservationAndForecast(newObservation: number): { forecast: number; lowerBound: number; upperBound: number; error?: number } {
let error: number | undefined;
if (this.lastForecast) {
error = Math.abs(this.lastForecast.forecast - newObservation);
if (this.verbose) {
console.log(`Previous forecast: ${this.lastForecast.forecast.toFixed(3)}, Actual observation: ${newObservation.toFixed(3)}, Error: ${error.toFixed(3)}`);
}
}
this.currentData.push(newObservation);
if (this.refitModel) {
const updatedModel = new ARIMA(this.currentModel.getParams());
updatedModel.fit(this.currentData);
this.currentModel = updatedModel;
}
const nextForecast = this.currentModel.forecast(1);
const forecastResult = {
forecast: nextForecast.forecast[0],
lowerBound: nextForecast.lowerBound[0],
upperBound: nextForecast.upperBound[0]
};
this.lastForecast = forecastResult;
if (this.verbose) {
console.log(`Next forecast: ${forecastResult.forecast.toFixed(3)}`);
}
return {
...forecastResult,
error
};
}
reset(newInitialData: number[]): void {
this.currentData = [...newInitialData];
this.currentModel = new ARIMA(this.baseModel.getParams());
this.currentModel.fit(this.currentData);
this.lastForecast = null;
}
getCurrentData(): number[] {
return [...this.currentData];
}
getCurrentModel(): ARIMA {
return this.currentModel;
}
}
export class RollingWindow extends ForecastStrategy {
private windowSize: number;
private verbose: boolean;
private currentData: number[];
private lastForecast: { forecast: number; lowerBound: number; upperBound: number } | null = null;
constructor(
baseModel: ARIMA,
initialData: number[],
options: {
windowSize?: number;
verbose?: boolean;
} = {}
) {
super(baseModel, initialData);
this.windowSize = options.windowSize ?? initialData.length;
this.verbose = options.verbose ?? false;
this.currentData = [...initialData];
}
forecast(steps: number): ForecastResult {
// Use current window to forecast multiple steps ahead
const windowData = this.currentData.slice(-this.windowSize);
const model = new ARIMA(this.baseModel.getParams());
model.fit(windowData);
const result = model.forecast(steps);
return {
forecasts: result.forecast,
confidenceIntervals: {
lower: result.lowerBound,
upper: result.upperBound
}
};
}
forecastWithRealTimeData(actualObservations: number[]): RealTimeForecastResult {
const steps = actualObservations.length;
const forecasts: number[] = [];
const lowerBounds: number[] = [];
const upperBounds: number[] = [];
const errors: number[] = [];
let workingData = [...this.currentData];
for (let step = 0; step < steps; step++) {
// Keep only the most recent observations (rolling window)
const windowData = workingData.slice(-this.windowSize);
// Fit model on current window
const model = new ARIMA(this.baseModel.getParams());
model.fit(windowData);
// Forecast one step ahead
const oneStepForecast = model.forecast(1);
const forecastValue = oneStepForecast.forecast[0];
const lowerBound = oneStepForecast.lowerBound[0];
const upperBound = oneStepForecast.upperBound[0];
// Store results
forecasts.push(forecastValue);
lowerBounds.push(lowerBound);
upperBounds.push(upperBound);
// Use actual observation and calculate error
const actualObservation = actualObservations[step];
const error = Math.abs(forecastValue - actualObservation);
errors.push(error);
workingData.push(actualObservation);
if (this.verbose) {
console.log(`Rolling Step ${step + 1}: Forecast=${forecastValue.toFixed(3)}, Actual=${actualObservation.toFixed(3)}, Error=${error.toFixed(3)}, Window Size=${windowData.length}`);
}
}
// Update internal state
this.currentData = workingData;
return {
forecasts,
confidenceIntervals: { lower: lowerBounds, upper: upperBounds },
errors,
actualValues: actualObservations
};
}
addObservationAndForecast(newObservation: number): { forecast: number; lowerBound: number; upperBound: number; error?: number } {
let error: number | undefined;
// Calculate error if we have a previous forecast
if (this.lastForecast) {
error = Math.abs(this.lastForecast.forecast - newObservation);
if (this.verbose) {
console.log(`Previous forecast: ${this.lastForecast.forecast.toFixed(3)}, Actual observation: ${newObservation.toFixed(3)}, Error: ${error.toFixed(3)}`);
}
}
// Add new observation
this.currentData.push(newObservation);
// Keep only the most recent observations (rolling window)
const windowData = this.currentData.slice(-this.windowSize);
// Fit model on current window
const model = new ARIMA(this.baseModel.getParams());
model.fit(windowData);
// Get next forecast
const nextForecast = model.forecast(1);
const forecastResult = {
forecast: nextForecast.forecast[0],
lowerBound: nextForecast.lowerBound[0],
upperBound: nextForecast.upperBound[0]
};
// Store this forecast for next error calculation
this.lastForecast = forecastResult;
if (this.verbose) {
console.log(`Next forecast: ${forecastResult.forecast.toFixed(3)}, Window Size: ${windowData.length}`);
}
return {
...forecastResult,
error
};
}
setWindowSize(newWindowSize: number): void {
this.windowSize = newWindowSize;
}
getWindowSize(): number {
return this.windowSize;
}
getCurrentData(): number[] {
return [...this.currentData];
}
reset(newInitialData: number[]): void {
this.currentData = [...newInitialData];
this.lastForecast = null;
}
}
export class Adaptive extends ForecastStrategy {
private stepwiseStrategy: Stepwise;
private rollingStrategy: RollingWindow;
private adaptationThreshold: number;
private errorWindow: number[];
private maxErrorWindowSize: number;
private currentStrategy: 'stepwise' | 'rolling';
private verbose: boolean;
constructor(
baseModel: ARIMA,
initialData: number[],
options: {
windowSize?: number;
adaptationThreshold?: number;
maxErrorWindowSize?: number;
verbose?: boolean;
} = {}
) {
super(baseModel, initialData);
this.stepwiseStrategy = new Stepwise(baseModel, initialData, {
refitModel: true,
verbose: options.verbose
});
this.rollingStrategy = new RollingWindow(baseModel, initialData, {
windowSize: options.windowSize,
verbose: options.verbose
});
this.adaptationThreshold = options.adaptationThreshold ?? 2.0;
this.maxErrorWindowSize = options.maxErrorWindowSize ?? 10;
this.errorWindow = [];
this.currentStrategy = 'stepwise';
this.verbose = options.verbose ?? false;
}
forecast(steps: number): ForecastResult {
if (this.currentStrategy === 'stepwise') {
return this.stepwiseStrategy.forecast(steps);
} else {
return this.rollingStrategy.forecast(steps);
}
}
forecastWithRealTimeData(actualObservations: number[]): RealTimeForecastResult {
const results = this.currentStrategy === 'stepwise'
? this.stepwiseStrategy.forecastWithRealTimeData(actualObservations)
: this.rollingStrategy.forecastWithRealTimeData(actualObservations);
if (results.errors) {
results.errors.forEach(error => this.addError(error));
if (this.shouldSwitchStrategy()) {
this.switchStrategy();
}
}
return results;
}
addObservationAndForecast(newObservation: number): { forecast: number; lowerBound: number; upperBound: number; error?: number } {
const result = this.currentStrategy === 'stepwise'
? this.stepwiseStrategy.addObservationAndForecast(newObservation)
: this.rollingStrategy.addObservationAndForecast(newObservation);
if (result.error !== undefined) {
this.addError(result.error);
if (this.shouldSwitchStrategy()) {
this.switchStrategy();
}
}
if (this.verbose) {
const errorText = result.error !== undefined ? `, Error: ${result.error.toFixed(3)}` : '';
console.log(`Adaptive strategy (${this.currentStrategy}): New observation: ${newObservation.toFixed(3)}, Next forecast: ${result.forecast.toFixed(3)}${errorText}`);
}
return result;
}
private shouldSwitchStrategy(): boolean {
if (this.errorWindow.length < this.maxErrorWindowSize) {
return false;
}
const recentAvgError = this.errorWindow.slice(-5).reduce((a, b) => a + b, 0) / 5;
return recentAvgError > this.adaptationThreshold;
}
private addError(error: number): void {
this.errorWindow.push(error);
if (this.errorWindow.length > this.maxErrorWindowSize) {
this.errorWindow.shift();
}
}
private switchStrategy(): void {
const oldStrategy = this.currentStrategy;
this.currentStrategy = this.currentStrategy === 'stepwise' ? 'rolling' : 'stepwise';
if (this.verbose) {
console.log(`Switching strategy from ${oldStrategy} to ${this.currentStrategy}`);
}
this.errorWindow = [];
}
getCurrentStrategy(): 'stepwise' | 'rolling' {
return this.currentStrategy;
}
reset(newInitialData: number[]): void {
this.stepwiseStrategy.reset(newInitialData);
this.rollingStrategy.reset(newInitialData);
this.errorWindow = [];
this.currentStrategy = 'stepwise';
}
}