LightGBM (production)
The model the live survey actually uses: the leaderboard's LightGBM + bucket scores at its full configuration (800 trees for gender, 1,000 for age and mood; num_leaves=63; lr=0.03). Training is a two-pass procedure - first an honest evaluation pass on a stratified 6,000 / 710 random split, then a deployment pass that refits the three boosters on all 6,710 valid sessions before emitting them. The Cloudflare worker only handles feature extraction and database persistence; the LightGBM trees themselves ship as JSON and the browser walks them with a 30-line tree walker.
Scores
Two-pass training
Pass one is the evaluation pass. The 6,710 valid
sessions are split with sklearn's train_test_split,
stratified on gender with seed 42, into 6,000 train / 710 validation
rows. Per-pool bucket grids are built from the 6,000-row training fold
only, so there is no leakage on the validation fold. All three heads
are fit and scored on the 710-row hold-out; those numbers are what
populate the leaderboard row.
Pass two is the deployment pass. The bucket grids are
rebuilt from every row, and the three heads are refit on all 6,710
sessions. Only these final boosters get emitted to
models-js/ as JSON. The deployed model sees 710 more rows
than the eval model did, so its true generalisation should be no
worse than what pass one reports - the leaderboard number is a lower
bound on what is actually shipping.
An earlier version of this script used a temporal hold-out (the last 710 sessions in submission order) instead of a random stratified split. That cut gender AUC up to 0.900 on the hold-out, but it also blew age and mood MAE up to 7.3 and 10.4 because the newest cohort drifts on those targets - age R² on that hold-out went negative. The random split picked here gives a representative validation distribution and lets all three metrics be evaluated on the same protocol.
Configuration
- Feature vector477 floats for gender (441 base + 33 perceptual extras + 3 bucket totals), 475 for age and mood (the same with one bucket-encoded covariance score instead of three)
- Bucket grids8×8×8 RGB grid; girly/masc covariance for gender, target-centred covariance for age and mood. Eval-pass grids come from 6,000 training rows; deployed grids come from all 6,710
- Trees (gender)800, depth unrestricted, 63 leaves max, lr=0.03, L2 lambda 1.0
- Trees (age, mood)1,000, same config, default LightGBM L2 regression objective
- Evaluation splitstratified random 6,000 / 710 on gender, seed 42
- Conversioncustom flat-tree emitter walks
booster.dump_model()iteratively and emits compact JSON (~3 MB per booster). A 30-line JavaScript tree walker reads the JSON and runs inference client-side; bit-exact agreement with LightGBM raw scores (max|delta|= 0 on the verification batch) - Deploymentthree JSON tree files at
/ai/color-polygraph/models-js/{gender,age,mood}_trees.json(gender 2.8 MB, age 3.5 MB, mood 3.5 MB). Fetched lazily by the survey page and cached for the session - Warm inference~3-6 ms per target in V8 on a recent laptop, comfortably under the 10 ms budget