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:
- Config
- Load and clean data
- Train/validation split
- Metrics
- Train NHITS for evaluation
- Accuracy metrics
- Forecast visualization for one store
- Multi-store comparison
- Simple model tuning (vary INPUT_SIZE) and plot MAPE vs INPUT_SIZE
- 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 |
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}")
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}")
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%)
Tuning MAPE plot saved to: nhits_tuning_mape.png
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 [ ]: