A decision-support system for replenishment under sparse, censored demand and asymmetric costs — not a generic sales forecasting exercise.
The current implementation ships with a synthetic print-on-demand publishing dataset as a demo/case study. The underlying problem, and the code, are not specific to publishing; they apply to any retail or consumer-goods setting where reorder decisions are made under similar constraints.
Note: All data in this repository (
data/) is synthetically generated (dgp/). No real business, sales, or inventory data is included.
Replenishment decisions in these settings share four properties that make them harder than a standard regression or forecasting problem:
- Sparse demand — many SKUs sell in small, lumpy quantities, especially in the long tail.
- Censored observations — when a SKU stocks out, observed sales stop reflecting true demand; the gap is invisible unless explicitly modeled.
- Asymmetric costs — overstocking (idle capital, storage, markdown risk) and understocking (lost sales, unmet demand) are rarely equally costly, and the ratio between them is a business input, not something a model can infer on its own.
- MOQ constraints — reorder quantities are often bound by a minimum order quantity, so most decision points should correctly resolve to "do nothing" rather than trigger a reorder.
The system is built to make these constraints explicit and inspectable, rather than absorbing them into a single opaque prediction.
The codebase is organized around the data's actual lifecycle, not by file type:
dgp/ → generates synthetic sales, stockout, and catalog data
features/ → builds features and labels from raw sales history, enforcing
strict no-lookahead (features/engineering.py, features/labels.py)
models/ → interchangeable demand predictors behind one shared interface
(heuristic, linear, gbdt — see models/base.py)
decision/ → converts a predicted demand distribution into a reorder
decision (whether to print, how many) under cost and MOQ
constraints (decision/policy.py)
evaluation/ → backtesting, censoring-bias diagnostics, and the multi-layer
evaluation report
config/ → business_spec.yaml — the single entry point for business
parameters (see "Configuration" below)
scripts/ → entry points: generate data, build the training table, tune
on dev, evaluate on a frozen test set
Data flows in one direction: dgp → features → models → decision → evaluation. Each stage only depends on the stages before it, which is what makes it possible to swap out a model or a business spec without touching the rest of the pipeline.
Two SQLite databases separate what a model is allowed to see from what it's being graded against:
data/tables.db— observed data (what a real system would have access to at decision time)data/truth.db— ground-truth demand, including what was lost to stockouts; used only for evaluation, never for training
Three predictors share a common interface (fit, predict_rate, predict_quantiles):
- Heuristic — rolling-mean baseline, no training.
- Linear — Ridge regression.
- GBDT — gradient-boosted trees with a Poisson objective.
This project is an ongoing effort to systematically examine how these commonly-used model classes perform under different data conditions and business settings, rather than assuming a more complex model is automatically the better choice. That analysis is still in progress.
config/business_spec.yaml is the single place where business-specific parameters live: cost structure, MOQ, target quantiles, and related settings. Adapting the system to a different business environment is intended to be a matter of editing this spec and pointing dgp/features at a new data source, without changes to the core modeling or decision code.
- Resolve open questions in the linear model's feature handling
- Add a unified scalar metric (pinball loss) across all three predictors
- Extend evaluation to real business data
pip install -r requirements.txt
# 1. Generate synthetic data
python -m scripts.gen
# 2. Build the feature/label training table
python -m scripts.build_training_table
# 3. Tune on the dev split only
python -m scripts.test_model
# 4. Final, frozen evaluation — never used for tuning
python -m scripts.eval
python -m scripts.final_reporttest_model.py and eval.py/final_report.py are kept separate deliberately: parameters are only ever tuned against the dev split, and the test split is evaluated once, at the end, to avoid contaminating the final numbers.