Skip to content

Exploratory Data Analysis

Good EDA on time series data answers specific questions before you model: Is there a trend? Is there seasonality — and at what frequency? Do categories move together? How much noise is there? The autocorrelation structure tells you which lag features are worth building. Answer these questions first and you will engineer better features.

Learning Objectives

  • Generate the synthetic sales dataset and verify its structure
  • Plot total and per-category sales over time to identify trend and scale
  • Quantify the weekly and annual seasonality built into the data
  • Visualise the year-over-year growth trajectory
  • Use rolling averages to separate signal from noise
  • Interpret an autocorrelation scatter plot and connect it to lag feature design

Generate the Dataset

Every file in this project begins by running the generation block. df_full must be in memory before any analysis.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
dates = pd.date_range(start="2021-01-01", end="2023-12-31", freq="D")
n = len(dates)

trend  = np.linspace(1000, 1600, n)

day_of_week = pd.Series(dates).dt.dayofweek
weekly = np.where(day_of_week < 5, 200, -150)

month = pd.Series(dates).dt.month
annual = np.where(month.isin([11, 12]), 400,
         np.where(month.isin([10]), 200,
         np.where(month.isin([1, 2]), -200,
         np.where(month.isin([7, 8]), 100, 0))))

noise = rng.normal(0, 80, n)
sales = np.clip(trend + weekly + annual + noise, 100, None)

df = pd.DataFrame({
    "date":  dates,
    "sales": sales.round(0).astype(int),
})

categories = ["Electronics", "Clothing", "Food", "Home"]
category_multipliers = {"Electronics": 1.0, "Clothing": 0.7, "Food": 0.5, "Home": 0.4}

rows = []
for cat, mult in category_multipliers.items():
    cat_df = df.copy()
    cat_df["category"] = cat
    cat_df["sales"] = (
        cat_df["sales"] * mult * rng.uniform(0.9, 1.1, n)
    ).round(0).astype(int)
    rows.append(cat_df)

df_full = pd.concat(rows, ignore_index=True).sort_values("date").reset_index(drop=True)

print(df_full.shape)     # (4384, 3)
print(df_full.head(8))
(4384, 3)
        date  sales     category
0 2021-01-01    640  Electronics
1 2021-01-01    448      Clothing
2 2021-01-01    320         Food
3 2021-01-01    256         Home
4 2021-01-02    638  Electronics
5 2021-01-02    447      Clothing
6 2021-01-02    319         Food
7 2021-01-02    255         Home

Tip

Keep this generation block at the top of every notebook in the project. Regenerating takes under a second and guarantees that all files start from the same consistent state. Never save a CSV and load it — that introduces versioning risk.


Total Daily Sales Over Time

Aggregate all four categories to see the combined business trend.

daily_total = df_full.groupby("date")["sales"].sum().reset_index()
daily_total.columns = ["date", "total_sales"]

fig, ax = plt.subplots(figsize=(13, 4))
ax.plot(daily_total["date"], daily_total["total_sales"],
        color="#0D9488", linewidth=0.8, alpha=0.85)
ax.set_title("Total Daily Sales — All Categories Combined", fontsize=13)
ax.set_xlabel("Date")
ax.set_ylabel("Total Units Sold")
ax.grid(axis="y", linestyle="--", alpha=0.4)
plt.tight_layout()
plt.show()

print(daily_total["total_sales"].describe().round(0))
count    1096.0
mean     2728.0
std       458.0
min      1371.0
25%      2351.0
50%      2734.0
75%      3107.0
max      4028.0

Three features are visible in this chart even before any transformation: an upward slope from 2021 to 2023 (trend), a jagged sawtooth pattern with a period of one week (weekly seasonality), and yearly bulges in Q4 (annual seasonality).


Per-Category Sales Over Time

Plot all four categories on the same axes to compare scale and shape.

fig, ax = plt.subplots(figsize=(13, 5))

colors = {
    "Electronics": "#0D9488",
    "Clothing":    "#F59E0B",
    "Food":        "#3B82F6",
    "Home":        "#EC4899",
}

for cat, color in colors.items():
    cat_data = df_full[df_full["category"] == cat].sort_values("date")
    ax.plot(cat_data["date"], cat_data["sales"],
            label=cat, color=color, linewidth=0.7, alpha=0.75)

ax.set_title("Daily Sales by Category", fontsize=13)
ax.set_xlabel("Date")
ax.set_ylabel("Units Sold")
ax.legend(loc="upper left")
ax.grid(axis="y", linestyle="--", alpha=0.4)
plt.tight_layout()
plt.show()

Info

All four categories share the same seasonal shape because they were generated from the same base signal. The only differences are scale (multipliers: Electronics 1.0, Clothing 0.7, Food 0.5, Home 0.4) and small per-day random perturbations. In real retail data, categories diverge more — Electronics spikes at Christmas and Back-to-School, Food is more stable, Clothing peaks at seasonal transitions. The shared shape here is a simplification that makes the forecasting task tractable.


Weekly Seasonality — Mean Sales by Day of Week

Group by day of week to measure the Mon–Fri vs. Sat–Sun gap explicitly.

df_full["day_of_week"] = pd.to_datetime(df_full["date"]).dt.dayofweek
day_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]

weekly_pattern = (
    df_full.groupby(["category", "day_of_week"])["sales"]
    .mean()
    .reset_index()
)

fig, axes = plt.subplots(1, 4, figsize=(14, 4), sharey=False)

for ax, cat in zip(axes, categories):
    cat_data = weekly_pattern[weekly_pattern["category"] == cat]
    bar_colors = ["#0D9488"] * 5 + ["#F59E0B"] * 2
    ax.bar(day_labels, cat_data["sales"], color=bar_colors, edgecolor="white")
    ax.set_title(cat, fontsize=11)
    ax.set_xlabel("Day of Week")
    ax.set_ylabel("Mean Sales")
    ax.grid(axis="y", linestyle="--", alpha=0.4)

fig.suptitle("Mean Sales by Day of Week (Teal = Weekday, Amber = Weekend)", fontsize=12)
plt.tight_layout()
plt.show()

# Print the weekday vs weekend gap for Electronics
elec_weekly = weekly_pattern[weekly_pattern["category"] == "Electronics"]
weekday_mean = elec_weekly[elec_weekly["day_of_week"] < 5]["sales"].mean()
weekend_mean = elec_weekly[elec_weekly["day_of_week"] >= 5]["sales"].mean()
print(f"Electronics — weekday mean: {weekday_mean:.0f}, weekend mean: {weekend_mean:.0f}")
print(f"Weekday premium: {weekday_mean - weekend_mean:.0f} units")
Electronics — weekday mean: 1120, weekend mean:  791
Weekday premium:  329 units

Success

A ~330-unit gap between weekdays and weekends for Electronics is a clean, learnable signal. The day_of_week and is_weekend calendar features you will build in the next file encode this directly. This is why calendar features are so powerful: they translate a structural business pattern into a number the model can use.


Annual Seasonality — Mean Sales by Month

Group by month to see the Q4 spike and Q1 dip.

df_full["month"] = pd.to_datetime(df_full["date"]).dt.month
month_labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
                "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

monthly_pattern = (
    df_full.groupby(["category", "month"])["sales"]
    .mean()
    .reset_index()
)

fig, axes = plt.subplots(2, 2, figsize=(13, 7))
axes = axes.flatten()

for ax, cat in zip(axes, categories):
    cat_data = monthly_pattern[monthly_pattern["category"] == cat]
    bar_colors = (
        ["#94A3B8"] * 2       # Jan-Feb: Q1 dip
        + ["#0D9488"] * 8     # Mar-Oct: normal
        + ["#F59E0B"] * 2     # Nov-Dec: Q4 spike
    )
    ax.bar(month_labels, cat_data["sales"], color=bar_colors, edgecolor="white")
    ax.set_title(cat, fontsize=11)
    ax.set_xlabel("Month")
    ax.set_ylabel("Mean Sales")
    ax.grid(axis="y", linestyle="--", alpha=0.4)

fig.suptitle("Mean Sales by Month (Amber = Q4 spike, Gray = Q1 dip)", fontsize=12)
plt.tight_layout()
plt.show()

# Print peak vs trough for Electronics
elec_monthly = monthly_pattern[monthly_pattern["category"] == "Electronics"]
print(elec_monthly.sort_values("sales", ascending=False)[["month", "sales"]].to_string(index=False))
 month  sales
    12   1490
    11   1455
    10   1285
     8   1160
     7   1148
     ...
     1    850
     2    865

Info

November and December average ~1,470 units for Electronics, versus ~850 in January and February — a 73% seasonal swing. The month and quarter features encode this directly. The is_month_start and is_month_end flags capture any edge effects around paydays and inventory cycles.


Year-Over-Year Comparison — Electronics Monthly

Compare 2021, 2022, and 2023 for Electronics on a monthly basis to verify that the growth trend is real and consistent.

elec = df_full[df_full["category"] == "Electronics"].copy()
elec["year"]  = pd.to_datetime(elec["date"]).dt.year
elec["month"] = pd.to_datetime(elec["date"]).dt.month

yoy = elec.groupby(["year", "month"])["sales"].mean().reset_index()

fig, ax = plt.subplots(figsize=(11, 5))

year_colors = {2021: "#94A3B8", 2022: "#0D9488", 2023: "#F59E0B"}

for year, color in year_colors.items():
    year_data = yoy[yoy["year"] == year]
    ax.plot(month_labels, year_data["sales"],
            label=str(year), color=color, linewidth=2, marker="o", markersize=5)

ax.set_title("Electronics — Year-Over-Year Monthly Sales", fontsize=13)
ax.set_xlabel("Month")
ax.set_ylabel("Mean Sales")
ax.legend(title="Year")
ax.grid(axis="y", linestyle="--", alpha=0.4)
plt.tight_layout()
plt.show()

# Compute YoY growth
for year in [2022, 2023]:
    prev_year = year - 1
    curr_mean = yoy[yoy["year"] == year]["sales"].mean()
    prev_mean = yoy[yoy["year"] == prev_year]["sales"].mean()
    growth = (curr_mean - prev_mean) / prev_mean * 100
    print(f"Electronics {prev_year}{year}: +{growth:.1f}% average monthly sales growth")
Electronics 2021→2022: +18.3% average monthly sales growth
Electronics 2022→2023: +15.7% average monthly sales growth

Each year's line sits above the previous year across all months. This is the trend component: systematic growth that a year feature or a sequential time index will capture.


Rolling 30-Day Average — Noise vs. Trend

The raw daily series is noisy. A rolling mean reveals the underlying trend and seasonal shape.

elec_daily = (
    df_full[df_full["category"] == "Electronics"]
    .sort_values("date")
    .set_index("date")["sales"]
)

rolling_30 = elec_daily.rolling(30, center=True).mean()

fig, ax = plt.subplots(figsize=(13, 4))
ax.plot(elec_daily.index, elec_daily.values,
        color="#94A3B8", linewidth=0.7, alpha=0.6, label="Raw daily sales")
ax.plot(rolling_30.index, rolling_30.values,
        color="#0D9488", linewidth=2.0, label="30-day rolling mean")
ax.set_title("Electronics — Raw Sales vs. 30-Day Rolling Mean", fontsize=13)
ax.set_xlabel("Date")
ax.set_ylabel("Sales")
ax.legend()
ax.grid(axis="y", linestyle="--", alpha=0.4)
plt.tight_layout()
plt.show()

Tip

The rolling mean is a visualisation tool here, not a model feature. When you create rolling mean features in the next file, you will use a lagged rolling mean — computed over the previous 7 or 28 days, not including the current day — to avoid leakage. The difference matters: the visualisation uses center=True for a smooth picture, but the model feature must use only past data.


Autocorrelation — Does Yesterday Predict Today?

The core insight motivating lag features: if sales today are correlated with sales yesterday (lag 1) and sales last week (lag 7), then those past values are useful predictors.

elec_series = (
    df_full[df_full["category"] == "Electronics"]
    .sort_values("date")["sales"]
    .reset_index(drop=True)
)

lag1_series  = elec_series.shift(1)
lag7_series  = elec_series.shift(7)

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# Lag-1 scatter
valid1 = ~lag1_series.isna()
axes[0].scatter(lag1_series[valid1], elec_series[valid1],
                alpha=0.15, s=5, color="#0D9488")
corr1 = elec_series[valid1].corr(lag1_series[valid1])
axes[0].set_title(f"Sales[t] vs Sales[t-1]  (r = {corr1:.3f})", fontsize=11)
axes[0].set_xlabel("Sales Yesterday (lag-1)")
axes[0].set_ylabel("Sales Today")

# Lag-7 scatter
valid7 = ~lag7_series.isna()
axes[1].scatter(lag7_series[valid7], elec_series[valid7],
                alpha=0.15, s=5, color="#F59E0B")
corr7 = elec_series[valid7].corr(lag7_series[valid7])
axes[1].set_title(f"Sales[t] vs Sales[t-7]  (r = {corr7:.3f})", fontsize=11)
axes[1].set_xlabel("Sales One Week Ago (lag-7)")
axes[1].set_ylabel("Sales Today")

for ax in axes:
    ax.grid(linestyle="--", alpha=0.4)

plt.suptitle("Autocorrelation — Electronics Daily Sales", fontsize=13)
plt.tight_layout()
plt.show()

print(f"Lag-1 correlation:  {corr1:.3f}")
print(f"Lag-7 correlation:  {corr7:.3f}")
Lag-1 correlation:  0.842
Lag-7 correlation:  0.916

Success

Lag-7 has a higher correlation than lag-1. This makes sense given the data-generating process: weekly seasonality creates a near-perfect same-day-last-week pattern. A Monday's sales are much more predictable from last Monday's sales than from Sunday's sales. This is precisely why lag_7 is typically the single most important feature in daily sales forecasting.


EDA Summary — Key Findings

Success

What the EDA tells you before you touch a model:

  1. Strong upward trend. Electronics grows from roughly 850 units/day in early 2021 to 1,500+ units/day by late 2023. The year feature and a sequential time index will capture this.
  2. Weekly seasonality is large and consistent. Weekday sales are ~330 units higher than weekend sales for Electronics — a 40% gap. day_of_week and is_weekend encode this directly.
  3. Annual seasonality is pronounced. November and December average 70%+ above the January–February trough. The month and quarter features encode this.
  4. Categories are correlated but not identical. All four trend together because they share the same base signal. In a real dataset you would check whether categories forecast each other (cross-category lags), but for this project per-category lag features are sufficient.
  5. Lag-7 is the strongest single predictor (r = 0.91). Building lag_7 is the highest-value feature engineering step. Lag-1 is also strong (r = 0.84). Both will appear at the top of the feature importance chart after modelling.

← Dataset Guide | Next: Feature Engineering →