TRY IT OUT HERE: https://aish-ml-pricer-frontend.up.railway.app
A prototype project that trains LightGBM models to approximate Monte Carlo (MC) pricing of different derivative payoffs. The repo contains data generation, model training (with Optuna tuning), evaluation against MC at multiple path counts, and a Streamlit frontend + FastAPI backend for interactive pricing and diagnostics.
- Overview — what this repo does and what is being priced
- Architecture — code layout
- Payoffs explained — what each payoff represents and what the model predicts
- Data generation — how simulated paths and targets are generated
- Model training — LightGBM, Optuna, scalers, metrics, feature importance
- Evaluation — MC benchmarking, evaluation outputs and summary calculations
- Files to know and where results are saved (
results.json, models) - Frontend & backend — endpoints, expected requests/responses, Streamlit UI notes
- How to run locally (dev) — environment, commands, and verifying outputs
- How to increase training samples / n_paths_per_sample
- Deployment options
- Troubleshooting & common errors
- Performance notes
- Extras
This project produces ML models (LightGBM - final model) that estimate the present value of structured-payoff instruments (hopefully) faster than Monte Carlo. It uses Monte Carlo to produce target prices for many sampled parameter combinations, trains a model to learn the mapping (features → price), and exposes a small API + frontend to compare model price vs MC price at arbitrary input parameter sets.
Use cases:
- Rapid pricing where MC is too slow for interactive workflows.
- Investigating model error across parameter ranges.
- Visualizing feature importance and timing speedups.
The repo prices the present value (discounted payoff) of structured product payoffs computed from simulated underlying asset price paths. For each instrument the code computes the discounted payoff per Monte Carlo path then averages across paths to return a Monte Carlo estimate; the LightGBM model learns to approximate that averaged PV from the instrument parameters.
Top-level structure (representative):
neural-pricer/ ├─ app/ │ └─ frontend.py # Streamlit app (UI) | └─ backend.py # FastAPI backend exposing /price/ and /training/ ├─ src/ │ └─ final/ │ ├─ payoffs.py # BasePayoff and payoff implementations │ ├─ inherited_payoffs.py │ ├─ data_generator.py │ ├─ model_trainer.py │ ├─ evaluator.py │ ├─ pipeline.py │ └─ run.py # scripts to run pipelines for each payoff ├─ final/ │ └─ results/ │ └─ <payoff_name>/ # model.joblib, scaler.joblib, results.json ├─ requirements.txt └─ README.md Summarising all the modules:
payoffs.py— payoff classes implementingcompute_payoff(paths, params, r, T)andget_feature_order(); also containsparam_rangesused for sampling.inherited_payoffs.py— extended payoff classesdata_generator.py— simulates log-GBM paths (simulate_gbm_paths) and generates training data from sampled parameter tuples.model_trainer.py— trains LightGBM regressors with Optuna hyperparameter search; stores model, scaler, metrics, feature importance.evaluator.py— calculates MC baseline for givenn_pathsand compares to model predictions; returns structured result dict.pipeline.py— orchestrates generation, training, evaluation and saving outputs (model, scaler,results.json).run.py— script to run pipelines for different payoffs.app/frontend.py— Streamlit UI to input parameters and visualize model vs MC.app/backend.py— FastAPI endpoint/price/used by the frontend to run a model vs MC;/training/{payoff_type}returns saved trainingresults.json.
All payoffs compute a discounted present value of the payoff per Monte Carlo path and then the mean across paths is taken. The ML model is trained to approximate this mean (the price). Here’s what each payoff does:
- Observations at fixed indices (
obs_count). - If spot at an observation ≥
autocall_barrier_frac * S0then instrument autocalls: investor gets1 + coupon_ratepaid at the call time (discounted to t=0). - If not autocalled and the path ever breaches
knock_in_frac * S0, then at maturity payoff isS_T / S_0(lossful redemption). - If not autocalled and no knock-in, payoff equals
1 + coupon_rateat maturity. - Price = discounted expected payoff.
- Autocall barrier reduces by a
stepdown_rateat each observation; coupon can scale by the observation index. Useful for step-down coupons.
- At each observation (based on
obs_frequency), if spot is inside (lowerbarrier_frac * S0, upperbarrier_frac * S0) it accumulates (buys) at discounted priceS_t / (1 + participation_rate). - Final payoff equals the discounted average of accumulated contributions scaled by observation fraction.
- Intuitively: an investor accumulates at a discount while price trades inside the corridor.
- Opposite accumulation logic: accumulates when price is outside the corridor.
- If barrier breached at any time, payoff = 0.
- Otherwise payoff at maturity = max(S_T - K, 0) for a call, or max(K - S_T, 0) for a put; discounted to present.
- Sells shares when price is outside barriers and sums discounted proceeds. (Analogous to an inverted accumulator with
participation_ratemultiplied).
Important: In all cases, the code returns PVs normalized as the outcome (so some payoffs show values around 1.0 for normalized redemption payoffs, while accumulators / decumulators may produce larger absolute numbers — see results.json examples). The model learns whatever is returned by the compute_payoff averaging.
-
simulate_gbm_paths(s0, r, sigma, T, n_steps, n_paths, seed)simulates log-GBM paths withn_steps(time steps) andn_pathsindependent paths. Returns array of shape(n_paths, n_steps + 1). -
DataGenerator.sample_parameters(n_samples, payoff, seed)samples parameter sets uniformly frompayoff.param_ranges.obs_countis sampled as integer; others are floats. -
DataGenerator.generate(n_samples, n_paths_per_sample):- For each sampled parameter tuple, simulate
n_paths_per_sampleGBM paths and computepayoff.compute_payoff(paths, params, r, T). - The training target is
price = mean(payoffs)(a float). - The features
Xare arranged followingpayoff.get_feature_order().
- For each sampled parameter tuple, simulate
Notes:
- For higher quality training labels, increase
n_paths_per_sample. This is the main lever to reduce label noise at the cost of CPU/time. - Seed is optional; if provided, per-sample path seeds are
seed + i.
-
Uses LightGBM (
LGBMRegressor) with Optuna for hyperparameter tuning. -
Pipeline:
- Train/validation split using
random_state. - Optionally transform targets with
log1p(controlled byuse_log_target). StandardScalerapplied to features.- Optuna searches these params:
n_estimators,learning_rate,num_leaves,min_child_samples,subsample,colsample_bytree. - After search, fit the final model on combined training+validation (
X_full_s). - Evaluate final model on a held-out test set for metrics:
rmse,mae,r2. - Feature importance extracted (attempts to use booster gain if available, otherwise
feature_importances_).
- Train/validation split using
Return object from ModelTrainer.train():
{ "model": <LGBMRegressor instance>, "scaler": <StandardScaler instance>, "metrics": {"rmse": float, "mae": float, "r2": float}, "optuna_study": {"best_value": float, "best_params": {...}}, "feature_importance": [{"feature": name, "importance": float}, ...], "use_log_target": bool }Saved files (by PricingPipeline when output_dir specified):
model.joblib— trained LightGBM modelscaler.joblib— StandardScalerresults.json— a comprehensive JSON containingconfig,training(metrics, optuna, feature_importance),evaluation(test cases & per-npaths results), andtiming.
Evaluator.evaluate_case(params, model, scaler, n_paths_list, use_log_target, seed) returns a dict:
{ "params": { ... }, # the tested parameter tuple "per_npaths": { "<n_paths>": { "MC": {"price": float, "std": float, "time": float, "n_paths": int}, "Model": {"price": float, "time": float, "abs_error": float, "rel_error": float or None, "speedup": float} }, ... } }Evaluator.evaluate_multiple_cases() returns a list of such dicts.
Evaluator.summarize_results(results_list) aggregates errors, times, and speedups across test cases and returns:
{ "n_test_cases": int, "errors_by_npaths": { n_paths: {"abs_mean": float, "abs_std": float, "rel_mean": float|None, "rel_std": float|None}, ... }, "times_by_npaths": { n_paths: {"mc_mean": float, "model_mean": float}, ... }, "speedups_by_npaths": { n_paths: {"mean": float, "std": float}, ... } }Key points
rel_errorisabs_error / abs(mc_price)ifmc_price != 0elseNone.speedup = mc_time / model_time(ifmodel_time > 0).- Results are saved in
results.jsonbyPricingPipeline.
For each payoff you run via pipeline with output_dir=Path("final/results/<payoff>"), you will get:
final/results/<payoff>/model.joblib— trained model (joblib)final/results/<payoff>/scaler.joblib— scalerfinal/results/<payoff>/results.json— full training + evaluation summary (human- and machine-readable)
Use backend endpoint /training/{payoff_type} to fetch the results.json training part for the frontend feature importance.
POST /price/ — price an instrument using saved model + MC baseline Request body (Pydantic PricingRequest):
{ "payoff_type": "phoenix", "params": { ... }, // parameter dict for selected payoff "n_paths": 2000, // optional "use_log_target": true // optional }Successful response structure (wrapped by the backend):
{ "status": "success", "result": { ... } // exactly the dict returned by Evaluator.evaluate_case(...) }If there is an error, the API returns:
{ "status": "error", "message": "error message", "trace": "traceback (for debugging; remove for production)" }GET /training/{payoff_type} — returns training info read from results.json (used by frontend to show feature importance if not present in /price/ response).
-
UI allows selecting payoff type, entering numeric parameters (keyed numeric inputs — not sliders), and choosing MC
n_paths. -
On "Run Pricing", frontend calls
/price/and displays:- Dashboard tab: model price vs MC price bar, error metrics, timing (bar charts).
- Feature Analysis tab: feature importance (fetched either from returned
trainingor by calling/training/{payoff}). - Raw JSON tab: entire response for debugging.
Notes:
- The frontend code includes resilient parsing for many backend response shapes.
- The Streamlit UI expects
resultto containper_npaths(or an equivalent numeric-keys dict). - Plotly configuration deprecation warning: pass configuration through
configdict (the app usesst.plotly_chart(fig, config={"responsive": True})). If Plotly warns about specific keyword arguments, adjust to the newconfigstructure as the warning suggests.
python -m venv venv # Windows venv\Scripts\activate # macOS/Linux source venv/bin/activateCreate requirements.txt (representative):
fastapi uvicorn streamlit plotly pandas numpy scikit-learn lightgbm joblib requests optuna Install:
pip install -r requirements.txtFrom project root:
# ensure PYTHONPATH is set so `src` package is importable # Windows Powershell: $env:PYTHONPATH="." python -m uvicorn src.final.backend:app --reload --host 127.0.0.1 --port 8000Or:
uvicorn src.final.backend:app --reload --host 0.0.0.0 --port 8000Check http://127.0.0.1:8000 for health. Open docs at http://127.0.0.1:8000/docs.
In another terminal (from project root):
streamlit run app/frontend.pyThe Streamlit UI will open, default port 8501: http://localhost:8501.
Make sure the frontend API_URL variable points to your backend (http://localhost:8000 by default). In production, configure via environment variable or Streamlit secrets.
You control two separate sampling levels:
n_samples= number of parameter tuples the model trains on (X rows). Increase this to cover more of the parameter space.n_paths_per_sample= number of Monte Carlo paths used to compute the label (target) for each sampled parameter set. Increase this to reduce label noise.
Tradeoffs:
-
Increasing
n_paths_per_samplereduces label variance but costs CPU/time linearly. -
Increasing
n_samplesimproves model generalization but increases memory and training cost (O(n_samples)). -
For LightGBM, training scales well on CPU threads; Optuna tuning multiplies runtime by
n_trials. -
Recommended practice:
- Start with
n_samples= 1k–5k andn_paths_per_sample= 500–2000 for prototyping. - For production-quality models, increase
n_paths_per_sampleto 2k–8k andn_samplesto 5k–20k depending on model complexity.
- Start with
-
Use the
data_filecaching option inpipeline.run_full_pipeline()so you don’t regenerate samples every run.
Hardware estimate (very approximate; workload depends on payoff complexity):
- 1k samples × 2k paths × 252 steps → CPU only, minutes to tens of minutes.
- 5k samples × 4k paths → can be hours on a single CPU machine. Consider parallelization or a beefy multi-core instance.
If you need cleaner labels (low variance), increase n_paths_per_sample. If you need better coverage of parameter space, increase n_samples.
- Build two images (backend + frontend) and deploy them with Azure
- Provide persistent storage or embed pre-trained models in the image (not ideal if model artifacts are large — use cloud storage).
Important: Keep final/results/<payoff>/model.joblib and scaler.joblib present on the backend service. Either bake into the container or load from mounted storage.
Cause: PYTHONPATH not set or working directory wrong. Fix:
-
Run from repo root and ensure Python path includes
.:- Windows Powershell:
$env:PYTHONPATH="."; python -m uvicorn src.final.backend:app ... - Or
export PYTHONPATH='.'on macOS/Linux.
- Windows Powershell:
-
Alternatively, install the package (e.g.,
pip install -e .) with asetup.py/pyprojectthat includessrcas package.
Cause: frontend tries to read st.secrets[...] that doesn't exist. Fix:
- Add
.streamlit/secrets.tomlor removest.secrets.getfallback logic; ensureAPI_URLfallback exists in code.
Cause: backend response shape differs (maybe backend returns wrapped {"status": "success", "result": {...}} and frontend expects the nested result object). Fix:
- Frontend should extract
result = res.json()and then findper_npathsusingfind_per_npaths(result)— ensure backend returns{"status":"success","result": <eval_result>}consistently OR change backend to return theevaluate_casedict at top-level. Current frontend expects the nested structure; confirm. - If your actual backend returns
{"status": "success", "result": {...}}, ensure frontend setsresult = result.get("result", result)before scanning for per_npaths. (In our updated frontend we have resilient parsing; but verify.)
Cause: The model was trained with feature names and the input to model.predict() is a numpy array without column names. This is a warning; predictions still work. Fix:
-
Either convert feature row to a DataFrame with
columns=self.feature_namesbeforescaler.transform()or ignore the warning (harmless). -
Example:
feat = pd.DataFrame([params_list], columns=feature_order) feat_s = scaler.transform(feat.values)
Cause: Evaluator.evaluate_case() returns MC/model comparison but not training info; results.json holds feature importance. Frontend attempts to fetch /training/{payoff} if missing. Fix:
- Ensure
final/results/<payoff>/results.jsonexists and/training/{payoff}returns it. - Alternatively, add
feature_importanceinto the/price/response.
Message: "The keyword arguments have been deprecated — use config instead." Fix: In st.plotly_chart(fig, config={...}) pass configuration via config. Avoid legacy keyword args; the current code already uses config={"responsive": True}.
- LightGBM trains on CPU. Use
n_jobs=-1if you want to use all cores; currently the code usesn_jobs=1during Optuna trials to avoid oversubscription. After tuning, setn_jobsappropriately for final model training. - Optuna search multiplies training time by
n_trials. Consider enablingn_trialssmaller for prototyping (e.g., 10) and increasing later. - For huge budgets, consider distributed training or generating labels in parallel across multiple worker machines and storing them to a shared
npzfor training. - Model inference is extremely fast (milliseconds). MC time scales with
n_paths * n_steps.
curl -X POST "http://localhost:8000/price/" \ -H "Content-Type: application/json" \ -d '{ "payoff_type": "phoenix", "params": { "S0":100.0, "r":0.03, "sigma":0.2, "T":1.0, "autocall_barrier_frac":1.05, "coupon_barrier_frac":1.0, "coupon_rate":0.02, "knock_in_frac":0.7, "obs_count":6 }, "n_paths":2000, "use_log_target":true }'{ "status": "success", "result": { "params": { /* same params */ }, "per_npaths": { "2000": { "MC": { "price": 0.98, "std": 0.08, "time": 0.03, "n_paths": 2000 }, "Model": { "price": 0.98, "time": 0.001, "abs_error": 0.001, "rel_error": 0.001, "speedup": 30 } } } } }