@knockdata/lightgbmjs
v0.2.1
Published
LightGBM rewrite in C, fully wasm in Node and Browser
Maintainers
Readme
lightgbmjs
LightGBM for JavaScript. Trains and predicts gradient-boosted decision trees using the same
histogram-based, leaf-wise algorithm as LightGBM, and
reads and writes model files that are interchangeable with LightGBM's own text (.txt) and JSON
(dump_model) formats.
What is published is the WebAssembly engine: the C core built as freestanding wasm32, which runs the same everywhere — a browser, Node, a worker, a bundle. One binary, 21 KB, no per-platform install and nothing to compile.
Behind it is the pure-JS trainer, and it is not a lesser thing: whatever the C core does not
implement falls through to it rather than failing. So the objectives, categorical features and
missing values wasm does not cover are covered anyway, by the engine already in the package. The
same fallback covers wasm not loading at all — a strict Content-Security-Policy without
wasm-unsafe-eval, or a runtime with no WebAssembly — so init() never throws; it just leaves
the pure-JS engine as the one train() uses.
| | What it is | Objectives | Categorical / missing | | --- | --- | --- | --- | | wasm | the C core as freestanding wasm32 | regression, multiclass | falls back to js | | js | pure JavaScript, zero dependencies | regression, binary, multiclass, GOSS | yes |
Both hand back a model in the same shape, so prediction, model.txt and dump_model JSON are the
same code either way.
Install
npm install @knockdata/lightgbmjsThe WebAssembly module ships inside the package. There is no build step, no compiler, and no per-platform binary to install.
Usage
X is one row per sample, y is one label per row. Everything else — the objective, the tree size,
how many boosting rounds — goes in the first argument, under LightGBM's own parameter names.
init() is awaited once before the first train(): the wasm module grows its own memory to fit the
data, and train() is synchronous and so cannot await anything itself.
import lgbm from '@knockdata/lightgbmjs'
await lgbm.init() // once, before train()
const booster = lgbm.train(
{ objective: 'regression', num_leaves: 31, learning_rate: 0.1, num_iterations: 100 },
{ data: X, label: y, featureNames: ['a', 'b', 'c'] },
)
const preds = booster.predict(X)
const text = booster.toText() // LightGBM-compatible .txt model
const json = booster.toJSON() // LightGBM-compatible dump_model() JSON
const loaded = lgbm.loadModel(text) // or lgbm.loadModel(json)
console.log(booster.engine) // which engine trained it: 'wasm' or 'js'Regression — a number per row
objective: 'regression' fits L2 loss. y is the value to predict, and predict returns one number
per row. Here: a house price in thousands, from its size, bedroom count and age.
import lgbm from '@knockdata/lightgbmjs'
const X = [
[ 750, 1, 35], [ 900, 2, 28], [1100, 2, 15], [1250, 3, 22],
[1400, 3, 8], [1600, 3, 12], [1750, 4, 5], [1900, 4, 18],
[2100, 4, 3], [2300, 5, 10], [2500, 5, 2], [2750, 5, 7],
]
const y = [168, 205, 262, 291, 340, 372, 415, 398, 470, 495, 545, 578]
const booster = lgbm.train(
{ objective: 'regression', num_leaves: 4, num_iterations: 30, learning_rate: 0.1, min_data_in_leaf: 1 },
{ data: X, label: y, featureNames: ['sqft', 'bedrooms', 'age'] },
)
booster.predict([[1000, 2, 20], [2400, 5, 4]])
// Float64Array [ 268.5, 536.9 ]
booster.featureImportance() // { sqft: 71, age: 19 } — splits that chose each feature
booster.featureImportance('gain') // { sqft: 484461, age: 9369 }min_data_in_leaf: 1 is only because this example has twelve rows; leave it at its default of 20 on
real data.
Binary classification — one probability per row
objective: 'binary' fits logloss. y is 0 or 1, and predict returns P(y = 1). Here: whether a
loan defaults, from income, debt ratio and late payments.
const X = [
[42, 0.18, 0], [55, 0.22, 0], [38, 0.31, 0], [71, 0.15, 0], [63, 0.27, 0], [49, 0.20, 0],
[88, 0.12, 0], [52, 0.35, 1], [31, 0.55, 1], [27, 0.61, 2], [35, 0.48, 1], [44, 0.52, 2],
[29, 0.66, 3], [33, 0.58, 2], [60, 0.24, 0], [25, 0.72, 4], [47, 0.41, 1], [39, 0.29, 0],
]
const y = [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 0, 0]
const booster = lgbm.train(
{ objective: 'binary', num_leaves: 4, num_iterations: 30, learning_rate: 0.1, min_data_in_leaf: 1 },
{ data: X, label: y, featureNames: ['income_k', 'debt_ratio', 'late_payments'] },
)
booster.predict([[68, 0.19, 0], [28, 0.63, 3]])
// Float64Array [ 0.019, 0.971 ] — P(default), sigmoid applied
booster.predictRaw([[68, 0.19, 0], [28, 0.63, 3]])
// Float64Array [ -3.950, 3.511 ] — the log-odds before the sigmoidbinary runs on the pure-JS trainer, which needs no init().
Multiclass classification — one probability per class
objective: 'multiclass' fits softmax over num_class classes. y is a class index 0 .. K-1, and
predict returns an ARRAY per row — one probability per class, summing to 1. Here: iris species from
the four flower measurements.
const X = [
[5.1, 3.5, 1.4, 0.2], [4.9, 3.0, 1.4, 0.2], [4.7, 3.2, 1.3, 0.2], [5.0, 3.6, 1.4, 0.2], [5.4, 3.9, 1.7, 0.4],
[7.0, 3.2, 4.7, 1.4], [6.4, 3.2, 4.5, 1.5], [6.9, 3.1, 4.9, 1.5], [5.5, 2.3, 4.0, 1.3], [6.5, 2.8, 4.6, 1.5],
[6.3, 3.3, 6.0, 2.5], [5.8, 2.7, 5.1, 1.9], [7.1, 3.0, 5.9, 2.1], [6.3, 2.9, 5.6, 1.8], [6.5, 3.0, 5.8, 2.2],
]
const y = [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2] // 0 setosa, 1 versicolor, 2 virginica
const booster = lgbm.train(
{ objective: 'multiclass', num_class: 3, num_leaves: 4, num_iterations: 25, learning_rate: 0.2, min_data_in_leaf: 1 },
{ data: X, label: y, featureNames: ['sepal_len', 'sepal_wid', 'petal_len', 'petal_wid'] },
)
const probs = booster.predict([[5.0, 3.4, 1.5, 0.2], [6.1, 2.9, 4.7, 1.4], [6.7, 3.1, 5.6, 2.4]])
// [ [0.9991, 0.0005, 0.0005],
// [0.0014, 0.9973, 0.0014],
// [0.0005, 0.0005, 0.9991] ]
const predicted = probs.map((row) => row.indexOf(Math.max(...row))) // [0, 1, 2]
booster.numTrees // 75 — num_class trees per iteration, 25 iterations
booster.numClass // 3num_class may be left out, in which case it is inferred from the labels as max(y) + 1.
To choose the engine explicitly:
import lgbm from '@knockdata/lightgbmjs' // the WebAssembly engine — the default
import lgbm from '@knockdata/lightgbmjs/wasm' // the same thing, named
import lgbm from '@knockdata/lightgbmjs/js' // the pure-JS engine on its own, no init()In the browser
In Node, init() reads the module itself. A browser has no filesystem to read it from, so hand it
the bytes — the wasm is a package export, so a bundler resolves it like any other asset:
import lgbm from '@knockdata/lightgbmjs'
import wasmUrl from '@knockdata/lightgbmjs/lightgbmc.wasm?url' // vite, esbuild, webpack
await lgbm.init(await fetch(wasmUrl).then((response) => response.arrayBuffer()))
const booster = lgbm.train(params, { data: X, label: y })Without a bundler, resolve it against the module URL:
const bytes = await fetch(new URL('@knockdata/lightgbmjs/lightgbmc.wasm', import.meta.url)).then((r) => r.arrayBuffer())
await lgbm.init(bytes)lgbm.isAvailable() says whether the module is initialised. Training an objective the C core does
not implement needs no init() at all — it runs on the pure-JS trainer either way. If init()
could not load wasm — CSP, an old browser, a bad fetch — it resolves anyway, warns once on the
console, and leaves isAvailable() false; train() then runs on the pure-JS engine without the
caller having to check for it.
Without a bundler
The package also ships a minified, dependency-free bundle of the pure-JS engine at
dist/lightgbmjs.min.js — for a plain <script> tag, or a CDN, with no build step at all:
<script src="https://unpkg.com/@knockdata/lightgbmjs/dist/lightgbmjs.min.js"></script>
<script>
const booster = lgbm.train(
{ objective: 'regression', num_leaves: 4, num_iterations: 30, learning_rate: 0.1, min_data_in_leaf: 1 },
{ data: X, label: y },
)
</script>This is the JS engine only — no init(), no wasm fetch. dist/lightgbmjs.esm.js and
dist/lightgbmjs.cjs are the same bundle as an ES module and a CommonJS module, for a bundler that
would rather not resolve src/'s internal imports itself.
Input shapes
data is either an array of row arrays, or one flat Float64Array laid out row-major
(data[row * numFeatures + feature]) with featureNames given so the width is known. The flat form
avoids a copy on the WASM path, and avoids a pointer chase per row on the JS path.
API
train(params, dataset) — dataset is
{ data, label, weight?, featureNames?, categoricalFeatures? }.
Parameters follow LightGBM's names: objective (regression | binary | multiclass),
num_class, num_leaves, num_iterations, learning_rate, min_data_in_leaf,
min_sum_hessian_in_leaf, max_bin, lambda_l1, lambda_l2, min_gain_to_split, max_depth,
boosting (gbdt | goss), boost_from_average, seed.
loadModel(source) — a LightGBM .txt string, or a dump_model() JSON object.
Both return a booster:
| | |
| --- | --- |
| predict(rows, options?) | probabilities/values; { raw_score: true } for pre-transform scores. Multiclass returns one array of num_class probabilities per row, everything else one number per row |
| predictRaw(rows) | the same as raw_score |
| featureImportance(type?) | { featureName: number } for the features that were used. 'split' (the default) counts the splits that chose each feature, 'gain' totals the gain those splits claimed — the same two types as LightGBM's Booster.feature_importance |
| toText() / toJSON() | LightGBM-compatible model files |
| numTrees, numClass, featureNames, params | |
| engine | which engine trained this model: 'wasm' or 'js' |
Scope
- Objectives:
regression(L2),binary(logloss),multiclass(softmax). The C core implementsregressionandmulticlass;binaryruns on the pure-JS trainer whichever engine is asked for - Model files: LightGBM text and JSON. LightGBM has no binary format for a trained model, so none is invented here
- Single threaded
- Not in scope: ranking objectives, DART, linear-tree leaves, Exclusive Feature Bundling
Benchmark
Two engines this package ships — wasm and pure JS — on the same data through the same timed loop, against Microsoft's LightGBM as the reference. All single threaded. Regression (L2), matched hyperparameters, each number the best of 4 seconds of repeated runs.
Training
| Rows | Features | Leaves/iters | WASM (ms) | JS (ms) | LightGBM (ms) | WASM vs LightGBM | JS vs LightGBM | | --- | --- | --- | --- | --- | --- | --- | --- | | 2,000 | 10 | 15/30 | 6.3 | 18.5 | 5.5 | 1.14x slower | 3.35x slower | | 2,000 | 10 | 63/100 | 54.9 | 149.0 | 44.9 | 1.22x slower | 3.32x slower | | 20,000 | 10 | 15/30 | 26.8 | 85.8 | 14.9 | 1.80x slower | 5.74x slower | | 20,000 | 10 | 63/100 | 130.7 | 334.5 | 105.8 | 1.24x slower | 3.16x slower | | 300,000 | 10 | 63/100 | 1,345.2 | 3,252.3 | 709.3 | 1.90x slower | 4.59x slower |
Prediction
| Rows | Features | Leaves/iters | WASM (ms) | JS (ms) | LightGBM (ms) | WASM vs LightGBM | JS vs LightGBM | | --- | --- | --- | --- | --- | --- | --- | --- | | 2,000 | 10 | 15/30 | 0.5 | 1.2 | 1.3 | 2.34x faster | 1.05x faster | | 2,000 | 10 | 63/100 | 9.1 | 8.2 | 16.4 | 1.80x faster | 2.01x faster | | 20,000 | 10 | 15/30 | 11.4 | 15.3 | 15.1 | 1.32x faster | 1.01x slower | | 20,000 | 10 | 63/100 | 81.0 | 67.6 | 136.7 | 1.69x faster | 2.02x faster | | 300,000 | 10 | 63/100 | 1,324.3 | 1,159.8 | 2,248.7 | 1.70x faster | 1.94x faster |
Training is the one side where these engines trail LightGBM, by 1.2-1.9x for wasm at the sizes above — mostly the cost of the wasm32 runtime, since it compiles the same unmodified C source LightGBM's own C++ core plays against. Prediction is the one where both beat LightGBM, by 1.3-2.3x.
How the benchmark is recorded and reproduced is in BENCHMARK.md.
License
MIT
