NHITS Sales Forecast System¶

===============================¶

Dataset:¶

Synthetic sales data for 14 fictional stores, covering the period from January 2021 to December 2025, showing monthly sales and seasonality.

The process:¶

This process resembles what I do professionally, only in this case I am using fully synthetic data. The goal is to forecast 2026 sales for each store based on their historical data covering the period from January 2021 to December 2025.

Steps:

  1. Config
  2. Load and clean data
  3. Train/validation split
  4. Metrics
  5. Train NHITS for evaluation
  6. Accuracy metrics
  7. Forecast visualization for one store
  8. Multi-store comparison
  9. Simple model tuning (vary INPUT_SIZE) and plot MAPE vs INPUT_SIZE
  10. Final model for 2026 forecast

In [ ]:
# install neuralforecast
!pip install neuralforecast
In [1]:
# imports
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from neuralforecast import NeuralForecast
from neuralforecast.models import NHITS
from neuralforecast.losses.pytorch import MAE

0. Config¶

In [27]:
DATA_FILE = "synthetic_sales_data.xlsx"
FORECAST_OUTPUT_FILE = "sales_forecast_2026.xlsx"
METRICS_OUTPUT_FILE = "nhits_2025_metrics_by_store.xlsx"
STORE_PLOT_FILE = "nhits_store_forecast.png"
MULTISTORE_PLOT_FILE = "nhits_multistore_2025_actual_vs_pred.png"
TUNING_PLOT_FILE = "nhits_tuning_mape.png"

EVAL_CUTOFF = pd.Timestamp("2025-01-01")  # train < 2025, eval >= 2025
HORIZON_EVAL = 12                         # Jan–Dec 2025
HORIZON_FORECAST = 12                     # full 2026
INPUT_SIZE = 24                           # initial lookback for eval model
MAX_STEPS = 500
In [28]:
# Choose a store to visualize in detail
STORE_TO_PLOT = "Westside Market"

1. Load and clean data¶

In [29]:
df = pd.read_excel(DATA_FILE)
In [30]:
print(df["Store Name"].unique())
['Downtown Plaza' 'Westside Market' 'Eastgate Center' 'Northfield Mall'
 'Southside Pavilion' 'Riverside Commons' 'Highland Square'
 'Lakefront Station' 'Metro Hub' 'Suburban Crossing' 'Coastal Boulevard'
 'Mountain View' 'Valley Junction' 'Central Park']
In [31]:
df.head()
Out[31]:
Store Name Sales Amt Year Month
0 Downtown Plaza 282318.93 2021 January
1 Westside Market 254014.86 2021 January
2 Eastgate Center 180526.71 2021 January
3 Northfield Mall 184943.48 2021 January
4 Southside Pavilion 181907.71 2021 January
In [32]:
# Build date column from Year + Month
df['ds'] = pd.to_datetime(
    df['Month'] + ' ' + df['Year'].astype(str),
    format='%B %Y'
)

# Sort by Store Name and date
df = df.sort_values(['Store Name', 'ds'])

# Format for NeuralForecast
df_nf = df.rename(columns={
    'Store Name': 'unique_id',
    'Sales Amt': 'y'
})[['unique_id', 'ds', 'y']]

# Remove any null values
df_nf = df_nf.dropna(subset=['unique_id', 'ds', 'y'])
In [36]:
df_nf.head()
Out[36]:
unique_id ds y
13 Central Park 2021-01-01 53548.10
27 Central Park 2021-02-01 63083.29
41 Central Park 2021-03-01 50172.47
55 Central Park 2021-04-01 50785.89
69 Central Park 2021-05-01 80285.88

2. Train/Validation split¶

2021-2024 = train | 2025 = validation

In [34]:
train_df = df_nf[df_nf['ds'] < EVAL_CUTOFF].copy()
valid_df = df_nf[df_nf['ds'] >= EVAL_CUTOFF].copy()  # 2025 actuals (Jan–Dec)

print("Train range:", train_df['ds'].min(), "to", train_df['ds'].max())
print("Valid  range:", valid_df['ds'].min(), "to", valid_df['ds'].max())
Train range: 2021-01-01 00:00:00 to 2024-12-01 00:00:00
Valid  range: 2025-01-01 00:00:00 to 2025-12-01 00:00:00

3. Metrics¶

In [37]:
def mape(y_true, y_pred):
    y_true = np.asarray(y_true, dtype=float)
    y_pred = np.asarray(y_pred, dtype=float)
    mask = y_true != 0
    return np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100

def rmse(y_true, y_pred):
    y_true = np.asarray(y_true, dtype=float)
    y_pred = np.asarray(y_pred, dtype=float)
    return np.sqrt(np.mean((y_true - y_pred) ** 2))

4. Train NHITS for evaluation (predict 2025 from 2021–2024)¶

In [38]:
print("\n=== Training evaluation model (predict 2025) ===")

eval_model = NHITS(
    h=HORIZON_EVAL,
    input_size=INPUT_SIZE,
    loss=MAE(),
    max_steps=MAX_STEPS,
    start_padding_enabled=True
)

nf_eval = NeuralForecast(
    models=[eval_model],
    freq='MS'
)

nf_eval.fit(df=train_df)

# Forecast 2025 (12 months ahead: Jan–Dec)
fcst_valid = nf_eval.predict()  # shape: [unique_id, ds, NHITS]
fcst_valid = fcst_valid.rename(columns={"NHITS": "y_pred"})

# Merge predictions with actual 2025
valid_merged = valid_df.merge(
    fcst_valid[['unique_id', 'ds', 'y_pred']],
    on=['unique_id', 'ds'],
    how='inner'
)
Predicting ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1/1 0:00:00 • 0:00:00 0.00it/s  



5. Accuracy metrics¶

In [40]:
overall_mape = mape(valid_merged['y'], valid_merged['y_pred'])
overall_rmse = rmse(valid_merged['y'], valid_merged['y_pred'])

print("\n=== Overall 2025 accuracy (Jan–Dec, evaluated on hold-out year) ===")
print(f"MAPE: {overall_mape:.2f}%")
print(f"RMSE: {overall_rmse:,.2f}")

# Per-store metrics
store_metrics = (
    valid_merged
    .groupby('unique_id')
    .apply(lambda g: pd.Series({
        'MAPE': mape(g['y'], g['y_pred']),
        'RMSE': rmse(g['y'], g['y_pred'])
    }), include_groups=False)
    .reset_index()
)

store_metrics.to_excel(METRICS_OUTPUT_FILE, index=False)
print(f"\nPer-store metrics saved to: {METRICS_OUTPUT_FILE}")
=== Overall 2025 accuracy (Jan–Dec, evaluated on hold-out year) ===
MAPE: 7.50%
RMSE: 15,133.80

Per-store metrics saved to: nhits_2025_metrics_by_store.xlsx

6. Forecast visualization for one store (2025 + history)¶

In [42]:
store_hist = df_nf[df_nf['unique_id'] == STORE_TO_PLOT]
store_valid = valid_merged[valid_merged['unique_id'] == STORE_TO_PLOT]

plt.figure(figsize=(10, 5))
plt.plot(store_hist['ds'], store_hist['y'], label="Actual (History 2021–2025)")
plt.plot(store_valid['ds'], store_valid['y_pred'], label="Predicted (2025)", linestyle="--")
plt.axvline(EVAL_CUTOFF, color="gray", linestyle=":", label="Eval cutoff (2025-01-01)")

plt.title(f"NHITS forecast vs actuals for store: {STORE_TO_PLOT}")
plt.xlabel("Date")
plt.ylabel("Sales")
plt.legend()
plt.tight_layout()
plt.savefig(STORE_PLOT_FILE, dpi=150)
plt.show()
print(f"Store-level forecast plot saved to: {STORE_PLOT_FILE}")
No description has been provided for this image
Store-level forecast plot saved to: nhits_store_forecast.png

7. Multi-store comparison (2025 actual vs predicted totals)¶

In [43]:
store_totals = (
    valid_merged
    .groupby('unique_id')
    .agg(actual_2025=('y', 'sum'),
         predicted_2025=('y_pred', 'sum'))
    .reset_index()
)

store_totals = store_totals.sort_values('actual_2025', ascending=False)

plt.figure(figsize=(12, 6))
x = np.arange(len(store_totals))
width = 0.4

plt.bar(x - width/2, store_totals['actual_2025'], width, label='Actual 2025 (Jan–Nov)')
plt.bar(x + width/2, store_totals['predicted_2025'], width, label='Predicted 2025 (Jan–Nov)')

plt.xticks(x, store_totals['unique_id'], rotation=45, ha='right')
plt.ylabel("Total Sales (2025 Jan–Nov)")
plt.title("Actual vs Predicted 2025 Sales by Store")
plt.legend()
plt.tight_layout()
plt.savefig(MULTISTORE_PLOT_FILE, dpi=150)
plt.show()
print(f"Multi-store comparison plot saved to: {MULTISTORE_PLOT_FILE}")
No description has been provided for this image
Multi-store comparison plot saved to: nhits_multistore_2025_actual_vs_pred.png

8. Simple model tuning (vary INPUT_SIZE) and plot MAPE vs INPUT_SIZE¶

In [44]:
# Simple tuning: try different INPUT_SIZE values
candidate_input_sizes = [12, 18, 24, 30]

tuning_results = []

for inp_size in candidate_input_sizes:
    print(f"\nTraining model with INPUT_SIZE={inp_size} ...")
    model_tune = NHITS(
        h=HORIZON_EVAL,
        input_size=inp_size,
        loss=MAE(),
        max_steps=300,              # fewer steps for faster tuning
        start_padding_enabled=True
    )
    nf_tune = NeuralForecast(models=[model_tune], freq='MS')
    nf_tune.fit(df=train_df)

    fcst_tune = nf_tune.predict().rename(columns={"NHITS": "y_pred"})
    merged_tune = valid_df.merge(
        fcst_tune[['unique_id', 'ds', 'y_pred']],
        on=['unique_id', 'ds'],
        how='inner'
    )

    m = mape(merged_tune['y'], merged_tune['y_pred'])
    r = rmse(merged_tune['y'], merged_tune['y_pred'])
    tuning_results.append((inp_size, m, r))
    print(f"INPUT_SIZE={inp_size} -> MAPE={m:.2f}%, RMSE={r:,.2f}")

tuning_df = pd.DataFrame(tuning_results,
                         columns=['INPUT_SIZE', 'MAPE', 'RMSE'])
print("\nTuning summary:")
print(tuning_df)

best_row = tuning_df.loc[tuning_df['MAPE'].idxmin()]
print(f"\nBest INPUT_SIZE by MAPE: {int(best_row['INPUT_SIZE'])} "
      f"(MAPE={best_row['MAPE']:.2f}%)")

# "Loss plot": MAPE vs INPUT_SIZE
plt.figure(figsize=(8, 4))
plt.plot(tuning_df['INPUT_SIZE'], tuning_df['MAPE'], marker='o')
plt.xlabel("INPUT_SIZE (months of history)")
plt.ylabel("MAPE on 2025 (Jan–Dec)")
plt.title("NHITS tuning: INPUT_SIZE vs MAPE")
plt.grid(True, linestyle=':')
plt.tight_layout()
plt.savefig(TUNING_PLOT_FILE, dpi=150)
plt.show()
print(f"Tuning MAPE plot saved to: {TUNING_PLOT_FILE}")
Predicting ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1/1 0:00:00 • 0:00:00 0.00it/s  


INPUT_SIZE=30 -> MAPE=7.18%, RMSE=14,520.98

Tuning summary:
   INPUT_SIZE      MAPE          RMSE
0          12  6.866587  14129.745768
1          18  7.263068  13918.242754
2          24  7.141234  14688.005334
3          30  7.184215  14520.983352

Best INPUT_SIZE by MAPE: 12 (MAPE=6.87%)
No description has been provided for this image
Tuning MAPE plot saved to: nhits_tuning_mape.png

9. Final model for 2026 forecast¶

Train on full 2021–2025 data, using best INPUT_SIZE

In [47]:
BEST_INPUT_SIZE = int(best_row['INPUT_SIZE'])

# Training final model on full data with INPUT_SIZE={BEST_INPUT_SIZE}

final_model = NHITS(
    h=HORIZON_FORECAST,
    input_size=BEST_INPUT_SIZE,
    loss=MAE(),
    max_steps=MAX_STEPS,
    start_padding_enabled=True
)

nf_final = NeuralForecast(models=[final_model], freq='MS')
nf_final.fit(df=df_nf)

fcst_full = nf_final.predict()   # future 12 months from last obs (2025-12)
# Keep only year 2026 forecasts (Jan–Dec)
fcst_2026 = fcst_full[fcst_full['ds'].dt.year == 2026].copy()

fcst_2026['Store Name'] = fcst_2026['unique_id']
fcst_2026['Year'] = fcst_2026['ds'].dt.year
fcst_2026['Month'] = fcst_2026['ds'].dt.strftime('%b')
fcst_2026['Forecast Sales Amt'] = fcst_2026['NHITS']

output_df = fcst_2026[[
    'Store Name', 'Year', 'Month', 'Forecast Sales Amt'
]].sort_values(['Store Name', 'Year', 'Month'])

output_df.to_excel(FORECAST_OUTPUT_FILE, index=False)
print(f"\nFinal 2026 forecasts saved to: {FORECAST_OUTPUT_FILE}")

print("All done.")
Predicting ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1/1 0:00:00 • 0:00:00 0.00it/s  


Final 2026 forecasts saved to: sales_forecast_2026.xlsx
All done.
In [ ]: