Offline contextual bandit for dispatch optimization on Graph Data for supply chain of pallets.
This project learns a dispatching policy from logged decisions and applies it to recommend a ServiceCenter (SC) for each open WorkOrder (WO). It runs in two layers:
- A Python pipeline that pulls historical (context, action, reward) data from TigerGraph, trains five models, and writes per-candidate predictions back to TigerGraph as edge attributes.
- A set of GSQL queries that consume those predictions to produce business-readable recommendations with tunable cost weights.
The clean separation means data scientists can retrain models without touching business logic, and operators can re-weight cost penalties without retraining.
Given:
- A history of WorkOrder → ServiceCenter assignments with realized outcomes (was_late, had_damage, had_overflow, costs)
- A set of currently-open WorkOrders and their candidate ServiceCenters
The system produces, for every (open WO, candidate SC) pair:
- A predicted probability of late delivery, damage, and overflow
- A learned policy score
- A behavior propensity (how likely the historical policy was to make this choice given the WO context)
- A SHAP-based reasoning string explaining the prediction
- An expected total cost combining direct cost with risk-adjusted penalties
A GSQL query then picks the cost-minimizing SC per open WO using business-tunable penalty weights, and exposes a recommendation table any downstream consumer can query.
┌─────────────────┐ ┌──────────────────────────────────┐
│ TigerGraph │ │ Python pipeline │
│ │ pull │ │
│ Logged WOs │────────▶│ Encoder (one-hot + scaling) │
│ Open WOs │ │ Propensity μ̂(a|x) │
│ Outcomes │ │ Reward r̂(x, a) │
│ │ │ Learner π_θ (PyTorch MLP) │
│ │ │ Failure×3 p̂_late, p̂_dmg, p̂_ov │
│ │ │ SHAP attribution │
│ │ write │ │
│ Edge predicts │◀────────│ Writeback │
└────────┬────────┘ └──────────────────────────────────┘
│
│ run query
▼
┌─────────────────────────────────────────────────────────────┐
│ GSQL business layer │
│ │
│ recommend_sc_for_workorders(δ, λ, ω, n) │
│ expected_total_cost = direct_cost │
│ + δ · p_late · qty │
│ + λ · p_damage · qty │
│ + ω · p_overflow · qty │
│ argmin per WO → write to vertex; emit sorted table │
└─────────────────────────────────────────────────────────────┘
The system implements an offline contextual bandit. Three core estimators feed a Doubly-Robust (DR) policy value estimator (Dudík, Langford & Li, 2011):
Propensity model μ̂(a | x). A multinomial logistic regression on the WO-context features only — excluding action-side features to avoid feature/target leakage. Outputs the probability that the historical (logging) policy would have chosen action a given context x.
Reward model r̂(x, a). An XGBoost regressor estimating expected reward given context and action. Used as both the "direct method" predictor and as a baseline for the IPS correction.
Learner π_θ. An MLP scoring (context, action) feature vectors. Trained to maximize the soft (probabilistic) version of V̂_DR:
V̂_soft(π_θ) = (1/N) Σ_i [ Σ_j π_θ(c_j|x_i) · r̂(x_i, c_j)
+ π_θ(a_i|x_i) · (r_i − r̂(x_i, a_i))
/ μ̂(a_i|x_i) ]
The first term is the direct method (use r̂); the second is the IPS correction (reweight observed rewards by 1/μ̂). The estimator is doubly robust: it's unbiased if either r̂ or μ̂ is correctly specified.
Failure models p̂_k(x, a). Three binary XGBoost classifiers, one per failure mode (late, damage, overflow). These are what surface as risk probabilities in the recommendation table.
Reward construction. Realized rewards are negative cost:
r_i = −( delay_penalty · was_late · qty
+ damage_penalty · had_damage · qty
+ overflow_penalty · had_overflow · qty
+ realized_direct_cost )
Scaled by 1/1000 before training so values land in a numerically friendly range and downstream models don't overfit to noise on the high-magnitude tail.
Requires Python 3.14+ and a TigerGraph instance with the required queries installed (see "TigerGraph queries" below).
git clone <repo>
cd contextual-bandit-dispatch
python3.14 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -e ".[dev]"Copy .env.example to .env and fill in:
HOST=https://your-instance.i.tgcloud.io
GRAPHNAME=YOUR_GRAPH_NAME
SECRET=<your secret>
These are read via pydantic-settings at startup. Missing values raise
ValidationError with a clear message naming the missing field.
python -m contextual_bandit_dispatch.cliThis:
- Pulls logged candidate decisions from TigerGraph.
- Trains every model in the bandit (Propensity, Reward, Learner, three Failure classifiers).
- Compares the learner against three heuristic baselines via V̂_DR.
- Scores every open candidate edge and computes failure probabilities plus SHAP reasoning for each.
- Writes all predictions back to TigerGraph edges.
Expected output: stage-level progress logs, a policy comparison table, and a confirmation count of edges pushed.
After the pipeline finishes, per-candidate predictions live on
WORK_ORDER_FULFILLMENT_CANDIDATE edges. To turn those into
business-readable recommendations:
RUN QUERY recommend_sc_for_workorders(50.0, 200.0, 100.0, 1000)
The first three parameters are penalty weights for delay, damage, and overflow respectively. The fourth caps the result table size. The query:
- Computes
expected_total_costper candidate edge. - Picks the argmin per WO.
- Writes
recommended_sc_id,expected_total_cost, and a composedrecommendation_explanationto each open WO vertex. - Flags the winning candidate edge with
is_recommended = 1. - Returns a sorted table (one row per open WO) plus diagnostic counts.
Re-running with different penalty weights produces different recommendations without retraining anything in Python. This is the operator-facing knob.
The project assumes four installed queries on the graph:
| Query | Purpose |
|---|---|
extract_logged_decisions_with_realized_rewards(partition_id, total_partitions) |
Returns historical (context, action, reward) rows as typed tuples. Partition-aware for parallel pulls. |
extract_open_wo_candidate_contexts(partition_id, total_partitions) |
Returns (open WO, candidate SC) rows for scoring. Same partition contract. |
writeback_candidate_predictions(...) |
Receives per-candidate predictions from Python and writes them to edge attributes. |
recommend_sc_for_workorders(δ, λ, ω, limit_n) |
Cost-min recommendation engine. Reads edge predictions, applies penalty weights, writes recommendations to WO vertices, emits a sorted table. |
GSQL source files live alongside the repo and are installed via the
TG admin shell (gsql -g <graph> < query.gsql).
# Type checking (strict)
basedpyright src/contextual_bandit_dispatch
# Lint + import sorting
ruff check src/
# Tests
pytest # unit tests
pytest -m integration # tests requiring a live TG connectionThe project enforces strict basedpyright; uses cast() at library
boundaries rather than # pyright: ignore suppressions.
A few design decisions worth knowing if you're extending the code:
Propensity uses context-only features. The Propensity model
deliberately excludes sc_id and every action-side feature
(sc_capacity, distance_km, sc_lifetime_*, etc.) — otherwise the
model trivially identifies which action was chosen, producing
degenerate near-{0, 1} propensities and unstable IPS corrections. The
split is enforced by separate CONTEXT_CATEGORICAL_FEATURES and
CONTEXT_NUMERIC_FEATURES tuples in tigergraph/headers.py.
Reward scaling. Raw rewards are in the thousands (penalties × pallet quantities). Dividing by 1000 before any model sees them keeps XGBoost from overfitting wildly and the PyTorch DR loss from producing absurd magnitudes. Scaling is uniform across all downstream models so policy ordering is preserved.
Propensity floor in evaluation. The DR estimator floors propensities
at 0.01 to bound IPS variance. Without it, a single row with
μ̂ ≈ 1e-4 could swing V̂_DR by orders of magnitude. The floor is in
evaluation/value_estimator.py; lower it (or remove it) on
production-grade data where extreme propensities carry real signal.
Single-threaded torch. The Learner forces torch.set_num_threads(1)
because PyTorch's default OpenMP thread spawn is pathologically slow on
macOS, and there's no parallelism benefit at this data size anyway.
Predict for all candidates, not just picks. workflow.recommend()
scores every open candidate, not just the argmax per WO. This is what
makes the cost-min query in GSQL meaningful — it has real predictions
on every option, so penalty-weight changes produce real argmin changes
across the candidate set.
Argmin discovery is two passes. The TG recommend_sc_for_workorders
query splits argmin discovery (Step 1: MinAccum tracks running minimum)
from detail capture (Step 2: filter by expected_total_cost == best_cost).
This avoids a known GSQL race condition where conditional captures
inside an ACCUM block can produce nondeterministic results.
MIT.