eta-eats is a regression pipeline for food delivery teams that need a delivery time estimate. It takes a raw delivery file through these steps to a tested model, a model card and a submission file:
clean with checked category maps → add row-wise features → split by time or courier → fit one pipeline → tune with grouped folds → score by slice → predict the test file.
Summary · Workflow · Run it · Configuration · Known problems · Glossary
Note
This README uses ASD-STE100 Simplified Technical English. The writing rules and the project
vocabulary are in docs/ste-style-guide.md. Each term in the
Glossary has only one meaning.
eta-eats predicts the delivery time of a food order in minutes from the courier, the distance, the traffic, the weather and the time of day. The main idea is that every feature reaches the model through one tested path. A category map rejects unknown values. Row-wise features have no fitted state. One sklearn pipeline is fitted on the training rows only. The tuned model is the model that the evaluation scores and saves.
This README is the one location that explains all of eta-eats. It gives these topics:
- the general design
- each component and its procedure, step by step
- the decision rules
- the data map
- the runbook
- the validation results and the known problems
| If you are… | Read |
|---|---|
| A manager or reviewer | 1, 3, 4, 12, 14 |
| A developer who joins the project | All sections, in sequence. Keep 10 and 13 open while you work |
| An operator who runs eta-eats | 10, then the section for the component that you use |
- 🧭 Summary
- 🏗️ How eta-eats is built
- 2.1 Components
- 2.2 System context
- 2.3 Repository layout
- 🛡️ Design rules
- 🔄 The end-to-end workflow
- 🔵 Data cleanup and row-wise features
- 🟢 Splits, pipeline and models
- 🟣 Evaluation, model card and prediction
- ⚖️ Decision rules and thresholds
- 🗂️ Data and file map
▶️ How to run eta-eats- 10.1 Prerequisites · 10.2 Installation · 10.3 Run eta-eats · 10.4 Environment variables
- 🧩 How to extend eta-eats
- ✅ Validation results
⚠️ Known problems- 📌 Key points
- 📖 Glossary
- 📄 License
The problem. A delivery time model is only as good as its features and its test. These are the difficult questions:
- Does every traffic level, including
Jam, reach the model? - Is the preparation time correct when the pick-up is after midnight?
- Does any statistic of the test rows leak into the training step?
- Is the scored model the tuned model?
- Does the model work for new couriers and for later dates?
- Which inputs matter, and does the importance table use the correct names?
eta-eats gives each of these questions its own component and its own tests.
| Item | Value |
|---|---|
| Input | The Kaggle train.csv and test.csv files, or synthetic files in the same format |
| Output | A saved pipeline, metadata.json, model_card.md, slice metrics, permutation importance, a submission CSV |
| Components | 6: cleaner, row-wise features, splits, model pipelines, evaluation, prediction |
| Providers | None. LightGBM is an optional extra |
| Offline mode | Everything. eta-eats demo generates data and runs the full workflow |
| Safety | Unknown category values stop the run. The preprocessor sees training rows only |
| Tests | 32 unit tests (pytest), 1 skipped without the lightgbm extra |
flowchart LR
IN["raw train.csv"] --> A["clean + checks"] --> B["row-wise features"] --> C["split"] --> D["pipeline fit + tune"] --> E["slices + importance"] --> OUT["model + model card + submission"]
| Component | Module | Purpose |
|---|---|---|
| Cleaner | src/eta_eats/clean.py |
Column contract, category maps, target parsing, coordinates, roll-over, data quality report |
| Row-wise features | src/eta_eats/features.py |
Distance, preparation time, hour, weekday, interaction, the preprocessor |
| Loading and splits | src/eta_eats/data.py |
prepare, load, time, courier and random splits |
| Models | src/eta_eats/models.py |
Five pipelines, an optional LightGBM pipeline, the randomized search |
| Evaluation | src/eta_eats/evaluate.py |
MAE, RMSE, R², MAPE, bootstrap interval, slices, permutation importance |
| Training and prediction | src/eta_eats/train.py |
Fit and score, compare, save, model card, submission |
| Synthetic data | src/eta_eats/synthetic.py |
Raw-format files with the Kaggle quirks |
| CLI | src/eta_eats/cli.py |
The eta-eats command |
The component map shows which module calls which module. An arrow points from the caller to the module that it uses.
flowchart TB
CLI["cli.py<br/>eta-eats command"]
SYN["synthetic.py<br/>write_synthetic"]
subgraph DATAG["Data"]
DAT["data.py<br/>load, prepare, split"]
CLN["clean.py<br/>clean, DOMAINS, CleanReport"]
FEA["features.py<br/>add_features, make_preprocessor, model_input"]
end
subgraph MODELG["Model"]
TRN["train.py<br/>fit_and_score, compare, save, predict_file"]
MOD["models.py<br/>make_pipeline, tune"]
EVA["evaluate.py<br/>metrics, bootstrap_mae, slice_metrics, importance"]
end
CLI --> SYN
CLI --> DAT
CLI --> TRN
DAT --> CLN
DAT --> FEA
FEA --> CLN
TRN --> DAT
TRN --> FEA
TRN --> MOD
TRN --> EVA
MOD --> FEA
flowchart TB
U["analyst"] --> CLI["eta-eats CLI"]
CLI --> RAW["data/train.csv, data/test.csv"]
CLI --> MOD["models/ (pipeline, metadata, model card)"]
CLI --> REP["reports/ (submission, comparisons)"]
eta-eats/
├── src/eta_eats/
│ ├── clean.py # category maps, target, coordinates, roll-over
│ ├── features.py # row-wise features, preprocessor
│ ├── data.py # prepare, load, splits
│ ├── models.py # pipelines and tuning
│ ├── evaluate.py # metrics, slices, importance
│ ├── train.py # fit, compare, save, model card, predict
│ ├── synthetic.py # raw-format synthetic files
│ └── cli.py # eta-eats command
├── tests/ # 32 pytest tests, synthetic data only
├── data/README.md # source, columns, known values
├── docs/ste-style-guide.md
├── .github/workflows/ci.yml
└── pyproject.toml
clean.py removes spaces and changes the string NaN to a missing value. Every category must be in its known set. An unknown value raises DataQualityError. The traffic map is Low 0, Medium 1, High 2, Jam 3, and a check confirms that the map adds no missing value.
features.add_features adds all row-wise features to the same frame that model_input gives to the pipeline. A test checks that each feature name appears in the fitted preprocessor output.
clean.py joins Order_Date with each time. If the pick-up is earlier than the order, the pick-up moves to the next day. Preparation times below 0 or above 120 minutes become missing values.
Row-wise features use one row only. The imputer, the scaler and the one-hot encoder are inside the pipeline, so they are fitted on the training rows only.
flowchart LR
ROWS[/"Clean rows with row-wise features"/] --> SPL["data.split"]
SPL --> TR["Training rows"]
SPL --> TE["Test rows"]
TR --> CV["tune: GroupKFold folds<br/>inside the training rows"]
TR --> FIT["Pipeline.fit<br/>imputers, scaler, one-hot encoder, regressor"]
CV --> FIT
FIT --> PRED["Pipeline.predict<br/>no fit on test rows"]
TE --> PRED
PRED --> REP[/"Metrics, slices, importance"/]
models.tune uses RandomizedSearchCV with refit=True and courier-grouped folds. train.fit_and_score scores and saves best_estimator_. A test compares the saved parameters with the best parameters.
evaluate.importance runs permutation importance on the whole pipeline with the model input columns. The names in the table are the names of the shuffled columns.
The courier ID and the delivery ID are not inputs. The courier city from the courier ID is an input.
All splits and models take a seed. The time split tests on later dates. The courier split tests on other couriers. eta-eats predict scores the Kaggle test.csv in the submission format.
The package has a CLI, local relative paths, environment variables and no notebook-only steps. The core needs NumPy, pandas, scikit-learn and joblib.
flowchart TD
RAW[/"Raw train.csv<br/>Kaggle or synthetic"/] --> LOAD["data.load<br/>read all values as text"]
LOAD --> CL["clean: spaces, NaN strings, category maps,<br/>target, coordinates, roll-over"]
CL --> OK{"Contract holds?"}
OK -- "no" --> DQE[/"DataQualityError"/]
OK -- "yes" --> FE["add_features: distance_km, prep_min,<br/>order_hour, weekday, traffic_x_distance"]
FE --> SP{"--split"}
SP -- "time, default" --> T1["Test rows: latest 20 % of dates"]
SP -- "courier" --> T2["Test rows: other couriers"]
SP -- "random" --> T3["Test rows: random 20 %"]
T1 --> TU{"--tune?"}
T2 --> TU
T3 --> TU
TU -- "no" --> FIT["make_pipeline.fit<br/>on the training rows"]
TU -- "yes, gbm only" --> RS["tune: RandomizedSearchCV,<br/>GroupKFold by courier, refit"]
FIT --> SC["Score the test rows: metrics,<br/>bootstrap MAE interval"]
RS --> SC
SC --> SL["slice_metrics: city, traffic,<br/>weather, distance band, festival"]
SC --> IMP["importance: permutation"]
SL --> SAVE[("models/<br/>model.joblib, metadata.json, slices.csv,<br/>importance.csv, model_card.md")]
IMP --> SAVE
SAVE --> CHK{{"HUMAN<br/>read the slice table and the model card<br/>before use in a new city"}}
TEST[/"Raw test.csv, no target"/] --> PRED["predict_file<br/>feature check, clean, predict"]
SAVE --> PRED
PRED --> SUB[/"reports/submission.csv"/]
classDef human fill:#fff3cd,stroke:#b8901f,color:#3d2f00,font-weight:bold
class CHK human
stateDiagram-v2
state "Raw text frame" as Raw
state "Clean frame" as Clean
state "Frame with row-wise features" as Featured
state "Train and test parts" as Parts
state "Fitted pipeline" as Fitted
state "Tuned pipeline, best_estimator_" as Tuned
state "Scored" as Scored
state "Saved in models/" as Saved
[*] --> Raw: load, all values as str
Raw --> DataQualityError: missing column, unknown category, bad date or target
Raw --> Clean: clean
Clean --> Featured: add_features
Featured --> ValueError: empty part
Featured --> Parts: split
Parts --> ValueError: --tune with a model that is not gbm
Parts --> Fitted: make_pipeline.fit
Parts --> Tuned: tune, refit=True
Fitted --> Scored: metrics, bootstrap_mae, slices, importance
Tuned --> Scored: metrics, bootstrap_mae, slices, importance
Scored --> Saved: save, model_card
Saved --> [*]
DataQualityError --> [*]
ValueError --> [*]
- Read the raw file as text.
- Clean the columns and check every category.
- Add the row-wise features.
- Split the rows with the chosen split.
- Fit the pipeline on the training rows, or tune it with grouped folds.
- Score the test rows and calculate the bootstrap interval of the MAE.
- Calculate the slice metrics and the permutation importance.
- Save the pipeline, the metadata, the slices, the importance and the model card.
sequenceDiagram
autonumber
actor A as Analyst
participant CLI as eta-eats CLI
participant DAT as data.py
participant TRN as train.py
participant MOD as models.py
participant EVA as evaluate.py
participant FS as models/ and reports/
A->>CLI: eta-eats train --model gbm --split time --tune
CLI->>DAT: load(path), then clean and add_features
DAT-->>CLI: frame and CleanReport
CLI->>DAT: split(frame, time, seed)
DAT-->>CLI: SplitResult
CLI->>TRN: fit_and_score(gbm, parts, do_tune)
TRN->>MOD: tune(X_train, y_train, courier groups)
MOD-->>TRN: best_estimator_, best_params, cv_mae
TRN->>EVA: metrics, bootstrap_mae, slice_metrics, importance
EVA-->>TRN: test metrics, interval, slices, importance
TRN-->>CLI: TrainOutcome
CLI->>TRN: save(outcome)
TRN->>FS: model.joblib, metadata.json, slices.csv, importance.csv, model_card.md
A->>CLI: eta-eats predict --input data/test.csv
CLI->>TRN: predict_file(model_dir, input, out)
TRN->>FS: load_model, check the feature list
TRN->>DAT: load(input, require_target=False)
TRN->>FS: write submission.csv with ID and the prediction
CLI-->>A: number of predictions written
Purpose. Change the raw file into typed columns and correct features.
The cleaner, clean.clean:
flowchart TD
IN[/"Raw frame, all values as text"/] --> COL{"19 input columns present?<br/>Time_taken(min) for a training file"}
COL -- "no" --> ERR[/"DataQualityError"/]
COL -- "yes" --> STR["_strip: remove spaces,<br/>NaN, nan and empty to missing"]
STR --> NUM["Numbers: age, rating, coordinates,<br/>vehicle condition, multiple deliveries"]
STR --> CC["courier_city: letters<br/>before RES in the courier ID"]
STR --> DATE{"Order_Date readable<br/>as dd-mm-yyyy?"}
DATE -- "no" --> ERR
DATE -- "yes" --> TIME["Join the date with the order time<br/>and the pick-up time"]
STR --> CAT{"Each category in DOMAINS?<br/>weather without the conditions prefix"}
CAT -- "no" --> ERR
CAT -- "yes" --> TRF["traffic_level map<br/>Low 0, Medium 1, High 2, Jam 3"]
STR --> TGT["parse_target<br/>regex on (min) 24"]
TGT --> TOK{"Training file:<br/>target readable?"}
TOK -- "no" --> ERR
TOK -- "yes" --> OUT[/"Clean frame and CleanReport"/]
NUM --> OUT
CC --> OUT
TIME --> OUT
TRF --> OUT
The row-wise features, features.add_features:
flowchart LR
C[/"Clean frame"/] --> D["haversine_km<br/>restaurant to drop point"]
C --> P["prep_min = pick-up time - order time<br/>outside 0 to 120 set to missing"]
C --> H["order_hour from the order time,<br/>else from the pick-up time"]
C --> W["weekday from Order_Date"]
D --> X["traffic_x_distance =<br/>traffic_level × distance_km"]
D --> OUT[/"Frame with row-wise features,<br/>count of impossible prep times"/]
P --> OUT
H --> OUT
W --> OUT
X --> OUT
| Input | Output |
|---|---|
| A raw CSV file | A clean frame with row-wise features, a data quality report |
Procedure
- Check that all 19 input columns exist. A training file also needs
Time_taken(min). - Remove spaces and change
NaNstrings to missing values. - Remove the prefix
conditionsfrom the weather values. - Check each category against its known set.
- Change the target text
(min) 24to the number 24 with a regular expression. - Make negative coordinates positive. Make coordinates below 1 degree missing.
- Make ratings outside 1 to 5 missing.
- Join the order date with the order time and the pick-up time. Roll over pick-ups after midnight.
- Calculate the distance, the preparation time, the order hour, the weekday and
traffic_x_distance.
Rules
- The order hour comes from the order time. If the order time is missing, it comes from the pick-up time.
- The courier city is the letters before
RESin the courier ID.
| Feature | Formula |
|---|---|
distance_km |
Haversine distance, Earth radius 6371.0088 km |
prep_min |
Pick-up time minus order time, in minutes |
order_hour |
Hour plus minutes / 60 |
weekday |
Monday 0 to Sunday 6, from Order_Date |
traffic_level |
Low 0, Medium 1, High 2, Jam 3 |
traffic_x_distance |
traffic_level × distance_km |
Purpose. Fit a model that generalizes to later dates and to new couriers.
The splits, data.split:
flowchart TD
IN[/"Clean frame, kind,<br/>test_size 0.2, seed"/] --> K{"kind"}
K -- "time" --> T["Sort the distinct order dates,<br/>cut at 80 %"]
T --> TT["Train: dates before the cut<br/>Test: dates from the cut"]
K -- "courier" --> G["GroupShuffleSplit<br/>groups = courier_id"]
G --> GT["No courier in both parts"]
K -- "random" --> R["train_test_split<br/>seeded rows"]
K -- "other" --> ERR[/"ValueError"/]
TT --> E{"Empty part?"}
GT --> E
R --> E
E -- "yes" --> ERR
E -- "no" --> OUT[/"SplitResult: train, test,<br/>kind, description"/]
The model pipeline, models.make_pipeline:
flowchart LR
X[/"model_input<br/>17 feature columns"/] --> NUMC["10 NUMERIC columns"]
X --> CATC["7 CATEGORICAL columns"]
NUMC --> NI["SimpleImputer median<br/>+ missing indicators"]
NI --> SS["StandardScaler"]
CATC --> CI["SimpleImputer<br/>constant missing"]
CI --> OH["OneHotEncoder<br/>min_frequency 20, infrequent group"]
SS --> CT["ColumnTransformer prep"]
OH --> CT
CT --> RG["Regressor from _regressor<br/>median, ridge, random_forest,<br/>gbm, mlp or lightgbm"]
RG --> P[/"Pipeline: prep + model"/]
The tuning, models.tune:
flowchart TD
IN[/"Training rows, target,<br/>courier_id groups"/] --> G{"Model is gbm?"}
G -- "no" --> ERR[/"ValueError"/]
G -- "yes" --> RS["RandomizedSearchCV<br/>n_iter settings from SEARCH_SPACE"]
RS --> CV["GroupKFold, 5 folds<br/>each courier in one validation fold"]
CV --> SC["Score: negative MAE"]
SC --> BEST["best_params_, best_score_"]
BEST --> RF["refit=True: fit the best setting<br/>on all training rows"]
RF --> OUT[/"best_estimator_ is the final model,<br/>cv table, cv_mae"/]
| Input | Output |
|---|---|
| The clean frame | A fitted pipeline, the tuning table |
Procedure
- Split the rows. The default is the
timesplit: the latest 20 % of order dates are test rows. - Make the pipeline: the preprocessor plus one regressor.
- The preprocessor imputes numeric columns with the median and adds missing-value indicators. Then it scales them.
- It imputes categorical columns with
missingand one-hot encodes them. Values with fewer than 20 rows join one group. - If you give
--tune, the randomized search tries--n-itersettings of thegbmpipeline with 5 courier-grouped folds. - The search refits the best setting on all training rows. That pipeline is the final model.
Rules
- The
couriersplit puts each courier in the training rows or in the test rows, never in both. - The tuning uses the training rows only.
| Model | Regressor |
|---|---|
median (baseline) |
DummyRegressor(strategy="median") |
ridge |
Ridge(alpha=1.0) |
random_forest |
200 trees, min_samples_leaf=5 |
gbm |
HistGradientBoostingRegressor, 400 iterations, learning rate 0.1 |
mlp |
MLPRegressor(64, 32), early stopping, seeded |
lightgbm (optional extra) |
LGBMRegressor, 500 trees, learning rate 0.05 |
Purpose. Tell how good the model is, for which deliveries, and give predictions for new deliveries.
The evaluation, in train.fit_and_score and train.save:
flowchart LR
M[/"Fitted or tuned pipeline"/] --> PR["predict on the test rows"]
T[/"Test rows"/] --> PR
PR --> MET["metrics: MAE, RMSE, R²,<br/>MAPE, within 5 min"]
PR --> BS["bootstrap_mae<br/>1,000 draws, 95 %"]
PR --> SL["slice_metrics<br/>MAE and RMSE for each slice value"]
M --> IMP["importance: up to 3,000 rows,<br/>5 repeats, MAE increase"]
T --> IMP
MET --> CARD[("models/: metadata.json, slices.csv,<br/>importance.csv, model_card.md")]
BS --> CARD
SL --> CARD
IMP --> CARD
The prediction, train.predict_file:
flowchart TD
DIR[("models/<br/>metadata.json, model.joblib")] --> LM["load_model"]
LM --> FC{"Saved features =<br/>FEATURES of this version?"}
FC -- "no" --> ERR[/"ValueError: train it again"/]
FC -- "yes" --> LD["load the input file,<br/>require_target=False"]
IN[/"test.csv without a target"/] --> LD
LD --> MI["model_input"]
MI --> PR["model.predict"]
PR --> CL["Clip below 0 to 0,<br/>round to 4 decimals"]
CL --> OUT[/"submission.csv<br/>ID, Time_taken (min)"/]
| Input | Output |
|---|---|
| The fitted pipeline, the test rows, a file without a target | Metrics, slices, importance, model_card.md, submission.csv |
Procedure
- Calculate MAE, RMSE, R², MAPE and the share of predictions within 5 minutes.
- Calculate the 95 % bootstrap interval of the MAE over the test rows.
- Calculate the MAE and the RMSE for each slice.
- Calculate the permutation importance on up to 3,000 test rows with 5 repeats.
- Save the model card with the intended use, the data, the metrics, the top inputs and the limits.
- For
predict, load the model and check that its features match this version. - Clean the new file without a target, predict and write
IDandTime_taken (min).
Rules
- Predictions below 0 become 0.
- A saved model with other features stops
predictwith a message.
The diagram shows the value rules that one row goes through before its values reach the pipeline.
flowchart TD
ROW[/"One raw row"/] --> CO{"Coordinate below 0?"}
CO -- "yes" --> AB["Use the absolute value,<br/>count a sign fix"]
CO -- "no" --> C1{"Absolute value<br/>below 1 degree?"}
AB --> C1
C1 -- "yes" --> CM["Coordinate missing"]
C1 -- "no" --> RT{"Rating outside 1 to 5?"}
CM --> RT
RT -- "yes" --> RM["Rating missing"]
RT -- "no" --> RO{"Pick-up earlier<br/>than the order?"}
RM --> RO
RO -- "yes" --> RD["Add 1 day to the pick-up"]
RO -- "no" --> PT{"prep_min below 0<br/>or above 120?"}
RD --> PT
PT -- "yes" --> PM["prep_min missing"]
PT -- "no" --> OUT[/"Values that reach the pipeline"/]
PM --> OUT
| Rule | Value | Code |
|---|---|---|
| Missing value strings | NaN, nan, empty |
clean.py |
| Valid rating | 1 to 5 | clean.py |
| Valid coordinate | Absolute value of 1 degree or more | clean.py |
| Valid preparation time | 0 to 120 minutes | clean.py, features.py |
| Roll-over | Pick-up earlier than the order: add 1 day | clean.py |
| Test share | 20 % (time: latest dates, courier: groups, random: rows) | data.py |
| Rare category | Fewer than 20 training rows: one group | features.py |
| Tuning | n_iter 20 (CLI default), 5 GroupKFold folds, MAE |
models.py |
| Bootstrap | 1,000 draws, 95 % percentile interval | evaluate.py |
| Distance bands | 0-3, 3-6, 6-10, 10-15, 15+ km | evaluate.py |
| Importance | 5 repeats, up to 3,000 rows, MAE increase | evaluate.py |
Responsible use. The estimate is for planning and for customer messages. Do not use it to judge, rank or pay a courier. The data can have bias by city and by vehicle type. Check the slice table before you use a model in a new city.
| Path | Committed? | Contents |
|---|---|---|
data/README.md |
Yes | Source, columns, known values |
data/train.csv, data/test.csv, data/Sample_Submission.csv |
No (git ignores it) | Kaggle files or synthetic files |
models/model.joblib |
No (git ignores it) | The fitted pipeline |
models/metadata.json |
No (git ignores it) | Model, features, split, seed, metrics, tuning, versions |
models/model_card.md |
No (git ignores it) | Intended use, data, metrics, top inputs, limits |
models/slices.csv, models/importance.csv |
No (git ignores it) | Slice metrics, permutation importance |
reports/submission.csv |
No (git ignores it) | Predictions for the test file |
.env.example |
Yes | Variable names only |
| Need | For |
|---|---|
| Python 3.11+ | All components |
The Kaggle files (see data/README.md) |
Results on real data |
lightgbm (extra lightgbm) |
Only the lightgbm model |
git clone https://github.com/KrishnaAnnavaram/eta-eats.git
cd eta-eats
python -m venv .venv
. .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
pip install -e ".[lightgbm]" # optionalRun the offline demo first. It uses synthetic data only.
eta-eats demo --out reports/demoRun each step on the Kaggle files.
eta-eats synth --out data --rows 6000 # synthetic files in the raw format
eta-eats clean --data data/train.csv # data quality report
eta-eats clean --data data/test.csv --no-target
eta-eats compare --data data/train.csv --split time --out reports/compare_time.csv
eta-eats compare --data data/train.csv --split courier
eta-eats train --data data/train.csv --model gbm --split time --tune --n-iter 20 --out models
eta-eats predict --model-dir models --input data/test.csv --out reports/submission.csv
pytest -q| Command | What it does |
|---|---|
synth |
Writes synthetic train.csv, test.csv and Sample_Submission.csv |
clean |
Prints the data quality report and the traffic counts |
compare |
Fits all models on one split and prints the metrics with the MAE interval |
train |
Fits (or tunes) one model, scores it and saves the model, the metadata and the model card |
predict |
Scores a file without a target and writes the submission |
demo |
Synthetic files, two comparisons, a tuned model, importance, slices and a submission |
The diagram shows the order of the commands and the files that connect them.
flowchart LR
INS["pip install -e .[dev]"] --> SYN["eta-eats synth"]
KAG[/"Kaggle train.csv and test.csv"/] --> DATA
SYN --> DATA[("data/<br/>train.csv, test.csv")]
DATA --> CLN["eta-eats clean"]
DATA --> CMP["eta-eats compare"]
DATA --> TRN["eta-eats train"]
TRN --> MOD[("models/<br/>model.joblib, metadata.json")]
MOD --> PRD["eta-eats predict"]
DATA --> PRD
PRD --> SUB[/"reports/submission.csv"/]
INS --> DEMO["eta-eats demo<br/>synthetic data, compare, tuned gbm, submission"]
| Variable | Used by | Meaning |
|---|---|---|
ETA_EATS_DATA_DIR |
clean, train, compare |
Folder of train.csv when --data is not given, default data |
ETA_EATS_MODEL_DIR |
train, predict |
Model folder, default models |
ETA_EATS_OUTPUT_DIR |
predict |
Folder of submission.csv, default reports |
ETA_EATS_SEED |
train, compare |
Seed when --seed is not given, default 42 |
eta-eats needs no credentials. It reads the variables from the process environment only. It does not load a .env file, so export local settings in the shell. Git ignores .env.
flowchart LR
ENV[/"Process environment"/] --> E1["ETA_EATS_DATA_DIR<br/>default data"]
ENV --> E2["ETA_EATS_MODEL_DIR<br/>default models"]
ENV --> E3["ETA_EATS_OUTPUT_DIR<br/>default reports"]
ENV --> E4["ETA_EATS_SEED<br/>default 42"]
E1 -- "no --data" --> C1["clean, train, compare<br/>read train.csv"]
E2 -- "no --out or --model-dir" --> C2["train writes,<br/>predict reads"]
E3 -- "no --out" --> C3["predict writes<br/>submission.csv"]
E4 -- "no --seed" --> C4["train, compare"]
E4 -- "not an integer" --> X[/"SystemExit with a message"/]
| You want to… | Do this | Code change? |
|---|---|---|
| Accept a new category value | Add it to DOMAINS in clean.py |
Small |
| Add a row-wise feature | Calculate it in add_features and add its name to NUMERIC or CATEGORICAL |
Small |
| Add a model | Add a branch to _regressor and a name to MODELS |
Small |
| Tune another model | Add a search space and call RandomizedSearchCV like tune |
Small |
| Add a slice | Add the column name to SLICES in evaluate.py |
Small |
| Serve predictions | Load model.joblib and call prepare and model_input before predict |
Yes |
All results below come from pytest and from eta-eats demo with seed 42. The demo results use synthetic data: 6,000 generated training deliveries and 1,500 generated test deliveries. They are not results on the Kaggle data.
| Validation | Result | Command |
|---|---|---|
| Unit tests | 32 passed, 1 skipped (LightGBM not installed) | pytest -q |
| Data quality (synthetic) | 6,000 rows, 53 sign fixes, 382 invalid coordinates, 32 roll-overs, 85 missing traffic values, 0 unknown categories | eta-eats demo |
| Time split MAE in minutes (synthetic) | gbm 2.59 (2.45 to 2.75), mlp 2.75, random_forest 2.82, ridge 3.46, median 9.06 |
eta-eats demo |
| Courier split MAE in minutes (synthetic) | gbm 2.76 (2.61 to 2.92), mlp 2.92, random_forest 2.98, ridge 3.59, median 9.07 |
eta-eats demo |
Tuned gbm, time split (synthetic, 8 settings) |
MAE 2.50, cross-validated MAE 2.59, R² of the untuned gbm 0.88 |
eta-eats demo |
| Top permutation importance (synthetic) | distance_km 4.37 min, traffic_x_distance 3.87 min, traffic_level 0.64 min |
eta-eats demo |
| MAE by traffic level (synthetic) | Jam 1.61, High 2.08, Medium 2.87, Low 3.17 | eta-eats demo |
The synthetic delivery time comes from a known formula with a traffic × distance effect. The importance table finds that effect, so the interaction feature reaches the model. The courier split gives a higher MAE than the time split, as expected for new couriers. The prototype reported scores from a different, leaky setup. They are not reproduced here.
Read these problems before you use eta-eats in production.
| # | Area | Problem | Impact and action |
|---|---|---|---|
| 1 | Validation | CI uses synthetic data. Results on the 45,593 Kaggle deliveries are not reproduced here | Run eta-eats compare and eta-eats train --tune on the Kaggle files |
| 2 | Synthetic data | The synthetic formula is simpler than real deliveries | Do not compare synthetic MAE values with real MAE values |
| 3 | Coordinates | Coordinates below 1 degree become missing, and the distance is imputed | Deliveries with bad coordinates get a weaker estimate |
| 4 | Time | The dataset covers about 8 weeks. Seasonal effects are not in the data | Retrain with data from more months before long-term use |
| 5 | Tuning | Only the gbm model has a tuning search. The demo uses 8 settings |
Use --n-iter 20 or more on real data |
| 6 | Explainability | Permutation importance only. No SHAP values | Correlated inputs (distance and the interaction) share their importance |
| 7 | LightGBM | The optional model is not tested in CI | Run the skipped test with pip install -e ".[lightgbm]" |
| 8 | Fairness | The model is not checked for effects on couriers | Do not use the estimate to judge couriers |
- Every traffic level reaches the model. The category map includes
Jam, and unknown values stop the run. - Preparation time is correct after midnight. Pick-ups roll over to the next day.
- One pipeline, fitted on training rows only. No statistic of the test rows leaks.
- The tuned model is the saved model.
- Importance names are correct. They come from the shuffled input columns.
- The tests use later dates or new couriers.
- The Kaggle test file gets a submission.
| Term | Meaning |
|---|---|
| Baseline | The median model, which predicts the median delivery time |
| Category map | The fixed set of known values for one categorical column |
| Courier | The person who delivers, with a courier ID |
| Courier city | The city code at the start of the courier ID |
| Data quality report | The counts that the cleaning step prints |
| Delivery | One row of a raw file |
| Delivery time | The target: minutes from the order to the delivery |
| Distance | The Haversine distance in kilometres from the restaurant to the customer |
| Fold | One part of the courier-grouped cross-validation |
| Interaction | The feature traffic_x_distance |
| Model card | The file with the intended use, data, metrics and limits |
| Permutation importance | The increase of the test MAE when one input column is shuffled |
| Pipeline | The preprocessor plus the regressor, fitted on training rows only |
| Preparation time | Minutes from the order time to the pick-up time |
| Preprocessor | The ColumnTransformer for imputation, scaling and one-hot encoding |
| Raw file | A Kaggle CSV file before cleaning |
| Roll-over | A pick-up time earlier than the order time, moved to the next day |
| Row-wise feature | A feature that uses the values of one row only |
| Slice | A group of test rows with one value of one column |
| Split | The division into training rows and test rows |
| Submission | The CSV file with ID and the predicted Time_taken (min) |
| Synthetic data | Generated files. They are not real deliveries |
| Traffic level | Low 0, Medium 1, High 2, Jam 3 |
| Tuned model | The refitted best pipeline of the randomized search |
MIT © 2026 Krishna Annavaram