kuechenpassagent / src /ml /feature_engineering.py
lederyou's picture
Upload folder using huggingface_hub
db662ea verified
Raw
History Blame Contribute Delete
4.95 kB
"""Feature engineering for the preparation-time model.
Highlights:
- ``kitchen_load`` is derived from order timestamps: number of other orders that
fell into a 30-minute rolling window before each order, per city. The raw
dataset has no explicit "kitchen load" feature; we approximate it through
order density, which is the most direct proxy for "how busy was the kitchen
when the order came in".
- Time-of-day buckets, weekday/weekend, distance.
The output dataframe is what ``train.py`` consumes.
"""
from __future__ import annotations
import sys
from math import asin, cos, radians, sin, sqrt
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.config import PROCESSED_DIR # noqa: E402
CLEAN_CSV = PROCESSED_DIR / "food_delivery_clean.csv"
FEATURES_CSV = PROCESSED_DIR / "food_delivery_features.csv"
TARGET = "Time_taken(min)"
NUMERIC_FEATURES = [
"Delivery_person_Age",
"Delivery_person_Ratings",
"Vehicle_condition",
"multiple_deliveries",
"distance_km",
"distance_missing",
"kitchen_load_30min",
"hour_of_day",
"day_of_week",
"is_peak_hour",
"is_weekend",
]
CATEGORICAL_FEATURES = [
"Weatherconditions",
"Road_traffic_density",
"Type_of_order",
"Type_of_vehicle",
"Festival",
"City",
]
def haversine_km(lat1: pd.Series, lon1: pd.Series, lat2: pd.Series, lon2: pd.Series) -> pd.Series:
r = 6371.0
lat1r = np.radians(lat1)
lat2r = np.radians(lat2)
dlat = np.radians(lat2 - lat1)
dlon = np.radians(lon2 - lon1)
a = np.sin(dlat / 2) ** 2 + np.cos(lat1r) * np.cos(lat2r) * np.sin(dlon / 2) ** 2
return 2 * r * np.arcsin(np.sqrt(a))
def add_distance(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
if {
"Restaurant_latitude",
"Restaurant_longitude",
"Delivery_location_latitude",
"Delivery_location_longitude",
}.issubset(df.columns):
df["distance_km"] = haversine_km(
df["Restaurant_latitude"],
df["Restaurant_longitude"],
df["Delivery_location_latitude"],
df["Delivery_location_longitude"],
)
# cap absurd distances (data noise)
df.loc[df["distance_km"] > 50, "distance_km"] = np.nan
else:
df["distance_km"] = np.nan
return df
def add_time_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["order_datetime"] = pd.to_datetime(df["order_datetime"], errors="coerce")
df["hour_of_day"] = df["order_datetime"].dt.hour
df["day_of_week"] = df["order_datetime"].dt.dayofweek
df["is_weekend"] = df["day_of_week"].isin([5, 6]).astype(int)
df["is_peak_hour"] = df["hour_of_day"].isin([12, 13, 19, 20, 21]).astype(int)
return df
def add_kitchen_load(df: pd.DataFrame, window_minutes: int = 30) -> pd.DataFrame:
"""Count of other orders within the previous ``window_minutes`` per city.
The City column is a noisy categorical proxy for "restaurant location".
We fall back to a single global grouping if the column is missing.
"""
df = df.copy()
df = df.sort_values("order_datetime")
group_col = "City" if "City" in df.columns else None
def _per_group(g: pd.DataFrame) -> pd.DataFrame:
g = g.set_index("order_datetime").sort_index()
rolling = g.assign(_one=1)["_one"].rolling(f"{window_minutes}min").sum() - 1
g["kitchen_load_30min"] = rolling.values
return g.reset_index()
if group_col is not None and df[group_col].notna().any():
df = df.groupby(group_col, group_keys=False).apply(_per_group)
else:
df = _per_group(df)
df["kitchen_load_30min"] = df["kitchen_load_30min"].clip(lower=0).fillna(0)
return df
def build_features(df: pd.DataFrame) -> pd.DataFrame:
df = add_distance(df)
# Explicit missing-distance flag so the model can learn the missing-data
# pattern instead of relying on a silent median imputation. Captures both
# missing coordinates and distances capped as noise (> 50 km).
df["distance_missing"] = df["distance_km"].isna().astype(int)
df = add_time_features(df)
df = add_kitchen_load(df)
keep = [TARGET] + NUMERIC_FEATURES + [c for c in CATEGORICAL_FEATURES if c in df.columns]
keep = [c for c in keep if c in df.columns]
out = df[keep].copy()
# Drop rows with missing target
out = out.dropna(subset=[TARGET])
return out
def main() -> None:
if not CLEAN_CSV.exists():
raise FileNotFoundError(
f"{CLEAN_CSV} missing. Run 'python -m src.ml.prepare_data' first."
)
df = pd.read_csv(CLEAN_CSV)
feats = build_features(df)
FEATURES_CSV.parent.mkdir(parents=True, exist_ok=True)
feats.to_csv(FEATURES_CSV, index=False)
print(f"[features] wrote {FEATURES_CSV} ({len(feats):,} rows, {feats.shape[1]} cols)")
if __name__ == "__main__":
main()