---
title: Food Delivery Time Prediction Using Machine Learning
description: "The **Swiggy Delivery Time Prediction System** is an end-to-end machine learning project designed to predict food delivery time in minutes. ![](https://csestudy247.com/wp-content/uploads/2026/08/Food-Delivery-Time-Prediction-Using-Machine-Learning-1-1024x525.png) The project goes beyond simply training a machine learning model. It covers the complete workflow from **raw data processing…"
url: "https://csestudy247.com/docs/food-delivery-time-prediction-using-machine-learning/"
updated: "2026-08-19"
category: AI Projects
---

# Food Delivery Time Prediction Using Machine Learning

The **Swiggy Delivery Time Prediction System** is an end-to-end machine learning project designed to predict food delivery time in minutes.

![](https://csestudy247.com/wp-content/uploads/2026/08/Food-Delivery-Time-Prediction-Using-Machine-Learning-1-1024x525.png)

The project goes beyond simply training a machine learning model. It covers the complete workflow from **raw data processing and feature engineering to model training, evaluation, experiment tracking, model management, API serving, containerization, and deployment**.

The main objective is to build a practical, reproducible, and production-oriented machine learning system for predicting food delivery time.

---

## 1. What Problem Are We Solving?

Food delivery time depends on many real-world factors.

For example:

- Traffic conditions
- Weather conditions
- Delivery distance
- Rider information
- Vehicle condition
- Restaurant location
- Delivery location
- Order time
- Pickup duration
- Number of deliveries
- Festival conditions
- City type

Because these factors can affect how long an order takes to reach the customer, predicting delivery time is not a simple rule-based problem.

### Project Goal

The goal of this project is:

Predict the food delivery time in minutes using information available about the delivery.

The target variable is a continuous numerical value representing delivery time.

Therefore, this problem is treated as a **regression problem**.

---

## 2. Why Is Delivery-Time Prediction Important?

Estimated delivery time is an important part of the food-delivery experience.

When a customer places an order, they want to know approximately when the food will arrive.

An inaccurate prediction can lead to:

- Poor customer expectations
- Reduced customer satisfaction
- Inefficient delivery planning
- Difficulty identifying potentially delayed orders

A machine learning-based prediction system can help estimate delivery time more intelligently by learning patterns from historical delivery data.

### Customer

Accurate delivery-time estimates help customers understand when they can expect their food.

For example, instead of providing a generic delivery estimate, the system can consider the specific conditions of an order.

### Rider Dispatch

Delivery-time predictions can support better rider assignment and delivery planning.

The system can help identify deliveries that may require more time based on factors such as traffic, distance, and pickup duration.

### Operations

The business can use predictions to identify deliveries that may experience delays and take appropriate operational actions.

---

## 3. Machine Learning Solution

Instead of manually defining rules for every possible delivery situation, the system learns patterns from historical delivery data.

The overall workflow can be understood as:

 graph TD A\[Delivery Data\] --> B\[Data Processing\] B --> C\[Machine Learning Model\] C --> D\[Predicted Delivery Time\]

The project is designed as an **end-to-end pipeline**, rather than only a machine learning notebook.

This means the system considers the complete journey of the model:

**Raw Data → Cleaning → Feature Engineering → Preprocessing → Training → Evaluation → Model Tracking → Model Registry → API → Deployment**

This approach makes the project more suitable for real-world machine learning applications.

---

## 4. End-to-End System Architecture

The project follows an MLOps-oriented architecture.

The workflow starts with raw delivery data and continues through multiple stages:

1. Raw Data
2. Data Versioning
3. Data Cleaning
4. Feature Engineering
5. Data Preprocessing
6. Model Training
7. Model Evaluation
8. Experiment Tracking
9. Model Registry
10. API Serving
11. Containerization
12. Deployment

## Technologies Used

The project combines several technologies:

- **Python** for machine learning and data processing
- **Pandas and NumPy** for data manipulation
- **Scikit-learn** for preprocessing and machine learning
- **LightGBM** for gradient boosting
- **MLflow** for experiment tracking and model management
- **DVC** for reproducible data pipelines
- **FastAPI** for model serving
- **Docker** for containerization

The important idea is that each technology solves a different part of the machine learning lifecycle.

---

## 5. Data Processing Pipeline

The data processing pipeline prepares raw delivery data for machine learning.

The major stages are:

| Stage | Purpose | Output |
| --- | --- | --- |
| Data Cleaning | Remove invalid and inconsistent data | Cleaned dataset |
| Data Preparation | Prepare data for machine learning | Training and testing data |
| Feature Engineering | Create useful features | Engineered dataset |
| Preprocessing | Transform numerical and categorical features | Processed features |
| Training | Train the regression model | Trained model |
| Evaluation | Measure model performance | Evaluation results |
| Model Registry | Store and manage the model | Registered model |

Using separate stages makes the workflow easier to reproduce and maintain.

---

## 6. Data Cleaning

Before training the machine learning model, the raw Swiggy delivery dataset needs to be cleaned and transformed into a useful format.

The main data-preparation steps are:

1. Standardize column names
2. Handle missing values
3. Clean invalid rider and rating values
4. Clean latitude and longitude
5. Create date-based features
6. Create pickup-time features
7. Create time-of-day features
8. Clean categorical features
9. Calculate delivery distance
10. Combine everything into a reusable preprocessing pipeline

The goal is to convert raw delivery data into a **clean and machine-learning-ready dataset**.

---

### 6.1 Standardizing Column Names

The original dataset contains column names such as:

- `Delivery_person_Age`
- `Delivery_person_Ratings`
- `Restaurant_latitude`
- `Restaurant_longitude`
- `Time_taken(min)`

Working with inconsistent column names can make the code harder to maintain. Therefore, the column names are converted into a consistent lowercase naming convention.

### Code

```python

                def change_column_names(data: pd.DataFrame) -> pd.DataFrame:
    """
    Standardize the dataset column names.

    The original dataset contains long and inconsistent
    column names. We rename them into shorter,
    consistent names that are easier to use.
    """

    # Mapping of original column names to new column names
    rename_dict = {
        'ID': 'id',
        'Delivery_person_ID': 'rider_id',
        'Delivery_person_Age': 'age',
        'Delivery_person_Ratings': 'ratings',
        'Restaurant_latitude': 'restaurant_latitude',
        'Restaurant_longitude': 'restaurant_longitude',
        'Delivery_latitude': 'delivery_latitude',
        'Delivery_longitude': 'delivery_longitude',
        'Order_Date': 'order_date',
        'Time_Orderd': 'order_time',
        'Time_Order_picked': 'order_picked_time',
        'Weatherconditions': 'weather',
        'Road_traffic_density': 'traffic',
        'Vehicle_condition': 'vehicle_condition',
        'Type_of_order': 'type_of_order',
        'Type_of_vehicle': 'type_of_vehicle',
        'multiple_deliveries': 'multiple_deliveries',
        'Festival': 'festival',
        'City': 'city_type',
        'Time_taken(min)': 'time_taken'
    }

    # Rename known columns.
    # If a column is not in rename_dict, convert it to lowercase.
    return data.rename(
        columns=lambda col: rename_dict.get(col, col.lower())
    )
```

### Why do this?

Consistent column names make later operations easier.

For example: `Delivery_person_Age` becomes: `age`

This makes the dataset easier to understand and reduces unnecessary typing throughout the project.

---

## 6.2 Handling Missing Values

The dataset contains missing values represented as strings such as `NaN`.

These string values need to be converted into actual NumPy missing values so that Pandas and Scikit-learn can handle them correctly.

### Code

```python

                # Convert string representations of NaN into actual
# NumPy missing values.
df = df.replace(
    r'^\s*NaN\s*$',
    np.nan,
    regex=True
)
```

### Why?

There is an important difference between:

- `"NaN"` → a string
- `np.nan` → an actual missing numerical value

Converting the string into `np.nan` allows standard missing-value operations to work correctly.

---

## 6.3 Cleaning Invalid Rider and Rating Values

- The dataset contains rider age and rating information.
- Before using these features, they are converted into numerical values.
- Invalid records are then identified.

For this project:

- Rider age below 18 is considered anomalous.
- Ratings greater than 5 are considered invalid.

### Code

```python

                # Convert rider age into a numeric value.
# Invalid values are converted to NaN instead of causing an error.
df['age'] = pd.to_numeric(
    df['age'],
    errors='coerce'
)

# Convert rider ratings into numeric values.
# Invalid values are converted to NaN.
df['ratings'] = pd.to_numeric(
    df['ratings'],
    errors='coerce'
)

# Create a validation mask.
#
# Keep rows where:
# 1. Age is missing OR age is at least 18
# 2. Rating is missing OR rating is at most 5
valid_mask = (
    (df['age'].isna() | (df['age'] >= 18)) &
    (df['ratings'].isna() | (df['ratings'] <= 5.0))
)

# Keep only valid records.
df = df[valid_mask].copy()
```

### Why use `errors='coerce'`?

If a column contains an invalid value such as text where a number is expected, `errors='coerce'` converts that value into `NaN` instead of stopping the entire program with an error.

---

## 6.4 Cleaning Latitude and Longitude

The dataset contains geographical coordinates for both:

- Restaurant
- Delivery location

These coordinates are later used to calculate delivery distance.

Some coordinate values may be invalid. Therefore, values below the selected threshold are replaced with missing values.

### Code

```python

                def clean_lat_long(
    data: pd.DataFrame,
    threshold: float = 1.0
) -> pd.DataFrame:

    # Columns containing geographical coordinates
    location_columns = [
        'restaurant_latitude',
        'restaurant_longitude',
        'delivery_latitude',
        'delivery_longitude'
    ]

    # Create a copy so that the original DataFrame
    # is not modified directly.
    df_out = data.copy()

    # Process every location column.
    for col in location_columns:

        # Make sure the column exists before processing it.
        if col in df_out.columns:

            # Replace values below the threshold with NaN.
            df_out[col] = np.where(
                df_out[col] < threshold,
                np.nan,
                df_out[col]
            )

    return df_out
```

### Why clean geographical data?

Incorrect latitude or longitude values can produce incorrect distance calculations.

Since distance is an important feature in this project, invalid coordinates should be handled before calculating distance.

---

## 6.5 Creating Date Features

The original dataset contains an order date.

Instead of using the complete date directly, useful features are extracted from it.

The project creates:

- Order day
- Order month
- Day of week
- Weekend indicator

### Code

```python

                # Convert the order_date column into a datetime object.
#
# dayfirst=True is used because the dataset contains
# dates where the day appears before the month.
df['order_date'] = pd.to_datetime(
    df['order_date'],
    dayfirst=True,
    errors='coerce'
)

# Extract the day of the month.
df['order_day'] = df['order_date'].dt.day

# Extract the month.
df['order_month'] = df['order_date'].dt.month

# Extract the name of the day.
# Convert it to lowercase for consistency.
df['order_day_of_week'] = (
    df['order_date']
    .dt.day_name()
    .str.lower()
)

# Create a weekend indicator.
#
# Saturday and Sunday = 1
# Other days = 0
df['is_weekend'] = (
    df['order_date']
    .dt.day_name()
    .isin(['Saturday', 'Sunday'])
    .astype(int)
)
```

### Why create these features?

Delivery behavior can vary depending on the day.

For example:

- Weekdays may have different traffic patterns.
- Weekends may have different order volumes.
- Certain months may have different delivery behavior.

Instead of giving the model only the raw date, we extract information that may be more useful for prediction.

---

## 6.6 Creating Pickup-Time Features

The project calculates how much time passes between:

**Order Placed → Order Picked Up**

This creates a new feature: `pickup_time_minutes`

### Code

```python

                # Convert order time into datetime format.
df['order_time_dt'] = pd.to_datetime(
    df['order_time'],
    format='mixed',
    errors='coerce'
)

# Convert order pickup time into datetime format.
df['order_picked_time_dt'] = pd.to_datetime(
    df['order_picked_time'],
    format='mixed',
    errors='coerce'
)

# Calculate the difference between pickup time
# and order time.
#
# total_seconds() converts the time difference
# into seconds, and dividing by 60 converts it
# into minutes.
df['pickup_time_minutes'] = (
    df['order_picked_time_dt']
    - df['order_time_dt']
).dt.total_seconds() / 60.0
```

### Why is pickup time important?

Total delivery time is not only affected by the distance between the restaurant and customer.

The restaurant may also take time to prepare the order.

Therefore: **Longer pickup time → Potentially longer total delivery time**

This makes `pickup_time_minutes` a useful engineered feature.

---

## 6.7 Creating Time-of-Day Features

The order time is converted into an hour.

The hour is then grouped into meaningful categories:

- Morning
- Afternoon
- Evening
- Night
- After midnight

### Code

```python

                def time_of_day(hour_series: pd.Series) -> pd.Series:

    # Define the time ranges.
    conditions = [
        hour_series.between(6, 12, inclusive='left'),
        hour_series.between(12, 17, inclusive='left'),
        hour_series.between(17, 20, inclusive='left'),
        hour_series.between(20, 24, inclusive='left')
    ]

    # Assign a category to each time range.
    choices = [
        "morning",
        "afternoon",
        "evening",
        "night"
    ]

    # Select the appropriate category.
    # If none of the conditions match,
    # assign "after_midnight".
    return pd.Series(
        np.select(
            conditions,
            choices,
            default="after_midnight"
        ),
        index=hour_series.index
    )

# Extract the hour from the order datetime.
df['order_time_hour'] = (
    df['order_time_dt'].dt.hour
)

# Convert the hour into a time-of-day category.
df['order_time_of_day'] = time_of_day(
    df['order_time_hour']
)
```

### Why create a time-of-day feature?

Delivery conditions can change throughout the day.

For example: **Morning → Afternoon → Evening → Night**

may have different:

- Traffic
- Restaurant activity
- Order volume
- Rider availability

The categorical feature allows the model to learn these patterns.

---

## 6.8 Cleaning Categorical Features

The dataset contains several categorical columns.

Examples include:

- Weather
- Traffic
- Vehicle type

These values are cleaned by:

- Removing unnecessary spaces
- Converting values to lowercase
- Removing unwanted prefixes
- Converting invalid string representations into missing values

### Code

```python

                # Clean the weather column.
df['weather'] = (
    df['weather']
    .astype(str)

    # Remove the unnecessary "conditions" prefix.
    .str.replace(
        r'^conditions\s+',
        '',
        regex=True
    )

    # Remove leading and trailing spaces.
    .str.strip()

    # Convert all values to lowercase.
    .str.lower()

    # Convert string representations of missing values
    # into actual NaN values.
    .replace(['nan', 'none', ''], np.nan)
)

# Clean the traffic column.
df['traffic'] = (
    df['traffic']
    .astype(str)
    .str.strip()
    .str.lower()
    .replace(['nan', 'none', ''], np.nan)
)

# Clean the vehicle-type column.
df['type_of_vehicle'] = (
    df['type_of_vehicle']
    .astype(str)
    .str.strip()
    .str.lower()
    .replace(['nan', 'none', ''], np.nan)
)
```

### Why clean categorical data?

Suppose the dataset contains:

- `Sunny`
- `sunny`
- `SUNNY`

These values represent the same category but could be treated as different values if they are not standardized.

Cleaning them ensures consistent categorical representation.

---

The `time_taken` column is cleaned by removing `"(min)"` and converting the values from **string to integer** so the machine learning model can use it as a numerical target. The `order_time` and `order_picked_time` columns are then removed because they are no longer needed after feature engineering.

```python

                # target column modifications
time_taken = lambda x: (
    x['time_taken']
    .str.replace("(min) ", "")
    .astype(int)
)
.drop(columns=["order_time", "order_picked_time"])
```

## Food Delivery Time Analysis Using Exploratory Data Analysis

## Introduction

Before training any machine learning model, you must *understand* your data deeply. EDA (Exploratory Data Analysis) is the process of:

- Understanding the **shape, types, and quality** of your data
- Discovering **patterns and relationships** between features
- Identifying **anomalies and missing values**
- Forming **hypotheses** that guide feature engineering and model selection

In this blog, we walk through the complete EDA of the **Swiggy food delivery dataset** — a real-world dataset containing information about delivery riders, orders, locations, and delivery times.

Our goal: **Predict `time_taken` — the total delivery time in minutes.**

Let's explore every feature, one by one.

```python

                # import package
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
import seaborn as sns
import data_clean_utils
from scipy.stats import chi2_contingency, f_oneway, jarque_bera, probplot
import plotly.express as px
```

**Why these libraries?**

| Library | Purpose |
| --- | --- |
| `pandas` / `numpy` | Data manipulation and numerical operations |
| `matplotlib` / `seaborn` | Static visualizations (distributions, box plots, scatter plots) |
| `plotly.express` | Interactive map visualizations |
| `scipy.stats` | Statistical hypothesis tests (ANOVA, Chi-Square, Jarque-Bera) |
| `missingno` | Visualizing missing data patterns |
| `data_clean_utils` -Here Click For Code | Custom cleaning utility script (written separately) |

2. Load the Raw Data

```python

                # Load raw dataset
df = pd.read_csv('swiggy.csv')
df.sample(30)
```

## 3. Data Cleaning

We perform data cleaning using a custom utility that handles:

- Fixing data type issues
- Extracting date/time features from raw datetime columns
- Handling 0-value coordinates (0° lat/lon is technically in the ocean — not India)
- Computing the Haversine distance between restaurant and delivery location

```javascript

                # Clean the data using the utility script
data_clean_utils.perform_data_cleaning(data=df)
# Load the cleaned output
df_final = pd.read_csv('swiggy_cleaned.csv')
```

## 4. Preliminary Analysis

### Shape and Structure

```python

                df_final.isnull().sum()
```

---

```python

                # Count rows with at least one missing value
missing_rows = (
    df_final
    .isnull()
    .any(axis=1)
    .sum()
)

print(f'There are {missing_rows} rows with missing values in the data.')
print(f"It accounts for {(missing_rows/df_final.shape[0])*100:.2f}% of the data")
```

> Key finding: The initial percentage of missing data was 9.2% but increased to 16.35% after cleaning — because we correctly converted invalid 0.0 latitude/longitude values (which geographically land in the Atlantic Ocean) to NaN.

```python

                # Check for duplicates
df_final.duplicated().sum()
# No duplicate rows in the dataset.
```

---

```python

                df_final.dtypes

# Separate numerical and categorical columns
num_cols = df_final.columns[[1, 2, 3, 4, 5, 6, 16, 22, 25]].tolist()
cat_cols  = [col for col in df_final.columns.tolist() if col not in num_cols]

print(f'There are {len(num_cols)} numerical columns and {len(cat_cols)} categorical columns.')
```

---

```python

                # Statistical Summary
# Numerical columns
df_final[num_cols].describe()
```

---

```python

                # Categorical columns
(
    df_final
    .assign(**{col: df_final[col].astype("object") for col in cat_cols})
    .describe(include="object")
    .T
)
```

## 5. Missing Value Analysis

Understanding *where* and *why* data is missing is crucial. We use the `missingno` library for three complementary views.

### Matrix Plot

```python

                import missingno as msno

msno.matrix(df_final)
```

The matrix plot shows each row of the dataframe as a horizontal line. White gaps indicate missing values. This lets you see **which columns tend to be missing together** — a pattern called *co-occurrence*.

```python

                msno.heatmap(df_final)
```

The `missingno` heatmap shows how strongly the missingness of one column is **correlated** with another. A high correlation (close to 1) means whenever column A is missing, column B is also likely missing — suggesting a common cause (e.g., both come from the same source).

```python

                #Dendrogram
msno.dendrogram(df_final)
```

The dendrogram clusters columns by their missing-value correlation. Columns grouped close together have similar missingness patterns — helpful when deciding whether to impute together or separately.

Takeaway: Missing values in latitude/longitude columns co-occur heavily (they come from the same GPS source). Missing rider ratings and age appear independently.

>

## 6. Reusable Analysis Functions

Rather than repeating plotting code 40+ times, we built four reusable analysis functions:

**`numerical_analysis` — Understand a Continuous Variable**

```python

                def numerical_analysis(dataframe, column_name, cat_col=None, bins="auto"):
    fig = plt.figure(figsize=(15, 10))
    grid = GridSpec(nrows=2, ncols=2, figure=fig)

    ax1 = fig.add_subplot(grid[0, 0])  # KDE Plot
    ax2 = fig.add_subplot(grid[0, 1])  # Box Plot
    ax3 = fig.add_subplot(grid[1, :])  # Full-width Histogram

    sns.kdeplot(data=dataframe, x=column_name, hue=cat_col, ax=ax1)
    sns.boxplot(data=dataframe, x=column_name, hue=cat_col, ax=ax2)
    sns.histplot(data=dataframe, x=column_name, bins=bins, hue=cat_col, kde=True, ax=ax3)

    plt.tight_layout()
    plt.show()
```

**Why three plots?** KDE reveals the smooth distribution shape; box plot highlights outliers and quartiles; histogram shows the raw frequency counts. Together, they give a complete picture.

```python

                # numerical_categorical_analysis — Num vs Category

def numerical_categorical_analysis(dataframe, cat_column_1, num_column):
    fig, (ax1, ax2) = plt.subplots(2, 2, figsize=(15, 7.5))

    sns.barplot(data=dataframe,   x=cat_column_1, y=num_column, ax=ax1[0])  # Mean per group
    sns.boxplot(data=dataframe,   x=cat_column_1, y=num_column, ax=ax1[1])  # Quartiles per group
    sns.violinplot(data=dataframe,x=cat_column_1, y=num_column, ax=ax2[0])  # Full distribution shape
    sns.stripplot(data=dataframe, x=cat_column_1, y=num_column, ax=ax2[1])  # Raw data points

    plt.tight_layout()
    plt.show()
```

---

```python

                #categorical_analysis — Understand a Category Variable

def categorical_analysis(dataframe, column_name):
    # Show counts and percentages
    display(
        pd.DataFrame({
            "Count": dataframe[column_name].value_counts(),
            "Percentage": (
                dataframe[column_name]
                .value_counts(normalize=True)
                .mul(100).round(2)
                .astype("str").add("%")
            )
        })
    )

    print("*" * 50)
    unique_categories = dataframe[column_name].unique().tolist()
    print(f"Unique categories in {column_name}: {unique_categories}")
    print(f"Number of categories: {dataframe[column_name].nunique()}")

    sns.countplot(data=dataframe, x=column_name)
    plt.xticks(rotation=45)
    plt.show()
```

**`multivariate_analysis` — Two Categories vs One Numeric**

```python

                def multivariate_analysis(dataframe, num_column, cat_column_1, cat_column_2):
    fig, (ax1, ax2) = plt.subplots(2, 2, figsize=(15, 7.5))

    sns.barplot(data=dataframe,    x=cat_column_1, y=num_column, hue=cat_column_2, ax=ax1[0])
    sns.boxplot(data=dataframe,    x=cat_column_1, y=num_column, hue=cat_column_2, gap=0.1, ax=ax1[1])
    sns.violinplot(data=dataframe, x=cat_column_1, y=num_column, hue=cat_column_2, gap=0.1, ax=ax2[0])
    sns.stripplot(data=dataframe,  x=cat_column_1, y=num_column, hue=cat_column_2, dodge=True, ax=ax2[1])

    plt.tight_layout()
    plt.show()
```

**Hypothesis Testing Functions**

```python

                def anova_test(dataframe, num_col, cat_col, alpha=0.05):
    """One-Way ANOVA: Does the mean of num_col differ across cat_col groups?"""
    groups = [
        group[num_col].dropna().values
        for _, group in dataframe.groupby(cat_col)
    ]
    stat, p_value = f_oneway(*groups)
    print(f"F-statistic: {stat:.4f}, p-value: {p_value:.6f}")
    if p_value < alpha:
        print(f"✅ Reject H₀ — '{cat_col}' significantly affects '{num_col}'")
    else:
        print(f"❌ Fail to reject H₀ — '{cat_col}' does NOT significantly affect '{num_col}'")

def chi_2_test(dataframe, col1, col2, alpha=0.05):
    """Chi-Square Test: Are two categorical variables independent?"""
    contingency = pd.crosstab(dataframe[col1], dataframe[col2])
    chi2, p, dof, expected = chi2_contingency(contingency)
    print(f"Chi2: {chi2:.4f}, p-value: {p:.6f}, df: {dof}")
    if p < alpha:
        print(f"✅ Reject H₀ — '{col1}' and '{col2}' are NOT independent (related)")
    else:
        print(f"❌ Fail to reject H₀ — '{col1}' and '{col2}' are independent")

def test_for_normality(dataframe, column, alpha=0.05):
    """Jarque-Bera test for normality."""
    stat, p = jarque_bera(dataframe[column].dropna())
    print(f"JB-statistic: {stat:.4f}, p-value: {p:.6f}")
    if p < alpha:
        print(f"❌ '{column}' is NOT normally distributed")
    else:
        print(f"✅ '{column}' appears normally distributed")
```

## 7. Column-Wise EDA

### 7.1 Target Column: `time_taken`

The most important column in any ML project is the **target**. Let's understand it deeply.

```javascript

                # Numerical analysis of delivery time
numerical_analysis(df_final, 'time_taken', bins=10)
```

**Observations:**

1. `time_taken` is **not fully continuous** — it behaves like a discretized variable (riders likely round to the nearest minute)
2. It shows **bimodality** with two peaks — one around 17–18 minutes and another around 26–27 minutes, suggesting two distinct delivery scenarios (short local vs. longer cross-city)
3. There are extreme values near 50 minutes — these are **not true outliers**, just rare but valid long deliveries

```python

                # Q-Q Plot to visually assess normality
from scipy.stats import probplot

probplot(df_final['time_taken'], plot=plt)
plt.show()

# Statistical test for normality
test_for_normality(df_final, 'time_taken')
```

**Finding:** The Q-Q plot shows significant deviation from the diagonal, and the Jarque-Bera test confirms `time_taken` is **not normally distributed**. This is why we'll apply a PowerTransformer later.

#### Are the Extreme Values Really Outliers?

```python

                # Compute IQR bounds
target_25, target_75 = np.percentile(df_final['time_taken'], [25, 75])
iqr = target_75 - target_25
upper_bound = target_75 + (1.5 * iqr)

# What traffic conditions do extreme-time orders have?
df_final.loc[df_final['time_taken'] > upper_bound, "traffic"].value_counts()
# What weather conditions?
df_final.loc[df_final['time_taken'] > upper_bound, "weather"].value_counts()
# Are extreme-time orders covering longer distances?
avg_distance         = df_final["distance"].mean()
avg_distance_extreme = df_final.loc[df_final['time_taken'] > upper_bound, "distance"].mean()

print(f"Overall avg distance:       {avg_distance:.2f} km")
print(f"Extreme-time avg distance:  {avg_distance_extreme:.2f} km")
```

**Conclusion:** The high-time deliveries tend to be during heavy traffic, bad weather, and longer distances. They are **genuine real-world cases** — not data entry errors. We keep them.

```python

                # Fixing the Skewness with PowerTransformer
from sklearn.preprocessing import PowerTransformer

pt = PowerTransformer(method='yeo-johnson')
df_final['time_taken_pt'] = pt.fit_transform(df_final[['time_taken']])

# Visualize after transformation
numerical_analysis(df_final, "time_taken_pt", bins=10)

# Q-Q plot after transformation
probplot(df_final['time_taken_pt'], plot=plt)
plt.show()
```

>

Result: After Yeo-Johnson transformation, the distribution is much closer to Gaussian — the Q-Q plot aligns well with the diagonal. This will be used as the training target in Part 2.

```python

                df_final[["rider_id", "age", "ratings"]].groupby('rider_id').head(5).sort_values('rider_id')

# Check if a rider appears with different ages/ratings
df_final[["rider_id", "age", "ratings"]].dropna().duplicated(keep=False).sum()
```

**Finding:** `rider_id` is a unique identifier. It provides no predictive signal for delivery time and will be dropped before modelling.

```python

                # Rider Age
# Statistical summary
df_final['age'].describe()

# Distribution analysis
numerical_analysis(df_final, 'age', bins=20)

# Relationship between age and delivery time
sns.scatterplot(data=df_final, x='age', y='time_taken')
plt.show()
```

**Finding:** Age shows **no meaningful relationship with delivery time**. The scatter plot shows a uniform cloud with no trend. However, age may interact with vehicle condition — younger riders may maintain their vehicles better.

```python

                # Does vehicle condition vary with age?
sns.scatterplot(data=df_final, x='age', y='time_taken', hue="vehicle_condition")
plt.legend(bbox_to_anchor=(1.02, 1), loc=2)
plt.show()

# Vehicle type preferences by age
sns.stripplot(df_final, x='type_of_vehicle', y='age')
plt.show()
```

**Finding:** Different age groups prefer different vehicle types, but age alone doesn't drive delivery time. We include it as a feature to let the model decide its importance.

```python

                # Rider Rating
df_final['ratings'].describe()

# Distribution
numerical_analysis(df_final, 'ratings', bins=5)

# Does ratings affect delivery time?
sns.scatterplot(data=df_final, x='ratings', y='time_taken')
plt.show()
```

**Observation:** Riders with **higher ratings tend to receive more orders**, suggesting ratings act as a proxy for rider experience and reliability.

```python

                # Does vehicle condition affect ratings?
numerical_categorical_analysis(df_final, 'vehicle_condition', 'ratings')
```

**Finding:** Riders with worse vehicle condition (`vehicle_condition = 3`) receive lower ratings on average. Interestingly, this category has NaN ratings — suggesting **customers don't even bother rating when the vehicle is in terrible condition**.

```python

                # Check vehicle condition = 3 ratings distribution
(
    df_final[["ratings", "vehicle_condition"]]
    .loc[df_final["vehicle_condition"] == 3, "ratings"]
    .value_counts(dropna=False)
)

# Does vehicle type affect ratings?
numerical_categorical_analysis(df_final, 'type_of_vehicle', 'ratings')

# Does festival affect ratings?
numerical_categorical_analysis(df_final, 'festival', 'ratings')
```

Insight: The delivery points cluster around major Indian cities — Mumbai, Delhi, Bangalore, Hyderabad, Chennai. This confirms the dataset covers metropolitan delivery operations.

Note on coordinates: Raw latitude/longitude won't be used directly as model features. Instead, we computed the Haversine distance between the restaurant and delivery coordinates during data cleaning — a single, meaningful numerical feature that captures the geographic relationship.

7.5 📍 Location-Based Features

```python

                # Location data subset
location_subset = df_final.loc[:, df_final.columns[3:7].tolist() + ["city_name"]]
location_subset.dropna(inplace=True)

# Interactive Delivery Map
delivery_df = pd.DataFrame({
    'latitude':  location_subset['delivery_latitude'],
    'longitude': location_subset['delivery_longitude'],
    'city_name': location_subset['city_name']
})

fig = px.scatter_mapbox(
    delivery_df,
    lat='latitude', lon='longitude',
    title="Delivery Points Across India",
    hover_name="city_name"
)

fig.update_layout(
    mapbox_style="carto-positron",
    mapbox_center={"lat": 20.5937, "lon": 78.9629},  # Center of India
    mapbox_zoom=3,
)
fig.show()
```

7.6 Order Date, Day & Weekend

```python

                # Time-related columns
order_date_subset = df_final.loc[:, [
    "order_date", "order_day", "order_month",
    "order_day_of_week", "is_weekend", "festival"
]]
```

Does Day of Week Matter?

```python

                numerical_categorical_analysis(df_final, "order_day_of_week", "time_taken")
```

**Finding:** Delivery times are **relatively consistent across days of the week** with no dramatic differences. Day-of-week alone is not a strong predictor.

```python

                # Do Weekends Take Longer?
numerical_categorical_analysis(df_final, "is_weekend", "time_taken")

# Does weekend affect traffic?
chi_2_test(df_final, "is_weekend", "traffic")
```

**Finding:** Weekends show a slight increase in delivery time. The Chi-Square test confirms weekends **significantly affect traffic patterns** — more leisure travel leads to heavier traffic on weekends.

```python

                numerical_categorical_analysis(df_final, "festival", "time_taken")
```

**Observations:**

1. Average delivery time is **higher during festivals**
2. The range of delivery time is **narrower** during festivals — less variation because nearly all deliveries are slowed down uniformly
3. This makes festivals an important categorical signal for the model

```python

                # Do festivals affect traffic?
chi_2_test(df_final, "festival", "traffic")
```

The p-value is extremely small → **festivals significantly affect traffic**.

```python

                # Pivot table: average delivery time by traffic × festival
df_final.pivot_table(
    index="traffic",
    columns="festival",
    values="time_taken",
    aggfunc="mean"
)
```

| traffic | No Festival | Festival |
| --- | --- | --- |
| low | 20.1 | 22.3 |
| medium | 23.4 | 25.8 |
| high | 26.7 | 29.1 |
| jam | 30.2 | 33.4 |

>

Pattern: At every traffic level, festival deliveries take ~2–3 minutes longer on average.

```python

                # Combined effect: weekend + festival on delivery time
multivariate_analysis(df_final, "time_taken", "is_weekend", "festival")
```

### 7.7 Order Time of Day

```python

                # Time-related features
time_subset = df_final.loc[:, ["order_time_hour", "order_time_of_day", "pickup_time_minutes"]]

# Does time of day affect delivery time?
numerical_categorical_analysis(df_final, "order_time_of_day", "time_taken")

# ANOVA test: Is the difference statistically significant?
anova_test(df_final, "time_taken", "order_time_of_day")

# Which hours have the most orders?
df_final["order_time_hour"].value_counts().head(5)

# Distribution of orders by hour
categorical_analysis(df_final, "order_time_hour")

# Distribution by time of day bucket
categorical_analysis(df_final, "order_time_of_day")
```

**Finding:** ANOVA confirms time of day has a **statistically significant effect** on delivery time. Dinner orders (evening) tend to take longer — likely due to peak traffic and higher order volumes. The `order_time_of_day` (morning/afternoon/evening/night) is used as an engineered feature rather than the raw hour.

### 7.8 Pickup Time

`pickup_time_minutes` = time the rider waits at the restaurant before picking up the order.

```python

                # Relationship between pickup time and delivery time
sns.scatterplot(df_final, x="pickup_time_minutes", y="time_taken")
plt.show()

# Does pickup time (as category) affect delivery time?
numerical_categorical_analysis(df_final, "pickup_time_minutes", "time_taken")
# ANOVA test
anova_test(df_final, "time_taken", "pickup_time_minutes")
```

**Key Insight:** Pickup time has a **direct and strong relationship with total delivery time** — every minute waiting at the restaurant adds a minute to the total. ANOVA confirms this is highly significant. `pickup_time_minutes` is one of our **most important features**.

---

### 7.9 Traffic Conditions

```python

                # Distribution of traffic conditions
categorical_analysis(df_final, "traffic")

# Does traffic level depend on city type?
chi_2_test(df_final, "traffic", "city_type")

# Does traffic level depend on specific city?
chi_2_test(df_final, "traffic", "city_name")
```

**Finding:** Traffic distribution is **significantly different** across city types and individual cities — metropolitan cities have higher jam frequencies than semi-urban areas.

```python

                # Does traffic affect delivery time?
numerical_categorical_analysis(df_final, "traffic", "time_taken")

# ANOVA test
anova_test(df_final, "time_taken", "traffic")
```

**Finding:** Traffic is the **second most impactful feature** after distance. ANOVA strongly confirms that delivery time increases monotonically: low → medium → high → jam.

```python

                # Are some vehicles better in traffic?
multivariate_analysis(df_final, "time_taken", "traffic", "type_of_vehicle")

# Does vehicle condition interact with traffic?
multivariate_analysis(df_final, "time_taken", "traffic", "vehicle_condition")
```

⚠️ **Don't misinterpret!** If the chart shows "better vehicle condition = longer delivery time," this is a **confounding effect** — not causation. Better-condition vehicles are preferred for festival deliveries (which inherently take longer). See below:

```python

                # Confirm: good vehicles used more during festivals
multivariate_analysis(df_final, "time_taken", "festival", "vehicle_condition")
```

## 7.10 Multiple Deliveries

```python

                # Does batching multiple orders affect delivery time?
numerical_categorical_analysis(df_final, "multiple_deliveries", "time_taken")

# ANOVA test
anova_test(df_final, "time_taken", "multiple_deliveries")

# Are multiple-delivery orders covering more distance?
numerical_categorical_analysis(df_final, "multiple_deliveries", "distance")
```

**Finding:** Riders carrying multiple orders take significantly longer. ANOVA confirms this is statistically significant. Interestingly, multiple-delivery orders also tend to cover **larger total distances** — the rider must visit multiple drop points.

### 7.11 Weather Conditions

```python

                # Weather distribution
categorical_analysis(df_final, "weather")

# Does weather affect delivery time?
numerical_categorical_analysis(df_final, "weather", "time_taken")

# ANOVA test
anova_test(df_final, "time_taken", "weather")

# Does weather affect traffic?
chi_2_test(df_final, "weather", "traffic")
```

**Findings:**

- ANOVA confirms weather significantly affects delivery time
- Chi-Square confirms weather and traffic are **not independent** — bad weather causes heavier traffic
- Rain/fog conditions see the longest deliveries

```python

                # Combined effect: weather × traffic on delivery time
multivariate_analysis(df_final, "time_taken", "weather", "traffic")

# Pivot table
df_final.pivot_table(
    index="weather",
    columns="traffic",
    values="time_taken",
    aggfunc="mean"
)
```

**Key Insight:** The combination of **bad weather + heavy traffic** is the worst case — delivery times in Stormy + Jam conditions are ~35% higher than Sunny + Low traffic. **Traffic acts as the strongest discriminatory feature in combination with other variables.**

## 7.12 Vehicle Condition & Type

```python

                # Vehicle condition distribution
categorical_analysis(df_final, "vehicle_condition")

# Does condition affect delivery time?
numerical_categorical_analysis(df_final, "vehicle_condition", "time_taken")

# ANOVA test
anova_test(df_final, "time_taken", "vehicle_condition")

# Vehicle type distribution
categorical_analysis(df_final, "type_of_vehicle")

# Does type of vehicle affect delivery time?
numerical_categorical_analysis(df_final, "type_of_vehicle", "time_taken")

# Interaction: vehicle condition × vehicle type on delivery time
multivariate_analysis(df_final, "time_taken", "vehicle_condition", "type_of_vehicle")

# Are vehicle type and condition related?
chi_2_test(df_final, "type_of_vehicle", "vehicle_condition")
```

**Findings:**

- Vehicle condition affects delivery time — poorly maintained vehicles are slower
- Different vehicle types (scooter, motorcycle, bicycle, etc.) have different average speeds
- Chi-Square confirms vehicle type and condition are **related** — certain vehicle types are more likely to be in poor condition

## 7.13 Type of Order

```python

                # Order type distribution
categorical_analysis(df_final, "type_of_order")

# Does order type affect delivery time?
numerical_categorical_analysis(df_final, "type_of_order", "time_taken")

# ANOVA test
anova_test(df_final, "time_taken", "type_of_order")

# Cross-tab: order type vs weekend
pd.crosstab(df_final["type_of_order"], df_final["is_weekend"])
# Does order type affect pickup time?
chi_2_test(df_final, "pickup_time_minutes", "type_of_order")

# Does order type affect ratings?
numerical_categorical_analysis(df_final, "type_of_order", "ratings")

# Weekend vs order type
chi_2_test(df_final, "is_weekend", "type_of_order")

# Festival vs order type
chi_2_test(df_final, "festival", "type_of_order")
```

**Findings:**

- Order type (Snacks, Meal, Drinks, Buffet) has a significant effect on delivery time
- Restaurant meal orders take the longest (higher preparation complexity)
- Certain order types are more popular on weekends and festivals

### 7.14 City Analysis

```python

                # City name distribution
categorical_analysis(df_final, "city_name")

# Does city affect delivery time?
numerical_categorical_analysis(df_final, "city_name", "time_taken")

# City type distribution (metropolitan, urban, semi-urban)
categorical_analysis(df_final, "city_type")

# Does city type affect delivery time?
numerical_categorical_analysis(df_final, "city_type", "time_taken")

# ANOVA test
anova_test(df_final, "time_taken", "city_type")

# Does city type affect rider ratings?
numerical_categorical_analysis(df_final, "city_type", "ratings")

# Combined: city type × vehicle type on delivery time
multivariate_analysis(df_final, "time_taken", "city_type", "type_of_vehicle")
```

**Findings:**

- Metropolitan cities have slightly **longer delivery times** — denser traffic and longer distances to navigate
- City type significantly affects rider ratings — urban riders tend to have higher ratings (potentially due to better infrastructure and shorter delivery windows)
- Different vehicle types perform differently across city types (bicycles are slower in semi-urban spread-out areas)

### 7.15 Distance

The **Haversine distance** between the restaurant and delivery location — computed during cleaning.

```python

                # Numerical analysis of distance
numerical_analysis(df_final, "distance", bins=10)

# Scatterplot: distance vs delivery time
sns.scatterplot(df_final, x="distance", y="time_taken")
plt.show()

# Correlation
df_final[["distance", "time_taken"]].corr()

# Does vehicle type affect the distances covered?
numerical_categorical_analysis(df_final, "type_of_vehicle", "distance")

# Do riders cover more distance during festivals?
numerical_categorical_analysis(df_final, "distance", "festival")

# Distance distribution by vehicle type
numerical_categorical_analysis(df_final, "distance", "type_of_vehicle")
```

**Findings:**

- Distance shows a **positive correlation** with delivery time — the strongest linear relationship of any single feature
- The scatter plot shows significant variance at each distance value, confirming that distance alone doesn't explain everything (traffic, weather, pickup time all add noise)
- Festival deliveries tend to cover **slightly greater distances** — riders may accept orders from farther restaurants when order volumes are high
- Motorcycles and electric scooters cover the widest range of distances; bicycles are used predominantly for short distances

## 8. EDA Summary & Key Insights

After exploring all features, here's what we learned:

### Top Predictors of Delivery Time

| Feature | Impact | Nature |
| --- | --- | --- |
| `distance` | High | Strongest linear predictor |
| `traffic` | High | Monotonic ordinal effect (low → jam) |
| `pickup_time_minutes` | High | Every minute at restaurant = +1 to total |
| `festival` | Medium | Adds ~2–3 min across all traffic levels |
| `weather` | Medium | Bad weather compounds with traffic |
| `multiple_deliveries` | Medium | Batching significantly increases time |
| `order_time_of_day` | Medium | Evening orders take longest |
| `city_type` | Medium | Metro cities → longer deliveries |
| `vehicle_condition` | Low | Confounded with festival ordering patterns |
| `type_of_vehicle` | Low | Moderate effect, varies by city type |
| `ratings` | Low | Higher-rated = faster (indirect) |
| `age` | None | No meaningful direct effect |
| `rider_id` | None | Identifier, drop before modelling |

### Important Feature Interactions

1. **Traffic × Weather** → Worst combination for delivery time
2. **Festival × Traffic** → Festival amplifies traffic effect
3. **Multiple deliveries × Distance** → Batched orders cover more ground
4. **Vehicle condition × Festival** → Better vehicles used during festivals (confounding)
5. **Weekend × Traffic** → Weekends increase traffic, which increases time

### Preprocessing Decisions Made

| Decision | Reason |
| --- | --- |
| Convert `time_taken` with PowerTransformer | Right-skewed, non-normal distribution |
| Keep extreme delivery values | Real-world cases, not errors |
| Drop raw lat/lon, use `distance` | Engineered feature is more meaningful |
| Drop `rider_id` | Identifier, no predictive value |
| Encode `traffic` as ordinal | Natural ordering: low < medium < high < jam |
| Encode `distance_type` as ordinal | Natural ordering: short < medium < long < very_long |
| One-Hot Encode other categoricals | No natural ordering |
| Scale numerical features | Ensures distance doesn't dominate other features |

  [EDA NoteBook](https://github.com/sanjit507/Swiggy_Delivery_Time_Prediction/blob/main/notebooks/Food_Delivery_EDA.ipynb)

## Model Selection, Hyperparameter Tuning & The LightGBM Champion

## Introduction

In Part 1 of this series, we cleaned the raw Swiggy dataset, handled missing values, and extracted rich features like `distance`, `pickup_time_minutes`, `traffic`, `order_time_of_day`, and more. We ended with a tidy `swiggy_cleaned.csv` ready for modelling.

In this part, we tackle the most exciting phase of any ML project:

1. **Setting up the preprocessing pipeline**
2. **Model Selection** — Comparing 6 candidate algorithms using Optuna
3. **Hyperparameter Tuning** — Deep-dive tuning of the top contenders (Random Forest & LightGBM)
4. **Ensemble via Stacking** — Combining the best two models
5. **The Winner: LightGBM** — Final evaluation and what makes it shine

## 1. Loading the Cleaned Data

We start by loading the cleaned dataset produced in Part 1:

```python

                import pandas as pd
import numpy as np
import mlflow
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import PowerTransformer, MinMaxScaler, OneHotEncoder, OrdinalEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline

# load the cleaned data
df = pd.read_csv('swiggy_cleaned.csv')
df.shape  # (45,584 rows × 26 columns, approximately)
```

### Drop Columns Not Needed for Modelling

Several columns were useful for EDA but won't help the model: raw coordinates, IDs, and time-parts that are already captured by engineered features:

```python

                columns_to_drop = [
    'rider_id',
    'restaurant_latitude', 'restaurant_longitude',
    'delivery_latitude', 'delivery_longitude',
    'order_date',
    'order_time_hour', 'order_day',
    'city_name', 'order_day_of_week', 'order_month'
]

df.drop(columns=columns_to_drop, inplace=True)
```

Why drop raw coordinates? We already engineered a distance column (Haversine distance between restaurant and delivery point). The raw lat/lon would only add noise and dimensionality without providing additional information.

>

---

## 2. Data Split & Target Transformation

### Train-Test Split

We separate features (`X`) from the target (`y = time_taken`) and perform an 80/20 split:

```python

                # Drop rows with missing values (Experiment 1 approach)
temp_df = df.copy().dropna()

X = temp_df.drop(columns='time_taken')
y = temp_df['time_taken']

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

print("Train size:", X_train.shape)   # e.g. (36,000+, 14)
print("Test size:",  X_test.shape)    # e.g. (9,000+, 14)
```

### Why Transform the Target?

`time_taken` (delivery time in minutes) is right-skewed. Many ML algorithms (and especially loss functions) assume or perform better with normally-distributed targets.

We apply `PowerTransformer` (Yeo-Johnson), which makes the distribution closer to Gaussian:

```python

                from sklearn.preprocessing import PowerTransformer

pt = PowerTransformer()

# Fit ONLY on training data to avoid data leakage
y_train_pt = pt.fit_transform(y_train.values.reshape(-1, 1))
y_test_pt  = pt.transform(y_test.values.reshape(-1, 1))
```

⚠️ Data Leakage Warning: The PowerTransformer is fit only on y_train and then applied to y_test. Fitting on the full dataset before splitting would leak test information into training — a classic mistake.

>

---

## 3. Preprocessing Pipeline

### Feature Categories

Our features fall into three types, each requiring different treatment:

| Type | Features | Transformation |
| --- | --- | --- |
| **Numerical** | `age`, `ratings`, `pickup_time_minutes`, `distance` | MinMax Scaling |
| **Nominal Categorical** | `weather`, `type_of_order`, `type_of_vehicle`, `festival`, `city_type`, `is_weekend`, `order_time_of_day` | One-Hot Encoding |
| **Ordinal Categorical** | `traffic`, `distance_type` | Ordinal Encoding (with custom order) |

### Ordinal Feature Orders

Traffic and distance have natural orderings that we encode explicitly:

```python

                traffic_order       = ["low", "medium", "high", "jam"]
distance_type_order = ["short", "medium", "long", "very_long"]
```

**Building the Preprocessor**

```python

                num_cols = ["age", "ratings", "pickup_time_minutes", "distance"]

nominal_cat_cols = [
    'weather', 'type_of_order', 'type_of_vehicle',
    "festival", "city_type", "is_weekend", "order_time_of_day"
]

ordinal_cat_cols = ["traffic", "distance_type"]

preprocessor = ColumnTransformer(transformers=[
    ("scale",
     MinMaxScaler(),
     num_cols),

    ("nominal_encode",
     OneHotEncoder(drop="first", handle_unknown="ignore", sparse_output=False),
     nominal_cat_cols),

    ("ordinal_encode",
     OrdinalEncoder(
         categories=[traffic_order, distance_type_order],
         encoded_missing_value=-999,
         handle_unknown="use_encoded_value",
         unknown_value=-1
     ),
     ordinal_cat_cols)

], remainder="passthrough", n_jobs=-1,
   force_int_remainder_cols=False,
   verbose_feature_names_out=False)
```

**Key design decisions:**

- `drop="first"` in OneHotEncoder avoids the dummy variable trap (multicollinearity)
- `handle_unknown="ignore"` makes the pipeline robust to unseen categories at inference
- `encoded_missing_value=-999` ensures NaN ordinal values get a sentinel — not confused with a valid category

**Pipeline Assembly**

```python

                processing_pipeline = Pipeline(steps=[
    ("preprocess", preprocessor)
])

# Fit on train, transform both
X_train_trans = processing_pipeline.fit_transform(X_train)
X_test_trans  = processing_pipeline.transform(X_test)
```

## Model Selection with Optuna

Rather than manually trying each algorithm one by one, we run a **multi-model Optuna study** — an automated hyperparameter search that also selects the *type* of model to use.

### Why Optuna?

[Optuna](https://optuna.org/) uses **Tree-structured Parzen Estimator (TPE)** sampling — a Bayesian optimization technique that learns which configurations are promising and focuses the search there, unlike grid or random search that sample blindly.

```python

                from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from sklearn.neighbors import KNeighborsRegressor
from sklearn.svm import SVR
from xgboost import XGBRegressor
from lightgbm import LGBMRegressor
import optuna

from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import cross_val_score
from sklearn.compose import TransformedTargetRegressor
```

### The Objective Function

The objective function is the heart of Optuna. It:

1. Picks a model type (trial parameter)
2. Picks model-specific hyperparameters
3. Trains and cross-validates
4. Returns the metric to minimize (MAE)

```python

                def objective(trial):
    with mlflow.start_run(nested=True):

        model_name = trial.suggest_categorical(
            "model", ["SVM", "RF", "KNN", "GB", "XGB", "LGBM"]
        )

        # ── SVM ──────────────────────────────────────────────────────
        if model_name == "SVM":
            kernel_svm = trial.suggest_categorical(
                "kernel_svm", ["linear", "poly", "rbf"]
            )
            if kernel_svm == "linear":
                c_linear = trial.suggest_float("c_linear", 0, 10)
                model = SVR(C=c_linear, kernel="linear")

            elif kernel_svm == "poly":
                c_poly   = trial.suggest_float("c_poly", 0, 10)
                deg_poly = trial.suggest_int("degree_poly", 1, 5)
                model    = SVR(C=c_poly, degree=deg_poly, kernel="poly")

            else:  # rbf
                c_rbf    = trial.suggest_float("c_rbf", 0, 100)
                gamma    = trial.suggest_float("gamma_rbf", 0, 10)
                model    = SVR(C=c_rbf, gamma=gamma, kernel="rbf")

        # ── Random Forest ─────────────────────────────────────────────
        elif model_name == "RF":
            n_estimators_rf = trial.suggest_int("n_estimators_rf", 10, 200)
            max_depth_rf    = trial.suggest_int("max_depth_rf", 2, 20)
            model = RandomForestRegressor(
                n_estimators=n_estimators_rf,
                max_depth=max_depth_rf,
                random_state=42, n_jobs=-1
            )

        # ── Gradient Boosting ──────────────────────────────────────────
        elif model_name == "GB":
            n_estimators_gb  = trial.suggest_int("n_estimators_gb", 10, 200)
            learning_rate_gb = trial.suggest_float("learning_rate_gb", 0, 1)
            max_depth_gb     = trial.suggest_int("max_depth_gb", 2, 20)
            model = GradientBoostingRegressor(
                n_estimators=n_estimators_gb,
                learning_rate=learning_rate_gb,
                max_depth=max_depth_gb,
                random_state=42
            )

        # ── KNN ────────────────────────────────────────────────────────
        elif model_name == "KNN":
            n_neighbors_knn = trial.suggest_int("n_neighbors_knn", 1, 30)
            weights_knn     = trial.suggest_categorical(
                "weights_knn", ["uniform", "distance"]
            )
            model = KNeighborsRegressor(
                n_neighbors=n_neighbors_knn,
                weights=weights_knn, n_jobs=-1
            )

        # ── XGBoost ────────────────────────────────────────────────────
        elif model_name == "XGB":
            n_estimators_xgb  = trial.suggest_int("n_estimators_xgb", 10, 200)
            learning_rate_xgb = trial.suggest_float("learning_rate_xgb", 0, 1)
            max_depth_xgb     = trial.suggest_int("max_depth_xgb", 2, 20)
            model = XGBRegressor(
                n_estimators=n_estimators_xgb,
                learning_rate=learning_rate_xgb,
                max_depth=max_depth_xgb,
                random_state=42, n_jobs=-1, verbosity=0
            )

        # ── LightGBM ───────────────────────────────────────────────────
        else:  # LGBM
            n_estimators_lgbm  = trial.suggest_int("n_estimators_lgbm", 10, 200)
            learning_rate_lgbm = trial.suggest_float("learning_rate_lgbm", 0, 1)
            max_depth_lgbm     = trial.suggest_int("max_depth_lgbm", 2, 20)
            model = LGBMRegressor(
                n_estimators=n_estimators_lgbm,
                learning_rate=learning_rate_lgbm,
                max_depth=max_depth_lgbm,
                random_state=42, n_jobs=-1
            )

        # ── Wrap with target transformer ──────────────────────────────
        wrapped = TransformedTargetRegressor(regressor=model, transformer=pt)

        # ── 5-fold Cross Validation ───────────────────────────────────
        cv_scores = cross_val_score(
            wrapped, X_train_trans, y_train,
            cv=5, scoring="neg_mean_absolute_error", n_jobs=-1
        )
        mean_mae = -cv_scores.mean()

        mlflow.log_metric("cross_val_mae", mean_mae)
        return mean_mae
```

**Running the Study**

```python

                study = optuna.create_study(direction="minimize", study_name="model_selection")

with mlflow.start_run(run_name="Best Model") as parent:
    study.optimize(objective, n_trials=30, n_jobs=-1)
    mlflow.log_params(study.best_params)
    mlflow.log_metric("best_score", study.best_value)
```

### Model Selection Results

After 30 trials, we inspect which models performed best:

```javascript

                # Average MAE for each model type
study.trials_dataframe().groupby("params_model")['value'].mean().sort_values()
```

| Model | Average CV MAE (minutes) |
| --- | --- |
| **LGBM** | **4.71** |
| XGBoost | 4.89 |
| Random Forest | 5.02 |
| Gradient Boosting | 5.31 |
| KNN | 6.44 |
| SVM | 7.12 |

**LightGBM** and **Random Forest** clearly lead the pack. Both are tree-based ensemble methods that handle tabular data extremely well. We select these two for deep hyperparameter tuning.

```python

                # Visualize optimization history
optuna.visualization.plot_optimization_history(study)

# See model selection landscape
optuna.visualization.plot_parallel_coordinate(study, params=["model"])
```

## 5. Hyperparameter Tuning — Random Forest 🌲

Now we deep-dive into the Random Forest hyperparameter space with **20 Optuna trials**, each scored by 5-fold cross-validation MAE.

### Hyperparameter Search Space

| Parameter | Range | Meaning |
| --- | --- | --- |
| `n_estimators` | 10 – 500 | Number of trees in the forest |
| `max_depth` | 1 – 30 | Maximum depth of each tree |
| `max_features` | None / sqrt / log2 | Features considered per split |
| `min_samples_split` | 2 – 10 | Min samples to split an internal node |
| `min_samples_leaf` | 1 – 10 | Min samples required at a leaf node |
| `max_samples` | 0.5 – 1.0 | Fraction of training samples for each tree |

```python

                from sklearn.ensemble import RandomForestRegressor
import optuna

def objective(trial):
    with mlflow.start_run(nested=True):
        params = {
            "n_estimators":    trial.suggest_int("n_estimators", 10, 500),
            "max_depth":       trial.suggest_int("max_depth", 1, 30),
            "max_features":    trial.suggest_categorical("max_features",
                                                          [None, "sqrt", "log2"]),
            "min_samples_split": trial.suggest_int("min_samples_split", 2, 10),
            "min_samples_leaf":  trial.suggest_int("min_samples_leaf", 1, 10),
            "max_samples":     trial.suggest_float("max_samples", 0.5, 1.0),
            "random_state": 42,
            "n_jobs": -1,
        }

        mlflow.log_params(params)

        rf    = RandomForestRegressor(**params)
        model = TransformedTargetRegressor(regressor=rf, transformer=pt)
        model.fit(X_train_trans, y_train)

        # 5-fold CV
        cv_score  = cross_val_score(model, X_train_trans, y_train,
                                    cv=5, scoring="neg_mean_absolute_error",
                                    n_jobs=-1)
        mean_score = -cv_score.mean()
        mlflow.log_metric("cross_val_error", mean_score)
        return mean_score
```

**Running RF Tuning**

```python

                study_rf = optuna.create_study(direction="minimize")

with mlflow.start_run(run_name="best_rf_model"):
    study_rf.optimize(objective, n_trials=20, n_jobs=-1, show_progress_bar=True)

    mlflow.log_params(study_rf.best_params)
    mlflow.log_metric("best_score", study_rf.best_value)

    # Train and evaluate the best RF
    best_rf = RandomForestRegressor(**study_rf.best_params)
    best_rf.fit(X_train_trans, y_train_pt.values.ravel())

    y_pred_train = best_rf.predict(X_train_trans)
    y_pred_test  = best_rf.predict(X_test_trans)

    # Inverse-transform predictions back to original scale (minutes)
    y_pred_train_org = pt.inverse_transform(y_pred_train.reshape(-1, 1))
    y_pred_test_org  = pt.inverse_transform(y_pred_test.reshape(-1, 1))

    print(f"RF Train MAE: {mean_absolute_error(y_train, y_pred_train_org):.2f} min")
    print(f"RF Test  MAE: {mean_absolute_error(y_test,  y_pred_test_org):.2f} min")
    print(f"RF Train R²:  {r2_score(y_train, y_pred_train_org):.3f}")
    print(f"RF Test  R²:  {r2_score(y_test,  y_pred_test_org):.3f}")
```

**Best RF Hyperparameters Found**

```python

                best_rf_params = {
    'n_estimators':      479,
    'criterion':         'squared_error',
    'max_depth':         17,
    'max_features':      None,           # Consider all features at each split
    'min_samples_split': 9,
    'min_samples_leaf':  2,
    'max_samples':       0.6603          # Use ~66% of training samples per tree
}
```

**Visualising the RF Tuning**

```python

                # How the best score evolved over trials
optuna.visualization.plot_optimization_history(study_rf)

# Which hyperparameters mattered most
optuna.visualization.plot_param_importances(study_rf)

# How each hyperparameter value relates to the score
optuna.visualization.plot_slice(study_rf)
```

Insight from param importance: n_estimators and max_depth are the most influential for Random Forest — more trees generally help up to a point, and deeper trees capture complex interactions at the risk of overfitting (controlled by min_samples_leaf and max_samples).

>

---

## 6. Hyperparameter Tuning — LightGBM ⚡

LightGBM uses **leaf-wise tree growth** (unlike Random Forest's level-wise), making it significantly faster and often more accurate on tabular data. We run **50 trials** to explore its richer hyperparameter space.

### Why LightGBM?

| Feature | Random Forest | LightGBM |
| --- | --- | --- |
| Tree growth | Level-wise | Leaf-wise |
| Speed | Moderate | Very fast |
| Memory usage | High | Low |
| Regularization | Via max_depth / min_samples | L1, L2, min_gain, min_child |
| Handles missing values | No (needs imputation) | Natively |
| Categorical features | Needs encoding | Natively (optionally) |

### Hyperparameter Search Space

| Parameter | Range | Meaning |
| --- | --- | --- |
| `n_estimators` | 10 – 200 | Number of boosting rounds |
| `max_depth` | 1 – 40 | Max depth per tree |
| `learning_rate` | 0.1 – 0.8 | Shrinkage applied to each tree's contribution |
| `subsample` | 0.5 – 1.0 | Fraction of data sampled per boosting round |
| `min_child_weight` | 5 – 20 | Min sum of instance weight in a leaf |
| `min_split_gain` | 0 – 10 | Min gain to perform a split |
| `reg_lambda` | 0 – 100 | L2 regularization term |

---

```python

                from lightgbm import LGBMRegressor

def objective(trial):
    with mlflow.start_run(nested=True):
        params = {
            "n_estimators":    trial.suggest_int("n_estimators", 10, 200),
            "max_depth":       trial.suggest_int("max_depth", 1, 40),
            "learning_rate":   trial.suggest_float("learning_rate", 0.1, 0.8),
            "subsample":       trial.suggest_float("subsample", 0.5, 1.0),
            "min_child_weight":trial.suggest_int("min_child_weight", 5, 20),
            "min_split_gain":  trial.suggest_float("min_split_gain", 0, 10),
            "reg_lambda":      trial.suggest_float("reg_lambda", 0, 100),
            "random_state": 42,
            "n_jobs": -1,
        }

        mlflow.log_params(params)

        lgbm  = LGBMRegressor(**params)
        model = TransformedTargetRegressor(regressor=lgbm, transformer=pt)
        model.fit(X_train_trans, y_train)

        cv_score   = cross_val_score(model, X_train_trans, y_train,
                                     cv=5, scoring="neg_mean_absolute_error",
                                     n_jobs=-1)
        mean_score = -cv_score.mean()
        mlflow.log_metric("cross_val_error", mean_score)
        return mean_score
```

**Running LightGBM Tuning**

```python

                study_lgbm = optuna.create_study(direction="minimize")

with mlflow.start_run(run_name="best_lgbm_model"):
    study_lgbm.optimize(objective, n_trials=50, n_jobs=-1, show_progress_bar=True)

    mlflow.log_params(study_lgbm.best_params)
    mlflow.log_metric("best_score", study_lgbm.best_value)

    best_lgbm = LGBMRegressor(**study_lgbm.best_params)
    best_lgbm.fit(X_train_trans, y_train_pt.values.ravel())

    y_pred_train = best_lgbm.predict(X_train_trans)
    y_pred_test  = best_lgbm.predict(X_test_trans)

    y_pred_train_org = pt.inverse_transform(y_pred_train.reshape(-1, 1))
    y_pred_test_org  = pt.inverse_transform(y_pred_test.reshape(-1, 1))

    print(f"LGBM Train MAE: {mean_absolute_error(y_train, y_pred_train_org):.2f} min")
    print(f"LGBM Test  MAE: {mean_absolute_error(y_test,  y_pred_test_org):.2f} min")
    print(f"LGBM Train R²:  {r2_score(y_train, y_pred_train_org):.3f}")
    print(f"LGBM Test  R²:  {r2_score(y_test,  y_pred_test_org):.3f}")
```

**Best LightGBM Hyperparameters Found**

```python

                best_lgbm_params = {
    'n_estimators':    154,
    'max_depth':       27,
    'learning_rate':   0.22234,
    'subsample':       0.7592,      # Sample 76% of data per round
    'min_child_weight':20,          # High — prevents overfitting on small samples
    'min_split_gain':  0.00460,     # Very small — allows most splits
    'reg_lambda':      97.81        # Strong L2 regularization
}
```

**Note on `reg_lambda=97.81`:** This high L2 penalty may seem surprising but is a hallmark of well-tuned gradient boosted models. Combined with `min_child_weight=20` and `subsample=0.76`, it provides multiple layers of regularization that prevent overfitting while allowing the model to learn from complex patterns.

```python

                #LightGBM Visualizations
# Optimization convergence
optuna.visualization.plot_optimization_history(study_lgbm)

# Most impactful hyperparameters
optuna.visualization.plot_param_importances(study_lgbm)

# Score distribution across each param's range
optuna.visualization.plot_slice(study_lgbm)
```

## 7. Stacking Regressor — Can Two Heads Beat One? 🤝

Even after individual tuning, we explore whether **stacking** (a meta-ensemble technique) can squeeze out more performance by combining RF and LGBM predictions.

### What is Stacking?

![](https://csestudy247.com/wp-content/uploads/2026/08/image.png)

A **Stacking Regressor** uses the predictions of base models as *features* for a meta-model (here, Linear Regression). The meta-model learns how to best combine the base model predictions.

### Building the Stacking Regressor

```python

                from sklearn.ensemble import StackingRegressor
from sklearn.linear_model import LinearRegression
from sklearn.tree import DecisionTreeRegressor
from sklearn.neighbors import KNeighborsRegressor

best_rf   = RandomForestRegressor(**best_rf_params)
best_lgbm = LGBMRegressor(**best_lgbm_params)
```

### Tuning the Meta-Estimator

We also optimise the meta-estimator using Optuna, testing Linear Regression, KNN, and Decision Tree:

```python

                def objective(trial):
    with mlflow.start_run(nested=True):
        meta_model_name = trial.suggest_categorical("model", ["LR", "KNN", "DT"])

        if meta_model_name == "LR":
            meta = LinearRegression()

        elif meta_model_name == "KNN":
            n_neighbors = trial.suggest_int("n_neighbors_knn", 1, 15)
            weights     = trial.suggest_categorical("weights_knn",
                                                    ["uniform", "distance"])
            meta = KNeighborsRegressor(n_neighbors=n_neighbors,
                                       weights=weights, n_jobs=-1)

        elif meta_model_name == "DT":
            max_depth        = trial.suggest_int("max_depth_dt", 1, 10)
            min_samples_split= trial.suggest_int("min_samples_split_dt", 2, 10)
            min_samples_leaf = trial.suggest_int("min_samples_leaf_dt", 1, 10)
            meta = DecisionTreeRegressor(max_depth=max_depth,
                                         min_samples_split=min_samples_split,
                                         min_samples_leaf=min_samples_leaf,
                                         random_state=42)

        mlflow.log_param("meta_model_name", meta_model_name)

        # Build the stacking regressor (5-fold CV for generating meta-features)
        stacking_reg = StackingRegressor(
            estimators=[("rf", best_rf), ("lgbm", best_lgbm)],
            final_estimator=meta,
            cv=5, n_jobs=-1
        )

        model = TransformedTargetRegressor(regressor=stacking_reg, transformer=pt)
        model.fit(X_train_trans, y_train)

        y_pred_test = model.predict(X_test_trans)
        error = mean_absolute_error(y_test, y_pred_test)
        mlflow.log_metric("MAE", error)
        return error
```

---

```python

                study_stack = optuna.create_study(direction="minimize")

with mlflow.start_run(run_name="best_stacking_model"):
    study_stack.optimize(objective, n_trials=20, n_jobs=-1, show_progress_bar=True)
    mlflow.log_params(study_stack.best_params)
    mlflow.log_metric("best_score", study_stack.best_value)
```

Meta-Estimator Comparison

```python

                # Average MAE for each meta-estimator type
study_stack.trials_dataframe().groupby("params_model")['value'].mean().sort_values()
```

| Meta-Estimator | Average MAE (minutes) |
| --- | --- |
| Linear Regression | **4.68** |
| KNN | 4.72 |
| Decision Tree | 4.81 |

Linear Regression as the meta-model wins — it simply learns a weighted linear combination of the two base models, which generalizes best.

---

## 8. Final Model Comparison

After all experiments, here's the complete picture:

| Model | Train MAE | Test MAE | CV MAE | Train R² | Test R² |
| --- | --- | --- | --- | --- | --- |
| Linear Regression (baseline) | ~7.5 min | ~7.5 min | ~7.5 min | 0.47 | 0.46 |
| Random Forest (tuned) | ~2.1 min | ~4.9 min | ~4.98 min | 0.91 | 0.81 |
| **LightGBM (tuned)** | **~2.8 min** | **~4.7 min** | **~4.71 min** | **0.89** | **0.83** |
| Stacking (RF + LGBM + LR) | ~2.5 min | ~4.68 min | ~4.69 min | 0.90 | 0.83 |

Why is LightGBM the winner? While Stacking achieves a slightly lower test MAE, LightGBM alone achieves nearly the same performance with dramatically less complexity — no need to maintain two models, no cross-val overhead during inference, and prediction speed is much faster. In production, simplicity wins.

>

---

## 9. Final Model — LightGBM with TransformedTargetRegressor

### Final Model Architecture

```python

                from sklearn.compose import TransformedTargetRegressor
from sklearn.model_selection import cross_val_score

best_lgbm_params = {
    'n_estimators':     154,
    'max_depth':        27,
    'learning_rate':    0.22234435854395157,
    'subsample':        0.7592213724048168,
    'min_child_weight': 20,
    'min_split_gain':   0.004604680609280751,
    'reg_lambda':       97.81002379097947
}

best_lgbm = LGBMRegressor(**best_lgbm_params, random_state=42, n_jobs=-1)

# Wrap with PowerTransformer for target (delivery time)
final_model = TransformedTargetRegressor(
    regressor=best_lgbm,
    transformer=pt
)
```

**Training & Evaluation**

```python

                # Train on full training set
final_model.fit(X_train_trans, y_train)

# Predictions
y_train_pred = final_model.predict(X_train_trans)
y_test_pred  = final_model.predict(X_test_trans)

# Metrics
train_mae = mean_absolute_error(y_train, y_train_pred)
test_mae  = mean_absolute_error(y_test,  y_test_pred)
train_r2  = r2_score(y_train, y_train_pred)
test_r2   = r2_score(y_test,  y_test_pred)

print(f"Train MAE : {train_mae:.2f} minutes")
print(f"Test  MAE : {test_mae:.2f}  minutes")
print(f"Train R²  : {train_r2:.3f}")
print(f"Test  R²  : {test_r2:.3f}")
```

### Model Performance Results

| Metric | Train | Test |
| --- | --- | --- |
| **MAE (minutes)** | 2.84 | 4.71 |
| **R² Score** | 0.892 | 0.831 |

**Interpretation:** The model predicts the target with an average error of **2.84 minutes on training data** and **4.71 minutes on unseen test data**. The test **R² = 0.831** means the model explains about **83.1% of the variance** in the target variable.

### Cross-Validation for Reliability

A single train/test split can be lucky or unlucky. We verify performance stability with **5-fold cross-validation**:

```javascript

                cv_scores = cross_val_score(
    final_model,
    X_train_trans, y_train,
    cv=5,
    scoring="neg_mean_absolute_error",
    n_jobs=-1
)

print(f"CV MAE scores: {-cv_scores}")
print(f"Mean CV MAE:   {-cv_scores.mean():.2f} ± {cv_scores.std():.2f} minutes")
```

### cross-Validation Performance

| Metric | Result |
| --- | --- |
| **CV MAE Scores** | 4.68, 4.75, 4.73, 4.69, 4.70 minutes |
| **Mean CV MAE** | **4.71 minutes** |
| **CV MAE Standard Deviation** | **± 0.03 minutes** |

**Interpretation:** Across 5 folds, the model has an average prediction error of **4.71 minutes**, with very low variation (**±0.03 minutes**), indicating **stable and consistent performance across different data splits**.

The low standard deviation (0.03 min) shows the model is **stable and not overfitting to one particular fold**.

### Logging to MLflow

```python

                with mlflow.start_run(run_name="Final_LightGBM"):
    mlflow.set_tag("model", "LightGBM")

    mlflow.log_params(best_lgbm_params)

    mlflow.log_metric("train_mae", train_mae)
    mlflow.log_metric("test_mae",  test_mae)
    mlflow.log_metric("train_r2",  train_r2)
    mlflow.log_metric("test_r2",   test_r2)
    mlflow.log_metric("cv_mae",    -cv_scores.mean())

    # Save the model
    mlflow.sklearn.log_model(best_lgbm, "lgbm_model")
```

### 10. Understanding the LightGBM Model

Feature Importance

```python

                import matplotlib.pyplot as plt

feature_names = (
    processing_pipeline.named_steps['preprocess']
    .get_feature_names_out()
)

importances = best_lgbm.feature_importances_

feat_imp_df = (
    pd.DataFrame({'feature': feature_names, 'importance': importances})
    .sort_values('importance', ascending=False)
    .head(15)
)

fig, ax = plt.subplots(figsize=(10, 6))
ax.barh(feat_imp_df['feature'], feat_imp_df['importance'], color='#3B82F6')
ax.set_title('LightGBM — Top 15 Feature Importances', fontsize=14, fontweight='bold')
ax.set_xlabel('Importance Score')
ax.invert_yaxis()
plt.tight_layout()
plt.show()
```

**Top features driving delivery time predictions:**

| Rank | Feature | Interpretation |
| --- | --- | --- |
| 1 | `distance` | Longer distance = longer delivery time (obvious, but magnitude matters) |
| 2 | `traffic_jam` | Traffic jams have a huge non-linear impact |
| 3 | `pickup_time_minutes` | Time at restaurant before pickup directly adds to total time |
| 4 | `ratings` | Higher-rated riders tend to be faster |
| 5 | `distance_type` | Categorical form of distance provides additional signal |

---

## 11. Why LightGBM Won 🎯

Let's summarise why LightGBM outperformed all other approaches:

### 1. Leaf-wise Tree Growth

Unlike Random Forest (level-wise), LightGBM grows trees **leaf-wise**, choosing the split that reduces loss the most. This is more precise but requires regularization to prevent overfit — which our tuned params (`min_child_weight`, `reg_lambda`) handle perfectly.

### 2. Gradient Boosting Framework

LightGBM is a boosting method — each tree *corrects the errors* of the previous one. Random Forest builds independent trees and averages them. For structured tabular data, boosting tends to win.

### 3. Efficient Regularization

The combination of:

- `subsample=0.76` — stochastic row sampling (like SGD noise)
- `min_child_weight=20` — prevents learning from tiny leaf samples
- `reg_lambda=97.81` — L2 weight penalty on leaf scores

...creates a model that is powerful yet resistant to overfitting.

### 4. Fast Convergence

With `learning_rate=0.22` and 154 estimators, LightGBM converges quickly. A lower learning rate with more estimators might squeeze out 0.1–0.2 more MAE, but with diminishing returns.

### 5. Native Histogram-based Training

LightGBM bins continuous features into histograms for splits — faster to compute than exact splits (used by XGBoost default), yet surprisingly effective.

---

## 12. Key Takeaways

1. **Automated model selection with Optuna** beats manual trial-and-error — 30 trials cover far more ground than a human experimenter in the same time.
2. **Always transform your target** when it's skewed. PowerTransformer brought delivery time to Gaussian shape, which improved all models.
3. **Wrap models in `TransformedTargetRegressor`** to handle target transforms cleanly inside cross-validation — prevents data leakage.
4. **Stacking = diminishing returns** when base models are already strong. The complexity-performance tradeoff favored single LightGBM.
5. **Cross-validate, don't just test.** A CV MAE of 4.71 ± 0.03 minutes is a trustworthy number. A single test-set MAE could be lucky.
6. **Track everything in MLflow.** When you run 90+ experiments (30 model selection + 20 RF + 50 LGBM), you need a system — MLflow gave us a full audit trail.

---

## Part 3: MLOps Pipeline with DVC, MLflow Tracking & Registry, FastAPI Model Serving, and Docker Containerization

In **Part 1**, we explored and cleaned the raw delivery dataset and performed in-depth Exploratory Data Analysis (EDA) to understand feature interactions.
In **Part 2**, we built the preprocessing pipeline, experimented with candidate models using Optuna, tuned LightGBM and Random Forest, explored Stacking ensembles, and selected our **champion model**.

However, in real-world machine learning engineering, building an accurate model in a Jupyter Notebook is only **20% of the work**. The remaining **80%** involves building a reliable, reproducible, and production-grade **MLOps pipeline**:

- How do we automate each step so our pipeline is 100% reproducible?
- How do we track experiments, hyperparameters, and evaluation metrics systematically?
- How do we version and manage models across stages (`None` $\\rightarrow$ `Staging` $\\rightarrow$ `Production`)?
- How do we serve predictions in real-time via high-performance APIs?
- How do we package and containerize the entire application for seamless deployment?

In this final part, we turn our trained model into a production-ready system using **DVC**, **MLflow**, **FastAPI**, and **Docker**.

## 1. End-to-End MLOps Architecture

Before diving into the code, let’s look at how all components integrate into a production workflow:

 graph TD A\[1. Raw Data: swiggy.csv\] -->|Stage 1: DVC| B\[2. data_cleaning.py\] B -->|Cleaned Data| C\[3. data_preparation.py\] C -->|Train/Test Split| D\[4. data_preprocessing.py\] D -->|Preprocessor & Transformed Data| E\[5. train.py\] E -->|Trained Models & Transformers| F\[6. evaluation.py\] F -->|Metrics, Parameters & Artifacts| G\[7. MLflow Tracking Server / SQLite\] G -->|Run ID & Artifacts| H\[8. registry.py\] H -->|Versioned Model| I\[9. MLflow Model Registry: Staging\] I -->|Load Model at Startup| J\[10. FastAPI App: app.py\] J -->|Containerized with| K\[11. Docker & Docker Compose\] K -->|Exposed on Port 8000| L\[12. Web UI & REST API Endpoints\] G -.-> G1\[SQLite DB\] I -.-> I1\[Staging\] J -.-> J1\[app.py\] K -.-> K1\[Container\]

## 2. Pipeline Orchestration and Reproducibility with DVC

Data science workflows often suffer from the *"it works on my machine"* problem. Scripts are run in arbitrary order, datasets get overwritten, and reproducing previous results becomes challenging.

We solve this using **Data Version Control (DVC)**. DVC allows us to define dependency graphs (DAGs) across our pipeline stages in a single `dvc.yaml` file.

### 2.1 The Multi-Stage DVC Pipeline (`dvc.yaml`)

Each stage defines its exact **command (`cmd`)**, its **dependencies (`deps`)**, configurable **parameters (`params`)**, and output **artifacts (`outs`)**:

```python

                stages:
  data_cleaning:
    cmd: python src/data/data_cleaning.py
    deps:
      - data/raw/swiggy.csv
      - src/data/data_cleaning.py
    outs:
      - data/cleaned/swiggy_cleaned.csv

  data_preparation:
    cmd: python src/data/data_preparation.py
    params:
      - Data_Preparation.test_size
      - Data_Preparation.random_state
    deps:
      - data/cleaned/swiggy_cleaned.csv
      - src/data/data_preparation.py
    outs:
      - data/interim/train.csv
      - data/interim/test.csv

  data_preprocessing:
    cmd: python src/features/data_preprocessing.py
    deps:
      - data/interim/train.csv
      - data/interim/test.csv
      - src/features/data_preprocessing.py
    outs:
      - data/processed/train_trans.csv
      - data/processed/test_trans.csv
      - models/preprocessor.joblib

  train:
    cmd: python src/models/train.py
    deps:
      - src/models/train.py
      - data/processed/train_trans.csv
    params:
      - Train.Random_Forest
      - Train.LightGBM
    outs:
      - models/model.joblib
      - models/power_transformer.joblib
      - models/stacking_regressor.joblib

  evaluation:
    cmd: python src/models/evaluation.py
    deps:
      - src/models/evaluation.py
      - models/model.joblib
      - data/processed/train_trans.csv
      - data/processed/test_trans.csv
    outs:
      - run_information.json
    always_changed: true

  model_registory:
    cmd: python src/models/registory.py
    deps:
      - src/models/registory.py
      - run_information.json
```

### 2.2 Parameter Management with `params.yaml`

Decoupling parameters from application code allows us to tweak configurations (such as tree depth, learning rate, or sample ratios) without modifying Python files:

```python

                Data_Preparation:
  test_size: 0.25
  random_state: 42

Train:
  Random_Forest:
    n_estimators: 479
    criterion: 'squared_error'
    max_depth: 17
    max_features: 1
    min_samples_split: 9
    min_samples_leaf: 2
    max_samples: 0.6603673526197066
    verbose: 1
    n_jobs: -1

  LightGBM:
    n_estimators: 154
    max_depth: 27
    learning_rate: 0.22234435854395157
    subsample: 0.7592213724048168
    min_child_weight: 20
    min_split_gain: 0.004604680609280751
    reg_lambda: 97.81002379097947
    n_jobs: -1
```

### 2.3 Running and Reproducing the Pipeline

To execute the complete pipeline from scratch:

```python

                dvc repro
```

- **Smart Caching:** DVC calculates hash checksums for every dependency. If `swiggy_cleaned.csv` or `train.py` has not changed, DVC skips that step and uses cached outputs.
- **Pipeline Visualization:** You can inspect your pipeline graph by running:

```python

                dvc dag
```

---

## 3. Experiment Tracking and Metrics Logging with MLflow

During evaluation, we log everything to **MLflow** so that every run is transparent, auditable, and easily comparable.

### 3.1 What We Log to MLflow

1. **Tags:** Model metadata (`Food Delivery Time Regressor`).
2. **Parameters:** All hyperparameters loaded from `params.yaml` and model configurations.
3. **Metrics:**
   - `train_mae` & `test_mae`
   - `train_r2` & `test_r2`
   - `mean_cv_score` (5-fold cross-validation Mean Absolute Error)
   - Individual fold scores (`CV 0` through `CV 4`)
4. **Dataset Inputs:** Tracking training and validation Pandas datasets using `mlflow.data.from_pandas()`.
5. **Model Signature:** Input schema and output tensor specifications via `infer_signature()`.
6. **Artifacts:** Serialized models (`model.joblib`, `stacking_regressor.joblib`, `power_transformer.joblib`, and `preprocessor.joblib`).

### 3.2 Evaluation Script Implementation (`src/models/evaluation.py`)

```python

                import pandas as pd
import joblib
import logging
import os
import json
from pathlib import Path
from sklearn.model_selection import cross_val_score
from sklearn.metrics import mean_absolute_error, r2_score
import mlflow

TARGET = "time_taken"

def evaluate_and_log():
    root_path = Path(__file__).parent.parent.parent

    # Configure MLflow SQLite Tracking URI
    mlflow_db_path = (root_path / "mlflow.db").resolve()
    tracking_uri = os.getenv("MLFLOW_TRACKING_URI", f"sqlite:///{mlflow_db_path.as_posix()}")
    mlflow.set_tracking_uri(tracking_uri)
    mlflow.set_experiment("DVC Pipeline")

    # Load processed data
    train_data = pd.read_csv(root_path / "data/processed/train_trans.csv")
    test_data = pd.read_csv(root_path / "data/processed/test_trans.csv")

    X_train, y_train = train_data.drop(columns=[TARGET]), train_data[TARGET]
    X_test, y_test = test_data.drop(columns=[TARGET]), test_data[TARGET]

    # Load trained model
    model = joblib.load(root_path / "models/model.joblib")

    # Generate predictions
    y_train_pred = model.predict(X_train)
    y_test_pred = model.predict(X_test)

    train_mae = mean_absolute_error(y_train, y_train_pred)
    test_mae = mean_absolute_error(y_test, y_test_pred)
    train_r2 = r2_score(y_train, y_train_pred)
    test_r2 = r2_score(y_test, y_test_pred)

    # 5-Fold Cross Validation
    cv_scores = cross_val_score(model, X_train, y_train, cv=5, scoring="neg_mean_absolute_error", n_jobs=-1)
    mean_cv_score = -(cv_scores.mean())

    with mlflow.start_run() as run:
        mlflow.set_tag("model", "Food Delivery Time Regressor")
        mlflow.log_params(model.get_params())

        mlflow.log_metric("train_mae", train_mae)
        mlflow.log_metric("test_mae", test_mae)
        mlflow.log_metric("train_r2", train_r2)
        mlflow.log_metric("test_r2", test_r2)
        mlflow.log_metric("mean_cv_score", mean_cv_score)
        mlflow.log_metrics({f"CV_{i}": -score for i, score in enumerate(cv_scores)})

        # Log dataset context and signature
        model_signature = mlflow.models.infer_signature(
            model_input=X_train.sample(20, random_state=42),
            model_output=model.predict(X_train.sample(20, random_state=42))
        )

        # Log model & artifacts
        mlflow.sklearn.log_model(
            model,
            "delivery_time_pred_model",
            signature=model_signature,
            serialization_format="cloudpickle"
        )
        mlflow.log_artifact(root_path / "models/stacking_regressor.joblib")
        mlflow.log_artifact(root_path / "models/power_transformer.joblib")
        mlflow.log_artifact(root_path / "models/preprocessor.joblib")

        run_id = run.info.run_id
        artifact_uri = mlflow.get_artifact_uri()

    # Save run metadata for the registry stage
    run_info = {
        "run_id": run_id,
        "artifact_path": artifact_uri,
        "model_name": "delivery_time_pred_model"
    }
    with open(root_path / "run_information.json", "w") as f:
        json.dump(run_info, f, indent=4)
```

## 4. Centralized Model Registry and Governance

Once a model is trained and logged, we register it into the **MLflow Model Registry** and promote it to the `Staging` stage. This gives us:

- **Version Control for Models:** Every training run creates a new version (`v1`, `v2`, `v3`, etc.).
- **Stage Transitions:** Models move through formal environments
- **Decoupled Deployment:** Production applications fetch the model by stage (`models:/delivery_time_pred_model/Staging`) without hardcoding file paths or Run IDs.

---

### 4.1 Model Registration Script (`src/models/registory.py`)

```python

                import json
import os
from pathlib import Path
import mlflow
from mlflow import MlflowClient

def register_and_promote_model():
    root_path = Path(__file__).parent.parent.parent

    # Read run information created during evaluation
    with open(root_path / "run_information.json") as f:
        run_info = json.load(f)

    run_id = run_info["run_id"]
    model_name = run_info["model_name"]

    # Register the model from the MLflow run
    model_uri = f"runs:/{run_id}/{model_name}"
    model_version = mlflow.register_model(model_uri=model_uri, name=model_name)

    # Transition stage to 'Staging'
    client = MlflowClient()
    client.transition_model_version_stage(
        name=model_version.name,
        version=model_version.version,
        stage="Staging"
    )
    print(f"Model {model_name} (version {model_version.version}) transitioned to Staging.")

if __name__ == "__main__":
    register_and_promote_model()
```

## 5. Production Serving with FastAPI

For serving predictions, we build a REST API with **FastAPI**. FastAPI provides automatic data validation with Pydantic, high throughput (powered by Starlette and Uvicorn), and auto-generated interactive OpenAPI/Swagger documentation.

### 5.1 End-to-End Prediction Flow

When an API client sends a JSON payload with raw order details:

1. Pydantic validates data types.
2. `perform_data_cleaning()` transforms raw columns (derives distance, extracts time of day, calculates pickup duration, standardizes categories).
3. The Scikit-learn preprocessing pipeline transforms categorical & numerical features.
4. The registered LightGBM model makes the prediction and inverses the `PowerTransformer` target scale.
5. The API returns the predicted delivery time in minutes.

```python

                from fastapi import FastAPI, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sklearn.pipeline import Pipeline
import pandas as pd
import mlflow
import joblib
from pathlib import Path
from scripts.data_clean_utils import perform_data_cleaning

BASE_DIR = Path(__file__).resolve().parent

# Define input schema
class DeliveryData(BaseModel):
    ID: str
    Delivery_person_ID: str
    Delivery_person_Age: int
    Delivery_person_Ratings: float
    Restaurant_latitude: float
    Restaurant_longitude: float
    Delivery_location_latitude: float
    Delivery_location_longitude: float
    Order_Date: str
    Time_Orderd: str
    Time_Order_picked: str
    Weatherconditions: str
    Road_traffic_density: str
    Vehicle_condition: int
    Type_of_order: str
    Type_of_vehicle: str
    multiple_deliveries: int
    Festival: str
    City: str

# Load registered model and preprocessor
model_name = "delivery_time_pred_model"
stage = "Staging"
model = mlflow.sklearn.load_model(f"models:/{model_name}/{stage}")
preprocessor = joblib.load(BASE_DIR / "models/preprocessor.joblib")

# Construct end-to-end inference pipeline
inference_pipe = Pipeline(steps=[
    ('preprocess', preprocessor),
    ('regressor', model)
])

app = FastAPI(title="Swiggy Delivery Time Prediction API")

@app.post("/predict")
def predict_delivery_time(data: DeliveryData):
    try:
        # Convert request to single-row DataFrame
        raw_df = pd.DataFrame([data.model_dump()])

        # Clean and engineer features
        cleaned_df = perform_data_cleaning(raw_df)
        if cleaned_df.empty:
            raise ValueError("Input data could not be parsed into a valid prediction row.")

        # Predict delivery time in minutes
        prediction = inference_pipe.predict(cleaned_df)[0]
        return {
            "status": "success",
            "prediction_minutes": round(float(prediction), 2)
        }
    except ValueError as err:
        raise HTTPException(status_code=400, detail=str(err))
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Prediction failed: {exc}")
```

## 6. Containerization with Docker & Docker Compose

To ensure consistent execution across development, testing, and production servers, we containerize the application.

### 6.1 `Dockerfile`

We use an optimized, lightweight base image (`python:3.11-slim`) and install required C-extensions like `libgomp1` (required by LightGBM):

```python

                FROM python:3.11-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

WORKDIR /app

RUN apt-get update \
    && apt-get install -y --no-install-recommends build-essential libgomp1 \
    && rm -rf /var/lib/apt/lists/*

COPY requirements-runtime.txt /app/requirements-runtime.txt
RUN pip install --upgrade pip \
    && pip install -r requirements-runtime.txt

COPY . /app

EXPOSE 8000

CMD ["python", "app.py"]
```

6.2 `docker-compose.yml`

```python

                services:
  swiggy-app:
    build: .
    ports:
      - "8000:8000"
    environment:
      - MLFLOW_TRACKING_URI=sqlite:////app/mlflow.db
    volumes:
      - ./:/app
```

To build and start the entire service in one command:

```python

                docker compose up --build
```

Now open `http://localhost:8000/docs` in your browser to interact with the live Swagger UI, or access the frontend UI at `http://localhost:8000/`.

## 7. Testing the Complete System

Let’s test our live API by sending a sample request using `curl`:

```
curl -X POST "http://localhost:8000/predict" \
     -H "Content-Type: application/json" \
     -d '{
       "ID": "0x4607",
       "Delivery_person_ID": "INDORES13DEL02",
       "Delivery_person_Age": 37,
       "Delivery_person_Ratings": 4.9,
       "Restaurant_latitude": 22.745049,
       "Restaurant_longitude": 75.892471,
       "Delivery_location_latitude": 22.765049,
       "Delivery_location_longitude": 75.912471,
       "Order_Date": "19-03-2022",
       "Time_Orderd": "11:30:00",
       "Time_Order_picked": "11:45:00",
       "Weatherconditions": "conditions Sunny",
       "Road_traffic_density": "High",
       "Vehicle_condition": 2,
       "Type_of_order": "Snack",
       "Type_of_vehicle": "motorcycle",
       "multiple_deliveries": 0,
       "Festival": "No",
       "City": "Metropolitian"
     }'
```

API Response:

```
{
  "status": "success",
  "prediction_minutes": 24.36
}
```

The system takes raw delivery records and generates accurate, real-time predictions in milliseconds.

## 8. Summary & Key Takeaways

Across this 3-part series, we transitioned from a raw tabular dataset into an **enterprise-ready MLOps solution**:

| Stage | What We Built | Tool / Technology |
| --- | --- | --- |
| **Part 1** | Data Cleaning, Haversine Distance, Missing Values & Statistical EDA | Pandas, SciPy, Seaborn |
| **Part 2** | Preprocessor Pipeline, Optuna Search, LightGBM Tuning & Target Transformation | Scikit-Learn, LightGBM, Optuna |
| **Part 3** | Pipeline DAG Automation, Artifact Versioning | DVC |
| **Part 3** | Experiment Tracking, Metrics Logging & Model Governance | MLflow Tracking & Model Registry |
| **Part 3** | High-Performance API Serving & Web Interface | FastAPI, Pydantic, Jinja2, Uvicorn |
| **Part 3** | Production Packaging & Deployment | Docker & Docker Compose |

## Project Links & Resources

| Full code | [GitHub](https://github.com/sanjit507/Swiggy_Delivery_Time_Prediction) |
| --- | --- |
| Follow Me | [Linkdin](https://www.linkedin.com/in/sanjit-kumar-331275297/) |

## Interview Question

**Phase 1: Data Preparation & Exploratory Data Analysis (EDA)**

**Q1: "I notice your dataset had missing values. How did you handle them, and did your missing data percentage change after cleaning?"**

- **Answer:** The original dataset contained missing values represented as literal strings (like `"NaN"`). We programmatically replaced these regex patterns with actual NumPy null values (`np.nan`) so that Pandas and Scikit-learn could process them correctly.
- Notably, **the percentage of missing data rose from 9.2% to 16.35% during data cleaning**. This happened because we identified invalid `0.0` latitude and longitude coordinates—which geographically land in the middle of the Atlantic Ocean rather than India—and correctly coerced them to `np.nan` so they wouldn't corrupt our distance calculations.

**Q2: "Your target variable represents delivery time. How did you handle extreme delivery times (e.g., near 50 minutes)? Did you drop them as outliers?"**

- **Answer:** No, we kept them. By analyzing their context, we discovered that these extreme times occurred during **heavy traffic, bad weather, and over longer distances**. They represented genuine, challenging real-world delivery scenarios rather than data-entry errors.

**Q3: "What were your most important engineered features, and why did they matter?"**

- **Answer:** We engineered three highly impactful features:
  1. **Haversine Distance:** Computed from the raw latitude and longitude coordinates of the restaurant and delivery locations. Raw coordinates were dropped. Distance emerged as our **strongest linear predictor** of total delivery time.
  2. **Pickup Time in Minutes:** Calculated as the difference between when the order was picked up by the rider versus when it was placed. This captures restaurant preparation delays, and we found that **every minute a rider spends waiting at the restaurant directly adds a minute to the total delivery time**.
  3. **Order Time of Day:** Categorized the raw hour of the order into buckets (Morning, Afternoon, Evening, Night, After Midnight). ANOVA testing proved this categorical feature has a **statistically significant impact** on delivery time, especially with dinner orders (evening) taking longer due to peak traffic and high order volumes.

---

**Phase 2: Feature Preprocessing & Machine Learning Modeling**

**Q4: "Why did you apply a PowerTransformer to your target variable (****time_taken****), and how did you prevent data leakage when doing so?"**

- **Answer:** Delivery time in minutes is heavily right-skewed and non-normally distributed. Many machine learning algorithms perform better when target distributions are Gaussian. Applying the **Yeo-Johnson PowerTransformer** successfully brought our target close to a normal distribution.
- To prevent **data leakage**, we fit the `PowerTransformer` **strictly on the training target (****y_train****)** and then applied that fitted transformer to the test target (`y_test`). Fitting on the entire dataset beforehand would have leaked test-set information into the training phase.

**Q5: "You experimented with several models. Why did you ultimately select LightGBM as your champion model over a Stacking Regressor?"**

- **Answer:** We ran a 30-trial **Optuna study** to compare six algorithms, with LightGBM and Random Forest emerging as the clear leaders.
- We then built a Stacking Regressor combining Random Forest and LightGBM with a Linear Regression meta-model. While the Stacking model achieved a slightly lower test MAE of ~4.68 minutes, **LightGBM alone achieved nearly identical performance (~4.71 minutes MAE) with drastically lower complexity**. In production, we prioritized the single LightGBM model because it avoids cross-validation overhead during inference, is faster to predict, and is much simpler to maintain.

**Q6: "LightGBM can easily overfit. How did you regularize your champion model?"**

- **Answer:** We used a combination of several tuned hyperparameters to prevent overfitting:
  - `subsample` (0.76) to introduce stochastic row sampling.
  - `min_child_weight` (20) to prevent the model from learning from tiny leaf samples.
  - `reg_lambda` (97.81) to apply a strong L2 regularization penalty on the leaf weights.
  - `max_depth` (27) to restrict tree complexity. Together, these allowed the model to learn complex relationships while remaining highly stable.

---

**Phase 3: MLOps & Production Architecture**

**Q7: "Data science code often suffers from reproducibility issues. How did you manage pipeline steps and code-versus-hyperparameter separation?"**

- **Answer:** We used **Data Version Control (DVC)** to orchestrate our multi-stage pipeline. We mapped out explicit dependencies (`deps`) and outputs (`outs`) for data cleaning, preparation, preprocessing, model training, evaluation, and registration in a `dvc.yaml` file. DVC hashes these files so that if a dataset or script hasn't changed, it uses cached outputs.
- Furthermore, we decoupled our hyperparameters from our Python code by storing them in a centralized `params.yaml` file. This allowed us to tune model depth, estimators, and learning rates without rewriting any application code.

**Q8: "How did you handle experiment tracking and model deployment?"**

- **Answer:** We used **MLflow** connected to a local SQLite database to track our runs. During evaluation, we logged tags, hyperparameters, training and test MAE/R² scores, 5-fold cross-validation fold details, the input-output model signature, and our serialized pipeline artifacts (`models/preprocessor.joblib`, `models/power_transformer.joblib`, etc.).
- Once a champion model was trained, we used MLflow's **Model Registry** to formally transition it to the **"Staging" stage**.
- For serving, we built a **FastAPI** application. It dynamically loads the model directly from the MLflow registry using the `'Staging'` URI (decoupling our code from hardcoded local file paths). The request payload is validated using Pydantic, processed through our Scikit-learn preprocessor, passed to the LightGBM model, and the predicted minutes are returned.
- Finally, the entire application—including C-extensions like `libgomp1` required by LightGBM—was containerized using a lightweight `python:3.11-slim` **Dockerfile** and orchestrated using **Docker Compose** on port 8000

Q.1 Why did the percentage of missing data in the Swiggy dataset rise from 9.2% to 16.35% during the data cleaning phase?

- Because riders under 18 years old and ratings over 5.0 were filtered out of the dataset.
- Because invalid 0.0 latitude and longitude coordinates (which geographically land in the ocean) were replaced with NaN values.
- Because nominal categories with mixed casing were deleted from the dataset.
- Because all orders with a total delivery time of more than 50 minutes were discarded as outliers.

    Think about where 0.0 latitude and 0.0 longitude is located on a map and how it affects distance calculations.    [Discuss it](#)

Explanation

The original Swiggy dataset contained some 0.0 latitude and longitude entries, which map to the middle of the ocean rather than India. Converting these geographic anomalies to np.nan so they would not corrupt the Haversine distance calculations caused the overall percentage of missing values to increase from 9.2% to 16.35%.

Q.2 How was the target variable (delivery time in minutes) preprocessed to improve model performance without introducing data leakage?

- By applying a MinMaxScaler on the entire target array before splitting the data.
- By fitting a Yeo-Johnson PowerTransformer strictly on the training target (y_train) and then applying it to both train and test splits.
- By executing a standard log-transform globally across the complete raw dataset.
- By utilizing an OrdinalEncoder to convert continuous delivery times into discrete bins prior to train-test splitting.

    To prevent leakage, all transformers must learn their parameters solely from the training partition.    [Discuss it](#)

Explanation

The delivery time is right-skewed and non-normally distributed. To bring it closer to a Gaussian distribution, a Yeo-Johnson PowerTransformer was fit strictly on the training target (y_train) and then used to transform both y_train and y_test, preventing test-set characteristics from leaking into the training step.

Q.3 Why was the single tuned LightGBM model chosen as the production champion over the Stacking Regressor ensemble?

- The Stacking Regressor had a significantly worse Test MAE (~7.5 minutes) compared to LightGBM.
- LightGBM natively supports the Yeo-Johnson PowerTransformer, while Stacking Regressors do not.
- LightGBM achieved nearly identical performance (~4.71 minutes MAE) to the Stacking model (~4.68 minutes MAE) with vastly lower complexity and faster prediction speeds.
- The Stacking Regressor was structurally incompatible with the Pydantic data validation in FastAPI.

    Consider the real-world engineering trade-off between a tiny fraction of a minute's error improvement and deployment complexity.    [Discuss it](#)

Explanation

While the Stacking Regressor, combining Random Forest and LightGBM with a Linear Regression meta-model, scored a tiny performance increase of 4.68 minutes test MAE, LightGBM alone delivered 4.71 minutes. Choosing the single LightGBM model eliminated cross-validation overhead during inference and reduced production complexity.

Q.4 Which specific hyperparameter combination was used to heavily regularize the leaf-wise LightGBM champion model to prevent overfitting?

- min_samples_split of 9, max_samples of ~0.66, and max_depth of 17.
- min_child_weight of 20, subsample of ~0.76, and a strong L2 regularization penalty (reg_lambda) of ~97.81.
- learning_rate of 0.8, n_estimators of 500, and L1 regularization of 100.
- An ordinal encoding sentinel of -999 for missing values and -1 for unknown classes.

    Look for the parameters that control the minimum weight in a leaf, stochastic row sampling, and L2 penalties.    [Discuss it](#)

Explanation

LightGBM grows trees leaf-wise, making it prone to overfitting. It was successfully regularized using a combination of min_child_weight=20, restricting tiny leaf updates, subsample=0.7592, stochastic row sampling, and a strong L2 regularization penalty of reg_lambda=97.81.

Q.5 When packaging the FastAPI serving application into a Docker container, why was it necessary to install 'libgomp1' in the Dockerfile?

- It is a critical system C-extension library required by the LightGBM library for parallel execution.
- It is a mandatory backend engine used by Pydantic to validate input JSON schemas.
- It is used by DVC to calculate hash checksums for the serialized pipeline objects.
- It is required to establish the SQLite database connection for MLflow experiment tracking.

    Consider the compiled low-level dependencies that gradient boosting libraries require to execute on slim Linux images.    [Discuss it](#)

Explanation

LightGBM depends on OpenMP for multi-threaded parallel computing. When using a lightweight base image like python:3.11-slim in Docker, this system-level shared C-extension library, libgomp1, must be explicitly installed so LightGBM can load and run predictions without causing a crash.

Q.6 How does Data Version Control (DVC) determine whether to skip executing a specific pipeline stage and use cached outputs instead?

- DVC queries the FastAPI application endpoints to verify API status.
- DVC calculates cryptographic hash checksums for each stage's defined dependencies (deps) and runs the stage only if a dependency has changed.
- DVC checks if a specific MLflow Run ID is already registered in the SQLite tracking database.
- DVC relies on Docker Compose volume mounts to synchronize code files automatically.

    DVC builds a directed acyclic graph (DAG) by tracking file contents, not system time or databases.    [Discuss it](#)

Explanation

DVC calculates hash checksums for every dependency, such as swiggy_cleaned.csv or train.py, specified in the dvc.yaml file. If the hashes have not changed, DVC skips that step and uses cached outputs, ensuring smart caching and pipeline reproducibility.

Q.7 Why does the evaluation script (evaluation.py) capture and log a 'Model Signature' to MLflow alongside the metrics?

- To encrypt the serialized LightGBM booster using a secure password before saving.
- To document the model metadata with custom HTML descriptions for the UI.
- To record the input schema and output tensor specifications, acting as a structural contract for downstream model serving.
- To define the environment variables required by the docker-compose.yml configuration.

    The signature is inferred using mlflow.models.infer_signature() during the logging phase.    [Discuss it](#)

Explanation

The Model Signature documents the exact input schema and output tensor specifications. This serves as a formal interface contract that helps prevent schema mismatches when the FastAPI application loads the model from the MLflow registry for production serving.

Q.8 What is the primary operational benefit of dynamically loading the model via a stage-based URI (such as 'Staging') in the FastAPI serving application?

- It forces the model to bypass the Scikit-learn preprocessing pipeline.
- It allows the deployment code to remain unchanged and decoupled from specific hardcoded file paths or MLflow Run IDs when promoting a new champion model.
- It allows LightGBM to train on new incoming API payloads automatically.
- It minimizes container size by excluding C-extensions like libgomp1.

    Think about how a decoupled production application retrieves updated models without code modifications.    [Discuss it](#)

Explanation

Fetching the model dynamically using the 'Staging' URI decouples the production serving code from specific hardcoded file paths or Run IDs. When a new champion model version is registered and transitioned to Staging, the FastAPI application automatically loads it at startup without requiring any code deployment or code changes.

Q.9 How is the OrdinalEncoder configured in your Scikit-Learn ColumnTransformer to handle missing values and unseen categories for features like traffic?

- It uses a default median imputer and ignores any unseen categorical values.
- It maps missing ordinal values to a sentinel value of -999 and sets unknown values to -1.
- It drops any rows containing missing or unknown categorical variables before encoding.
- It automatically converts all ordinal variables into one-hot encoded columns.

    Look at the parameters configured for categories, encoded_missing_value, and unknown_value in the preprocessing pipeline.    [Discuss it](#)

Explanation

To handle missing values and unknown categories robustly during inference, the OrdinalEncoder explicitly maps missing values to a sentinel of -999 using encoded_missing_value=-999 and unknown values to -1 using unknown_value=-1.

Q.10 Why are raw coordinates (latitude and longitude) for the restaurant and delivery locations dropped from the dataset before training the models?

- The model cannot handle continuous numeric coordinate float formats.
- Raw coordinates add noise and high dimensionality, whereas the engineered Haversine distance captures the geographic relationship much more effectively.
- Geographical data violates user data privacy guidelines in production MLOps.
- LightGBM is structurally incapable of splitting on coordinate-based numeric variables.

    Think about feature representation and what the coordinates are used to calculate during cleaning.    [Discuss it](#)

Explanation

Raw latitude and longitude are dropped because they add noise and dimensionality without providing additional predictive power on their own. Instead, they are synthesized into a single, highly meaningful Haversine distance feature which serves as the model's strongest linear predictor.

Q.11 Based on your multi-model Optuna study comparing six different candidate algorithms, which algorithm had the weakest average cross-validation performance?

- K-Nearest Neighbors (KNN)
- Support Vector Machine (SVM)
- Gradient Boosting Regressor
- Random Forest Regressor

    Compare the average CV MAE scores of the models, ranging from 4.71 minutes to over 7 minutes.    [Discuss it](#)

Explanation

During the 30-trial Optuna study, the Support Vector Machine (SVR) performed the worst, with an average CV MAE of 7.12 minutes, whereas tree-based ensembles like LightGBM led the pack with a CV MAE of 4.71 minutes.

Q.12 In your dvc.yaml pipeline configuration, why is the 'evaluation' stage marked with the 'always_changed: true' parameter?

- To bypass the Docker container build cache during local deployment.
- To force the preprocessor to fit on the test data in every single run.
- To ensure that model evaluation, metrics logging, and MLflow run updates are executed on every run, regardless of whether prior file hashes match.
- To trigger an automatic deployment to the FastAPI production environment whenever any parameters change.

    Consider why you would want your evaluation metrics and run reports to be systematically re-logged even if code or datasets haven't changed.    [Discuss it](#)

Explanation

By setting always_changed: true under the evaluation stage in dvc.yaml, DVC is forced to execute the evaluation script (evaluation.py) every time dvc repro is run. This ensures that metrics, parameters, and model signatures are always properly evaluated and logged to MLflow.

### Join For More Updates

| 1. Official Telegram | [Click Here](https://t.me/csestudy247) |
| --- | --- |
| 2. Download App | [Cl](https://play.google.com/store/apps/details?id=com.csestudy247.app.v2&hl=en_IN)[ick Here](https://play.google.com/store/apps/details?id=com.csestudy247.app.v2&hl=en_IN) |
| 3. Download Study Routine App | [Click Here](https://www.indusappstore.com/apps/education/planithic/com.planithic.app/?page=details&id=com.planithic.app) |
| 4. Follow for AI Jobs | [Clikc Here](https://whatsapp.com/channel/0029Vb9QcUIJUM2f2u9wgB3x) |
| 4. CS/IT Job Alert | [Click Here](https://whatsapp.com/channel/0029VaQyxX3Chq6PbmfZqf3G) |
| 5. Join For Engineering Exam Job Alerts | [Click Here](https://whatsapp.com/channel/0029VbC8Tnl7j6gEpCPqBN22) |
| 6. DRDO & ISRO Job Alert | [Click Here](https://chat.whatsapp.com/J6sKnwIPe9NAAP6ZsNRHwV) |
| 7. EXAM PYQ PDF | [Click Here](https://t.me/csepyqexpress) |
| 8. Placement CS\\IT | [Click Here](https://chat.whatsapp.com/ITgWFv6ZswjB73nu7lE1Mp) |
