Real-Time Sales Forecasting
Abstract
Section titled “Abstract”Sales are demand plus what the business does to it: a weekend lift and promotions. This project decomposes the series into level, weekly profile and promotion effect rather than fitting raw lags. It recovers the weekly shape and reaches MAE 15.5 against a same-day-last-week baseline’s 28.1, a 45.0% improvement — and shows why the obvious estimate of promotion lift is wrong by 52 units.
Prerequisites
Section titled “Prerequisites”- Python 3.8 or above
- A code editor or IDE
- Basic understanding of ML and analytics
- Required libraries:
pandas,scikit-learn,matplotlib
Before you Start
Section titled “Before you Start”Install Python and the required libraries:
pip install pandas scikit-learn matplotlibGetting Started
Section titled “Getting Started”Create a Project
Section titled “Create a Project”- Create a folder named
real-time-sales-forecasting. - Open the folder in your code editor or IDE.
- Create a file named
real_time_sales_forecasting.py. - Copy the code below into your file.
Write the Code
Section titled “Write the Code”Real-Time Sales Forecasting
pch.viewSource"""Real-time sales forecasting.
Sales are demand plus the things a business does to it: a weekend lift, a
promotion that pulls sales forward, and a hard floor at zero. This forecaster
decomposes the series into level, weekly profile and promotion effect rather
than fitting raw lags, and reports where each component earns its place.
"""
import matplotlib.pyplot as plt
import numpy as np
WEEK = 7
class SeasonalForecaster:
"""Level + multiplicative weekly profile + an additive promotion lift."""
def __init__(self, window=56):
self.window = window
self.history = []
self.promotions = []
def observe(self, value, promoted):
self.history.append(float(value))
self.promotions.append(bool(promoted))
def ready(self):
return len(self.history) >= 2 * WEEK
def _profile(self, values, start):
"""Average ratio to the local level, per day of week.
`start` is the absolute index of `values[0]`. Without it the day-of-week
buckets are taken from positions inside the window, which only lines up
when the window happens to begin on a Monday -- and silently returns a
flat, shifted profile the rest of the time.
"""
series = np.asarray(values, dtype=float)
level = series.mean()
profile = np.ones(WEEK)
days = (np.arange(len(series)) + start) % WEEK
for day in range(WEEK):
day_values = series[days == day]
if len(day_values):
profile[day] = day_values.mean() / level if level else 1.0
return level, profile
def _promotion_lift(self, naive=False):
"""Extra units on a promoted day.
The obvious estimator -- mean(promoted) minus mean(everything else) --
is confounded: promotions land on whatever weekday they land on, and a
Saturday sells 1.35x a Monday regardless. Dividing out the weekly
profile first removes that, and the two numbers are printed side by
side because the gap between them is the whole point.
"""
values = np.asarray(self.history[-self.window:], dtype=float)
flags = np.asarray(self.promotions[-self.window:], dtype=bool)
if not (flags.any() and (~flags).any()):
return 0.0
if naive:
return float(values[flags].mean() - values[~flags].mean())
start = len(self.history) - len(values)
level, profile = self._profile(values, start)
days = (np.arange(len(values)) + start) % WEEK
expected = level * profile[days]
residual = values - expected
return float(residual[flags].mean() - residual[~flags].mean())
def forecast(self, next_day_index, promoted):
if not self.ready():
return self.history[-1] if self.history else 0.0
window = self.history[-self.window:]
level, profile = self._profile(window, len(self.history) - len(window))
base = level * profile[next_day_index % WEEK]
if promoted:
base += self._promotion_lift()
return max(base, 0.0)
def sales_series(periods=180, seed=1):
rng = np.random.default_rng(seed)
day = np.arange(periods)
weekly = np.array([0.86, 0.90, 0.95, 1.00, 1.15, 1.35, 1.25])
level = 220 + 0.35 * day
promoted = rng.random(periods) < 0.15
values = level * weekly[day % WEEK] + promoted * 90.0
values = values + rng.normal(0, 14.0, periods)
return np.maximum(values, 0.0), promoted
def replay(values, promoted, forecaster):
errors, naive_errors, predictions = [], [], []
for index, (actual, promo) in enumerate(zip(values, promoted)):
predicted = forecaster.forecast(index, promo)
naive = (forecaster.history[-WEEK] if len(forecaster.history) >= WEEK
else (forecaster.history[-1] if forecaster.history else actual))
if forecaster.ready():
errors.append(abs(predicted - actual))
naive_errors.append(abs(naive - actual))
predictions.append((index, predicted))
forecaster.observe(actual, promo)
return np.asarray(errors), np.asarray(naive_errors), predictions
def main():
values, promoted = sales_series()
forecaster = SeasonalForecaster()
errors, naive_errors, predictions = replay(values, promoted, forecaster)
print("Real-Time Sales Forecasting")
print(f" days replayed : {len(values)}")
print(f" promoted days : {int(promoted.sum())}")
print(f" seasonal model MAE : {errors.mean():.3f}")
print(f" same-day-last-week MAE : {naive_errors.mean():.3f}")
gain = (naive_errors.mean() - errors.mean()) / naive_errors.mean()
print(f" improvement over that : {gain:.1%}")
naive_lift = forecaster._promotion_lift(naive=True)
adjusted_lift = forecaster._promotion_lift()
print(f" promotion lift, naive : {naive_lift:.1f} units/day")
print(f" promotion lift, adjusted : {adjusted_lift:.1f} units/day "
f"(true 90.0)")
print(f" naive is off by {abs(90.0 - naive_lift):.1f}, adjusted by "
f"{abs(90.0 - adjusted_lift):.1f} -- removing the weekly profile "
f"fixes most")
print(f" of the bias; the rest is that only "
f"{int(np.asarray(forecaster.promotions[-56:]).sum())} of the last 56"
f" days were promoted, which is a small sample")
tail = forecaster.history[-56:]
_, profile = forecaster._profile(tail, len(forecaster.history) - len(tail))
names = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
print("\n recovered weekly profile (1.00 = an average day):")
for name, factor in zip(names, profile):
print(f" {name} {factor:.3f}")
print(" the naive baseline is same-day-last-week, which already carries")
print(" the weekly shape -- so the gain above is what decomposition adds")
print(" ON TOP of seasonality, not the value of seasonality itself")
steps, predicted = zip(*predictions)
figure, axes = plt.subplots(1, 2, figsize=(9.6, 3.6),
width_ratios=(2.0, 1.0))
axes[0].plot(values, linewidth=1.1, label="actual sales")
axes[0].plot(steps, predicted, linewidth=1.1, label="forecast")
promo_days = np.flatnonzero(promoted)
axes[0].scatter(promo_days, values[promo_days], s=10, zorder=3,
label="promotion")
axes[0].set_xlabel("day")
axes[0].set_ylabel("units")
axes[0].set_title(f"MAE {errors.mean():.1f} against baseline "
f"{naive_errors.mean():.1f}")
axes[0].legend(fontsize=7)
axes[1].bar(names, profile)
axes[1].axhline(1.0, linestyle=":", linewidth=1.0)
axes[1].set_ylabel("multiplier")
axes[1].set_title("weekly profile, recovered")
figure.tight_layout()
plt.savefig("real_time_sales_forecasting.png", dpi=120,
bbox_inches="tight")
print("saved real_time_sales_forecasting.png")
if __name__ == "__main__":
main() Example Usage
Section titled “Example Usage”python real_time_sales_forecasting.pyWhat it produces
Section titled “What it produces”Running the file exactly as it ships takes 1.4 s and prints:
Real-Time Sales Forecasting
days replayed : 180
promoted days : 21
seasonal model MAE : 15.458
same-day-last-week MAE : 28.089
improvement over that : 45.0%
promotion lift, naive : 37.8 units/day
promotion lift, adjusted : 73.6 units/day (true 90.0)
naive is off by 52.2, adjusted by 16.4 -- removing the weekly profile fixes most
of the bias; the rest is that only 6 of the last 56 days were promoted, which is a small sample
recovered weekly profile (1.00 = an average day):
Mon 0.884
Tue 0.859
Wed 0.889
Thu 0.956
Fri 1.046
Sat 1.227
Sun 1.137
the naive baseline is same-day-last-week, which already carries
...The first 20 of 23 lines are shown; the run continues past this point.
How it fits together
Section titled “How it fits together”Read from the top: this is what runs when you execute the file, and which function calls which. It is generated from the code, so it cannot drift from it.
flowchart TD RUN(["python real_time_sales_forecasting.py"]) SeasonalForecaster["SeasonalForecaster
class"] sales_series("sales_series") replay("replay") main("main") RUN --> main main --> SeasonalForecaster main --> replay main --> sales_series
Explanation
Section titled “Explanation”Key Features
Section titled “Key Features”- Seasonal decomposition: level, a seven-day multiplicative profile, and an additive promotion lift, each estimated separately.
- A hard baseline: same-day-last-week already carries the weekly shape, so the 45.0% gain is what decomposition adds on top of seasonality.
- Confounding, measured: the naive promotion lift reads 37.8 units against a true 90.0, because promotions land on whatever weekday they land on. De-seasonalising first brings it to 73.6.
- Day-of-week alignment: the profile is indexed by absolute day, not by position in the window — the bug that silently returns a flat, shifted profile.
Code Breakdown
Section titled “Code Breakdown”- What it imports (lines 9–10)
import matplotlib.pyplot as plt
import numpy as npSeasonalForecaster— the class (lines 15–78)
class SeasonalForecaster:
"""Level + multiplicative weekly profile + an additive promotion lift."""
def __init__(self, window=56):
self.window = window
self.history = []
self.promotions = []
def observe(self, value, promoted):
self.history.append(float(value))
self.promotions.append(bool(promoted))
def ready(self):
return len(self.history) >= 2 * WEEK
def _profile(self, values, start):
"""Average ratio to the local level, per day of week.
# ... 40 more lines in the file ...
window = self.history[-self.window:]
level, profile = self._profile(window, len(self.history) - len(window))
base = level * profile[next_day_index % WEEK]
if promoted:
base += self._promotion_lift()
return max(base, 0.0)sales_series— the function (lines 81–89)
def sales_series(periods=180, seed=1):
rng = np.random.default_rng(seed)
day = np.arange(periods)
weekly = np.array([0.86, 0.90, 0.95, 1.00, 1.15, 1.35, 1.25])
level = 220 + 0.35 * day
promoted = rng.random(periods) < 0.15
values = level * weekly[day % WEEK] + promoted * 90.0
values = values + rng.normal(0, 14.0, periods)
return np.maximum(values, 0.0), promotedreplay— the function (lines 92–103)
def replay(values, promoted, forecaster):
errors, naive_errors, predictions = [], [], []
for index, (actual, promo) in enumerate(zip(values, promoted)):
predicted = forecaster.forecast(index, promo)
naive = (forecaster.history[-WEEK] if len(forecaster.history) >= WEEK
else (forecaster.history[-1] if forecaster.history else actual))
if forecaster.ready():
errors.append(abs(predicted - actual))
naive_errors.append(abs(naive - actual))
predictions.append((index, predicted))
forecaster.observe(actual, promo)
return np.asarray(errors), np.asarray(naive_errors), predictionsmain— the function (lines 106–161)
def main():
values, promoted = sales_series()
forecaster = SeasonalForecaster()
errors, naive_errors, predictions = replay(values, promoted, forecaster)
print("Real-Time Sales Forecasting")
print(f" days replayed : {len(values)}")
print(f" promoted days : {int(promoted.sum())}")
print(f" seasonal model MAE : {errors.mean():.3f}")
print(f" same-day-last-week MAE : {naive_errors.mean():.3f}")
gain = (naive_errors.mean() - errors.mean()) / naive_errors.mean()
print(f" improvement over that : {gain:.1%}")
naive_lift = forecaster._promotion_lift(naive=True)
adjusted_lift = forecaster._promotion_lift()
print(f" promotion lift, naive : {naive_lift:.1f} units/day")
print(f" promotion lift, adjusted : {adjusted_lift:.1f} units/day "
f"(true 90.0)")
print(f" naive is off by {abs(90.0 - naive_lift):.1f}, adjusted by "
# ... 32 more lines in the file ...
axes[1].set_ylabel("multiplier")
axes[1].set_title("weekly profile, recovered")
figure.tight_layout()
plt.savefig("real_time_sales_forecasting.png", dpi=120,
bbox_inches="tight")
print("saved real_time_sales_forecasting.png")The file defines 4 top-level symbols in all; the whole thing is above under Write the Code.
Features
Section titled “Features”- Sales Forecasting: Real-time data preprocessing and forecasting
- Modular Design: Separate functions for each task
- Error Handling: Manages invalid inputs and exceptions
- Production-Ready: Scalable and maintainable code
Next Steps
Section titled “Next Steps”Enhance the project by:
- Integrating with more sales APIs
- Supporting advanced ML models
- Creating a GUI for forecasting
- Adding real-time analytics
- Unit testing for reliability
Educational Value
Section titled “Educational Value”This project teaches:
- Decomposition: separating level, season and event effects.
- Confounded estimates: why a difference in means is not a causal effect when the groups differ in another way.
- Choosing a baseline that is hard to beat, so the reported gain means something.
Real-World Applications
Section titled “Real-World Applications”- E-commerce Platforms
- Analytics Tools
- Forecasting Engines
Conclusion
Section titled “Conclusion”Real-Time Sales Forecasting demonstrates how to build a scalable and accurate sales forecasting tool using Python. With modular design and extensibility, this project can be adapted for real-world applications in e-commerce, analytics, and more. For more advanced projects, visit Python Central Hub.
Pitfalls
Section titled “Pitfalls”- The naive promotion lift is inflated by whatever else happens on promo days. The shipped project measures 37.8 units/day naive against a true 90.0; in the exercise below, where promotions run on weekends, the naive estimate reads 132.8 against a true 90.0 — wrong by +42.8, because it is measuring the weekend too.
- Removing the weekly profile fixes most of the bias, not all of it. The project’s adjusted estimate is 73.6 against a true 90.0 — still off by 16.4, and the reason is stated rather than hidden: only 6 of the last 56 days were promoted, which is a small sample.
- Beating a bad baseline proves nothing. The project’s baseline is same-day-last-week, which already carries the weekly shape. The 45.0% improvement is therefore what decomposition adds on top of seasonality, not the value of seasonality itself.
- Confounding cannot be modelled away. If every weekend had been promoted, there would be no unpromoted Saturday in the data and the weekend effect and the promotion effect would be mathematically inseparable. That is fixed when the promotions are scheduled or not at all.
- MAE is in units, so it is only meaningful next to the scale. 15.458 on a series averaging ~200/day is about 8%; the same number on a series averaging 20 would be catastrophic.
- Measured: 180 days replayed, 21 promoted, seasonal model MAE 15.458 against same-day-last-week 28.089 — a 45.0% improvement.
- Recovered weekly profile: Sat 1.227, Sun 1.137, Tue 0.859. The weekend is roughly 40% busier than a Tuesday.
- Naive lift 37.8, adjusted 73.6, true 90.0. Decomposition removes most of the bias and the remainder is a sample-size problem.
- A forecast comparison needs the baseline stated. “45% better” is only a number once you know 45% better than what.
-
Promotions ran on weekends, and the naive lift estimate came out at 132.8 against a true 90.0. What is it actually measuring?
pch.quizShowAnswer
B — The promotion effect plus the weekend effect, because promoted days differ from normal days in more than one way
-
The project improves on same-day-last-week by 45.0%. Why does the baseline matter to reading that number?
pch.quizShowAnswer
B — Same-day-last-week already carries the weekly shape, so the 45% is what decomposition adds on top of seasonality — against a flat mean baseline the same model would look far better and mean less
-
If every single weekend had been promoted, what could the model say about the promotion effect?
pch.quizShowAnswer
B — Nothing separable — with no unpromoted Saturday anywhere in the data, the weekend effect and the promotion effect are the same column, and no method distinguishes them
Try it yourself
Section titled “Try it yourself”pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading