@wlearn/liblinear 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/LICENSE +29 -0
- package/README.md +176 -0
- package/package.json +41 -0
- package/src/index.js +17 -0
- package/src/model.js +475 -0
- package/src/wasm.js +27 -0
- package/wasm/BUILD_INFO +6 -0
- package/wasm/linear.cjs +0 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
- Initial release
|
|
6
|
+
- LIBLINEAR v2.50 compiled to WASM via Emscripten
|
|
7
|
+
- Unified sklearn-style API: `create()`, `fit()`, `predict()`, `score()`, `save()`, `dispose()`
|
|
8
|
+
- Linear classifiers (logistic regression, SVC) and regressors (SVR)
|
|
9
|
+
- Buffer-based model I/O (no filesystem dependency)
|
|
10
|
+
- Accepts both typed matrices and number[][] with configurable coercion
|
|
11
|
+
- `predictProba()` for logistic regression solvers
|
|
12
|
+
- `decisionFunction()` for decision values
|
|
13
|
+
- `getParams()`/`setParams()` for AutoML integration
|
|
14
|
+
- `defaultSearchSpace()` for hyperparameter search
|
|
15
|
+
- `FinalizationRegistry` safety net for leak detection
|
|
16
|
+
- BSD-3-Clause license (same as upstream LIBLINEAR)
|
package/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Copyright (c) 2007-2025 The LIBLINEAR Project.
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without
|
|
5
|
+
modification, are permitted provided that the following conditions
|
|
6
|
+
are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright
|
|
9
|
+
notice, this list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright
|
|
12
|
+
notice, this list of conditions and the following disclaimer in the
|
|
13
|
+
documentation and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither name of copyright holders nor the names of its contributors
|
|
16
|
+
may be used to endorse or promote products derived from this software
|
|
17
|
+
without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
20
|
+
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
21
|
+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
22
|
+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR
|
|
23
|
+
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
24
|
+
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
25
|
+
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
|
26
|
+
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
27
|
+
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
28
|
+
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
29
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# @wlearn/liblinear
|
|
2
|
+
|
|
3
|
+
LIBLINEAR v2.50 compiled to WebAssembly. Linear classification and regression in browsers and Node.js.
|
|
4
|
+
|
|
5
|
+
Based on [LIBLINEAR v2.50](https://www.csie.ntu.edu.tw/~cjlin/liblinear/) (BSD-3-Clause). Zero dependencies. ESM.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @wlearn/liblinear
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { LinearModel } from '@wlearn/liblinear'
|
|
17
|
+
|
|
18
|
+
const model = await LinearModel.create({
|
|
19
|
+
solver: 'L2R_LR',
|
|
20
|
+
C: 1.0
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// Train -- accepts number[][] or { data: Float64Array, rows, cols }
|
|
24
|
+
model.fit(
|
|
25
|
+
[[1, 2], [3, 4], [5, 6], [7, 8]],
|
|
26
|
+
[0, 0, 1, 1]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// Predict
|
|
30
|
+
const preds = model.predict([[2, 3], [6, 7]]) // Float64Array
|
|
31
|
+
|
|
32
|
+
// Probabilities (logistic regression solvers only)
|
|
33
|
+
const probs = model.predictProba([[2, 3], [6, 7]]) // Float64Array (nrow * nclass)
|
|
34
|
+
|
|
35
|
+
// Score
|
|
36
|
+
const accuracy = model.score([[2, 3], [6, 7]], [0, 1])
|
|
37
|
+
|
|
38
|
+
// Save / load
|
|
39
|
+
const buf = model.save() // Uint8Array
|
|
40
|
+
const model2 = await LinearModel.load(buf)
|
|
41
|
+
|
|
42
|
+
// Clean up -- required, WASM memory is not garbage collected
|
|
43
|
+
model.dispose()
|
|
44
|
+
model2.dispose()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Typed matrix input (fast path)
|
|
48
|
+
|
|
49
|
+
For performance-critical code and AutoML integration, pass typed matrices directly:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
const X = {
|
|
53
|
+
data: new Float64Array([1, 2, 3, 4, 5, 6, 7, 8]),
|
|
54
|
+
rows: 4,
|
|
55
|
+
cols: 2
|
|
56
|
+
}
|
|
57
|
+
const y = new Float64Array([0, 0, 1, 1])
|
|
58
|
+
|
|
59
|
+
model.fit(X, y)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This avoids the conversion cost of nested arrays.
|
|
63
|
+
|
|
64
|
+
## Input coercion policy
|
|
65
|
+
|
|
66
|
+
Control how non-typed inputs are handled:
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
// Default: convert silently
|
|
70
|
+
const model = await LinearModel.create({ coerce: 'auto' })
|
|
71
|
+
|
|
72
|
+
// Warn once per instance when conversion happens
|
|
73
|
+
const model = await LinearModel.create({ coerce: 'warn' })
|
|
74
|
+
|
|
75
|
+
// Throw if input is not already a typed matrix
|
|
76
|
+
const model = await LinearModel.create({ coerce: 'error' })
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
### `LinearModel.create(params?)`
|
|
82
|
+
|
|
83
|
+
Async factory. Loads WASM module, returns a ready-to-use model.
|
|
84
|
+
|
|
85
|
+
Parameters:
|
|
86
|
+
- `solver` -- solver type string or number (default: `'L2R_LR'`)
|
|
87
|
+
- `C` -- regularization parameter (default: `1.0`)
|
|
88
|
+
- `eps` -- stopping tolerance (default: `0.01`)
|
|
89
|
+
- `bias` -- bias term, < 0 disables (default: `-1`)
|
|
90
|
+
- `p` -- epsilon in SVR loss (default: `0.1`)
|
|
91
|
+
- `coerce` -- input coercion: `'auto'` | `'warn'` | `'error'` (default: `'auto'`)
|
|
92
|
+
|
|
93
|
+
### `model.fit(X, y)`
|
|
94
|
+
|
|
95
|
+
Train on data. Returns `this` for chaining.
|
|
96
|
+
- `X` -- `number[][]` or `{ data: Float64Array, rows, cols }`
|
|
97
|
+
- `y` -- `number[]` or `Float64Array`
|
|
98
|
+
|
|
99
|
+
### `model.predict(X)`
|
|
100
|
+
|
|
101
|
+
Returns `Float64Array` of predicted labels.
|
|
102
|
+
|
|
103
|
+
### `model.predictProba(X)`
|
|
104
|
+
|
|
105
|
+
Returns `Float64Array` of shape `nrow * nclass` (row-major probabilities).
|
|
106
|
+
Only available for logistic regression solvers (L2R_LR, L1R_LR, L2R_LR_DUAL).
|
|
107
|
+
|
|
108
|
+
### `model.decisionFunction(X)`
|
|
109
|
+
|
|
110
|
+
Returns `Float64Array` of decision values.
|
|
111
|
+
|
|
112
|
+
### `model.score(X, y)`
|
|
113
|
+
|
|
114
|
+
Returns accuracy (classification) or R-squared (regression).
|
|
115
|
+
|
|
116
|
+
### `model.save()`
|
|
117
|
+
|
|
118
|
+
Returns `Uint8Array` (native LIBLINEAR model format).
|
|
119
|
+
|
|
120
|
+
### `LinearModel.load(buffer)`
|
|
121
|
+
|
|
122
|
+
Loads from `Uint8Array`. Returns `Promise<LinearModel>`.
|
|
123
|
+
|
|
124
|
+
### `model.dispose()`
|
|
125
|
+
|
|
126
|
+
Free WASM memory. Required. Idempotent.
|
|
127
|
+
|
|
128
|
+
### `model.getParams()` / `model.setParams(p)`
|
|
129
|
+
|
|
130
|
+
Get/set hyperparameters. Enables AutoML grid search and cloning.
|
|
131
|
+
|
|
132
|
+
### `LinearModel.defaultSearchSpace()`
|
|
133
|
+
|
|
134
|
+
Returns default hyperparameter search space for AutoML.
|
|
135
|
+
|
|
136
|
+
## Solver types
|
|
137
|
+
|
|
138
|
+
| Name | Code | Task |
|
|
139
|
+
|------|------|------|
|
|
140
|
+
| L2R_LR | 0 | L2-regularized logistic regression |
|
|
141
|
+
| L2R_L2LOSS_SVC_DUAL | 1 | L2-loss SVM (dual) |
|
|
142
|
+
| L2R_L2LOSS_SVC | 2 | L2-loss SVM (primal) |
|
|
143
|
+
| L2R_L1LOSS_SVC_DUAL | 3 | L1-loss SVM (dual) |
|
|
144
|
+
| MCSVM_CS | 4 | Multi-class SVM (Crammer-Singer) |
|
|
145
|
+
| L1R_L2LOSS_SVC | 5 | L1-regularized L2-loss SVM |
|
|
146
|
+
| L1R_LR | 6 | L1-regularized logistic regression |
|
|
147
|
+
| L2R_LR_DUAL | 7 | L2-regularized logistic regression (dual) |
|
|
148
|
+
| L2R_L2LOSS_SVR | 11 | L2-loss SVR (primal) |
|
|
149
|
+
| L2R_L2LOSS_SVR_DUAL | 12 | L2-loss SVR (dual) |
|
|
150
|
+
| L2R_L1LOSS_SVR_DUAL | 13 | L1-loss SVR (dual) |
|
|
151
|
+
|
|
152
|
+
## Resource management
|
|
153
|
+
|
|
154
|
+
WASM heap memory is not garbage collected. Call `.dispose()` on every model when done.
|
|
155
|
+
A `FinalizationRegistry` safety net warns if you forget, but do not rely on it.
|
|
156
|
+
|
|
157
|
+
## Build from source
|
|
158
|
+
|
|
159
|
+
Requires [Emscripten](https://emscripten.org/) (emsdk) activated.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
git clone --recurse-submodules https://github.com/wlearn-org/liblinear-wasm
|
|
163
|
+
cd liblinear-wasm
|
|
164
|
+
npm run build
|
|
165
|
+
npm test
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
If you already cloned without `--recurse-submodules`:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
git submodule update --init
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
BSD-3-Clause (same as upstream LIBLINEAR)
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wlearn/liblinear",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "LIBLINEAR v2.50 compiled to WebAssembly -- linear classification and regression in browsers and Node.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/",
|
|
12
|
+
"wasm/",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md",
|
|
15
|
+
"CHANGELOG.md"
|
|
16
|
+
],
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --experimental-vm-modules test/test.js",
|
|
23
|
+
"build": "bash scripts/build-wasm.sh",
|
|
24
|
+
"verify": "bash scripts/verify-exports.sh"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@wlearn/types": "0.1.0",
|
|
28
|
+
"@wlearn/core": "0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"liblinear",
|
|
32
|
+
"svm",
|
|
33
|
+
"logistic-regression",
|
|
34
|
+
"machine-learning",
|
|
35
|
+
"wasm",
|
|
36
|
+
"webassembly",
|
|
37
|
+
"wlearn"
|
|
38
|
+
],
|
|
39
|
+
"author": "Anton Zemlyansky",
|
|
40
|
+
"license": "BSD-3-Clause"
|
|
41
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { loadLinear, getWasm } from './wasm.js'
|
|
2
|
+
export { LinearModel, Solver } from './model.js'
|
|
3
|
+
|
|
4
|
+
// Convenience: create, fit, return fitted model
|
|
5
|
+
export async function train(params, X, y) {
|
|
6
|
+
const model = await LinearModel.create(params)
|
|
7
|
+
model.fit(X, y)
|
|
8
|
+
return model
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Convenience: load WLRN bundle and predict, auto-disposes model
|
|
12
|
+
export async function predict(bundleBytes, X) {
|
|
13
|
+
const model = await LinearModel.load(bundleBytes)
|
|
14
|
+
const result = model.predict(X)
|
|
15
|
+
model.dispose()
|
|
16
|
+
return result
|
|
17
|
+
}
|
package/src/model.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { getWasm, loadLinear } from './wasm.js'
|
|
2
|
+
import {
|
|
3
|
+
normalizeX, normalizeY,
|
|
4
|
+
encodeBundle, decodeBundle,
|
|
5
|
+
register,
|
|
6
|
+
DisposedError, NotFittedError
|
|
7
|
+
} from '@wlearn/core'
|
|
8
|
+
|
|
9
|
+
// FinalizationRegistry safety net -- warns if dispose() was never called
|
|
10
|
+
const leakRegistry = typeof FinalizationRegistry !== 'undefined'
|
|
11
|
+
? new FinalizationRegistry(({ ptr, freeFn }) => {
|
|
12
|
+
if (ptr[0]) {
|
|
13
|
+
console.warn('@wlearn/liblinear: Model was not disposed -- calling free() automatically. This is a bug in your code.')
|
|
14
|
+
freeFn(ptr[0])
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
: null
|
|
18
|
+
|
|
19
|
+
// --- Solver constants ---
|
|
20
|
+
|
|
21
|
+
export const Solver = {
|
|
22
|
+
L2R_LR: 0,
|
|
23
|
+
L2R_L2LOSS_SVC_DUAL: 1,
|
|
24
|
+
L2R_L2LOSS_SVC: 2,
|
|
25
|
+
L2R_L1LOSS_SVC_DUAL: 3,
|
|
26
|
+
MCSVM_CS: 4,
|
|
27
|
+
L1R_L2LOSS_SVC: 5,
|
|
28
|
+
L1R_LR: 6,
|
|
29
|
+
L2R_LR_DUAL: 7,
|
|
30
|
+
L2R_L2LOSS_SVR: 11,
|
|
31
|
+
L2R_L2LOSS_SVR_DUAL: 12,
|
|
32
|
+
L2R_L1LOSS_SVR_DUAL: 13
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SOLVER_NAMES = Object.fromEntries(
|
|
36
|
+
Object.entries(Solver).map(([k, v]) => [v, k])
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const LR_SOLVERS = new Set([Solver.L2R_LR, Solver.L1R_LR, Solver.L2R_LR_DUAL])
|
|
40
|
+
const SVR_SOLVERS = new Set([Solver.L2R_L2LOSS_SVR, Solver.L2R_L2LOSS_SVR_DUAL, Solver.L2R_L1LOSS_SVR_DUAL])
|
|
41
|
+
|
|
42
|
+
function resolveSolver(s) {
|
|
43
|
+
if (typeof s === 'number') return s
|
|
44
|
+
if (typeof s === 'string' && s in Solver) return Solver[s]
|
|
45
|
+
return Solver.L2R_LR
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Helper: write C string to WASM heap ---
|
|
49
|
+
|
|
50
|
+
function withCString(wasm, str, fn) {
|
|
51
|
+
const bytes = new TextEncoder().encode(str + '\0')
|
|
52
|
+
const ptr = wasm._malloc(bytes.length)
|
|
53
|
+
wasm.HEAPU8.set(bytes, ptr)
|
|
54
|
+
try {
|
|
55
|
+
return fn(ptr)
|
|
56
|
+
} finally {
|
|
57
|
+
wasm._free(ptr)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getLastError() {
|
|
62
|
+
const wasm = getWasm()
|
|
63
|
+
return wasm.ccall('wl_linear_get_last_error', 'string', [], [])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Internal sentinel for load path ---
|
|
67
|
+
const LOAD_SENTINEL = Symbol('load')
|
|
68
|
+
|
|
69
|
+
// --- LinearModel ---
|
|
70
|
+
|
|
71
|
+
export class LinearModel {
|
|
72
|
+
#handle = null
|
|
73
|
+
#freed = false
|
|
74
|
+
#ptrRef = null
|
|
75
|
+
#params = {}
|
|
76
|
+
#coerce = 'auto'
|
|
77
|
+
#warned = false
|
|
78
|
+
#fitted = false
|
|
79
|
+
|
|
80
|
+
constructor(handle, params, coerce) {
|
|
81
|
+
if (handle === LOAD_SENTINEL) {
|
|
82
|
+
// Internal: created by load()
|
|
83
|
+
this.#handle = params // params holds the handle in this path
|
|
84
|
+
this.#params = coerce || {} // coerce holds params in this path
|
|
85
|
+
this.#coerce = this.#params.coerce || 'auto'
|
|
86
|
+
this.#fitted = true
|
|
87
|
+
} else {
|
|
88
|
+
// Normal construction (from create())
|
|
89
|
+
this.#handle = null
|
|
90
|
+
this.#params = handle || {}
|
|
91
|
+
this.#coerce = this.#params.coerce || 'auto'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.#freed = false
|
|
95
|
+
if (this.#handle) {
|
|
96
|
+
this.#ptrRef = [this.#handle]
|
|
97
|
+
if (leakRegistry) {
|
|
98
|
+
leakRegistry.register(this, {
|
|
99
|
+
ptr: this.#ptrRef,
|
|
100
|
+
freeFn: (h) => getWasm()._wl_linear_free_model(h)
|
|
101
|
+
}, this)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
static async create(params = {}) {
|
|
107
|
+
await loadLinear()
|
|
108
|
+
return new LinearModel(params)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- Estimator interface ---
|
|
112
|
+
|
|
113
|
+
fit(X, y) {
|
|
114
|
+
this.#ensureFitted(false)
|
|
115
|
+
const wasm = getWasm()
|
|
116
|
+
|
|
117
|
+
// Dispose previous model if refitting
|
|
118
|
+
if (this.#handle) {
|
|
119
|
+
wasm._wl_linear_free_model(this.#handle)
|
|
120
|
+
this.#handle = null
|
|
121
|
+
if (this.#ptrRef) this.#ptrRef[0] = null
|
|
122
|
+
if (leakRegistry) leakRegistry.unregister(this)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const { data: xData, rows, cols } = this.#normalizeX(X)
|
|
126
|
+
const yNorm = normalizeY(y)
|
|
127
|
+
// WASM boundary requires Float64Array
|
|
128
|
+
const yData = yNorm instanceof Float64Array ? yNorm : new Float64Array(yNorm)
|
|
129
|
+
|
|
130
|
+
if (yData.length !== rows) {
|
|
131
|
+
throw new Error(`y length (${yData.length}) does not match X rows (${rows})`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Allocate X on WASM heap (float64)
|
|
135
|
+
const xBytes = xData.length * 8
|
|
136
|
+
const xPtr = wasm._malloc(xBytes)
|
|
137
|
+
wasm.HEAPF64.set(xData, xPtr / 8)
|
|
138
|
+
|
|
139
|
+
// Allocate y on WASM heap
|
|
140
|
+
const yBytes = yData.length * 8
|
|
141
|
+
const yPtr = wasm._malloc(yBytes)
|
|
142
|
+
wasm.HEAPF64.set(yData, yPtr / 8)
|
|
143
|
+
|
|
144
|
+
const solver = resolveSolver(this.#params.solver)
|
|
145
|
+
const C = this.#params.C ?? 1.0
|
|
146
|
+
const eps = this.#params.eps ?? 0.01
|
|
147
|
+
const bias = this.#params.bias ?? -1
|
|
148
|
+
const p = this.#params.p ?? 0.1
|
|
149
|
+
|
|
150
|
+
const modelPtr = wasm._wl_linear_train(
|
|
151
|
+
xPtr, rows, cols,
|
|
152
|
+
yPtr,
|
|
153
|
+
solver, C, eps, bias, p,
|
|
154
|
+
0, 0, 0 // nr_weight, weight_label, weight (none)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
wasm._free(xPtr)
|
|
158
|
+
wasm._free(yPtr)
|
|
159
|
+
|
|
160
|
+
if (!modelPtr) {
|
|
161
|
+
throw new Error(`Training failed: ${getLastError()}`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.#handle = modelPtr
|
|
165
|
+
this.#fitted = true
|
|
166
|
+
|
|
167
|
+
// Register for leak detection
|
|
168
|
+
this.#ptrRef = [this.#handle]
|
|
169
|
+
if (leakRegistry) {
|
|
170
|
+
leakRegistry.register(this, {
|
|
171
|
+
ptr: this.#ptrRef,
|
|
172
|
+
freeFn: (h) => getWasm()._wl_linear_free_model(h)
|
|
173
|
+
}, this)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return this
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
predict(X) {
|
|
180
|
+
this.#ensureFitted()
|
|
181
|
+
const wasm = getWasm()
|
|
182
|
+
const { data: xData, rows, cols } = this.#normalizeX(X)
|
|
183
|
+
|
|
184
|
+
const xPtr = wasm._malloc(xData.length * 8)
|
|
185
|
+
wasm.HEAPF64.set(xData, xPtr / 8)
|
|
186
|
+
|
|
187
|
+
const outPtr = wasm._malloc(rows * 8)
|
|
188
|
+
|
|
189
|
+
const ret = wasm._wl_linear_predict(this.#handle, xPtr, rows, cols, outPtr)
|
|
190
|
+
|
|
191
|
+
if (ret !== 0) {
|
|
192
|
+
wasm._free(xPtr)
|
|
193
|
+
wasm._free(outPtr)
|
|
194
|
+
throw new Error(`Predict failed: ${getLastError()}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const result = new Float64Array(rows)
|
|
198
|
+
for (let i = 0; i < rows; i++) {
|
|
199
|
+
result[i] = wasm.HEAPF64[outPtr / 8 + i]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
wasm._free(xPtr)
|
|
203
|
+
wasm._free(outPtr)
|
|
204
|
+
return result
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
predictProba(X) {
|
|
208
|
+
this.#ensureFitted()
|
|
209
|
+
const wasm = getWasm()
|
|
210
|
+
const { data: xData, rows, cols } = this.#normalizeX(X)
|
|
211
|
+
const nrClass = this.nrClass
|
|
212
|
+
|
|
213
|
+
const xPtr = wasm._malloc(xData.length * 8)
|
|
214
|
+
wasm.HEAPF64.set(xData, xPtr / 8)
|
|
215
|
+
|
|
216
|
+
const outPtr = wasm._malloc(rows * nrClass * 8)
|
|
217
|
+
|
|
218
|
+
const ret = wasm._wl_linear_predict_probability(this.#handle, xPtr, rows, cols, outPtr)
|
|
219
|
+
|
|
220
|
+
if (ret !== 0) {
|
|
221
|
+
wasm._free(xPtr)
|
|
222
|
+
wasm._free(outPtr)
|
|
223
|
+
throw new Error(`predictProba failed: ${getLastError()}`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const total = rows * nrClass
|
|
227
|
+
const result = new Float64Array(total)
|
|
228
|
+
for (let i = 0; i < total; i++) {
|
|
229
|
+
result[i] = wasm.HEAPF64[outPtr / 8 + i]
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
wasm._free(xPtr)
|
|
233
|
+
wasm._free(outPtr)
|
|
234
|
+
return result
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
decisionFunction(X) {
|
|
238
|
+
this.#ensureFitted()
|
|
239
|
+
const wasm = getWasm()
|
|
240
|
+
const { data: xData, rows, cols } = this.#normalizeX(X)
|
|
241
|
+
const dim = this.decisionDim
|
|
242
|
+
|
|
243
|
+
const xPtr = wasm._malloc(xData.length * 8)
|
|
244
|
+
wasm.HEAPF64.set(xData, xPtr / 8)
|
|
245
|
+
|
|
246
|
+
const outPtr = wasm._malloc(rows * dim * 8)
|
|
247
|
+
|
|
248
|
+
const ret = wasm._wl_linear_predict_values(this.#handle, xPtr, rows, cols, outPtr)
|
|
249
|
+
|
|
250
|
+
if (ret !== 0) {
|
|
251
|
+
wasm._free(xPtr)
|
|
252
|
+
wasm._free(outPtr)
|
|
253
|
+
throw new Error(`decisionFunction failed: ${getLastError()}`)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const total = rows * dim
|
|
257
|
+
const result = new Float64Array(total)
|
|
258
|
+
for (let i = 0; i < total; i++) {
|
|
259
|
+
result[i] = wasm.HEAPF64[outPtr / 8 + i]
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
wasm._free(xPtr)
|
|
263
|
+
wasm._free(outPtr)
|
|
264
|
+
return result
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
score(X, y) {
|
|
268
|
+
const preds = this.predict(X)
|
|
269
|
+
const yArr = normalizeY(y)
|
|
270
|
+
|
|
271
|
+
if (this.#isRegressor()) {
|
|
272
|
+
// R-squared
|
|
273
|
+
let ssRes = 0, ssTot = 0
|
|
274
|
+
let yMean = 0
|
|
275
|
+
for (let i = 0; i < yArr.length; i++) yMean += yArr[i]
|
|
276
|
+
yMean /= yArr.length
|
|
277
|
+
for (let i = 0; i < yArr.length; i++) {
|
|
278
|
+
ssRes += (yArr[i] - preds[i]) ** 2
|
|
279
|
+
ssTot += (yArr[i] - yMean) ** 2
|
|
280
|
+
}
|
|
281
|
+
return ssTot === 0 ? 0 : 1 - ssRes / ssTot
|
|
282
|
+
} else {
|
|
283
|
+
// Accuracy
|
|
284
|
+
let correct = 0
|
|
285
|
+
for (let i = 0; i < preds.length; i++) {
|
|
286
|
+
if (preds[i] === yArr[i]) correct++
|
|
287
|
+
}
|
|
288
|
+
return correct / preds.length
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// --- Model I/O ---
|
|
293
|
+
|
|
294
|
+
save() {
|
|
295
|
+
this.#ensureFitted()
|
|
296
|
+
const rawBytes = this.#saveRaw()
|
|
297
|
+
const solver = resolveSolver(this.#params.solver)
|
|
298
|
+
const typeId = SVR_SOLVERS.has(solver)
|
|
299
|
+
? 'wlearn.liblinear.regressor@1'
|
|
300
|
+
: 'wlearn.liblinear.classifier@1'
|
|
301
|
+
return encodeBundle(
|
|
302
|
+
{ typeId, params: this.getParams() },
|
|
303
|
+
[{ id: 'model', data: rawBytes }]
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
static async load(bytes) {
|
|
308
|
+
const { manifest, toc, blobs } = decodeBundle(bytes)
|
|
309
|
+
return LinearModel._fromBundle(manifest, toc, blobs)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
static async _fromBundle(manifest, toc, blobs) {
|
|
313
|
+
await loadLinear()
|
|
314
|
+
const wasm = getWasm()
|
|
315
|
+
|
|
316
|
+
const entry = toc.find(e => e.id === 'model')
|
|
317
|
+
if (!entry) throw new Error('Bundle missing "model" artifact')
|
|
318
|
+
const raw = blobs.subarray(entry.offset, entry.offset + entry.length)
|
|
319
|
+
|
|
320
|
+
const bufPtr = wasm._malloc(raw.length)
|
|
321
|
+
wasm.HEAPU8.set(raw, bufPtr)
|
|
322
|
+
const modelPtr = wasm._wl_linear_load_model(bufPtr, raw.length)
|
|
323
|
+
wasm._free(bufPtr)
|
|
324
|
+
|
|
325
|
+
if (!modelPtr) {
|
|
326
|
+
throw new Error(`load failed: ${getLastError()}`)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return new LinearModel(LOAD_SENTINEL, modelPtr, manifest.params || {})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
dispose() {
|
|
333
|
+
if (this.#freed) return
|
|
334
|
+
this.#freed = true
|
|
335
|
+
|
|
336
|
+
if (this.#handle) {
|
|
337
|
+
const wasm = getWasm()
|
|
338
|
+
wasm._wl_linear_free_model(this.#handle)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (this.#ptrRef) this.#ptrRef[0] = null
|
|
342
|
+
if (leakRegistry) leakRegistry.unregister(this)
|
|
343
|
+
|
|
344
|
+
this.#handle = null
|
|
345
|
+
this.#fitted = false
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- Params ---
|
|
349
|
+
|
|
350
|
+
getParams() {
|
|
351
|
+
return { ...this.#params }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
setParams(p) {
|
|
355
|
+
Object.assign(this.#params, p)
|
|
356
|
+
if ('coerce' in p) this.#coerce = p.coerce
|
|
357
|
+
return this
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
static defaultSearchSpace() {
|
|
361
|
+
return {
|
|
362
|
+
solver: { type: 'categorical', values: ['L2R_LR', 'L2R_L2LOSS_SVC_DUAL', 'L1R_LR'] },
|
|
363
|
+
C: { type: 'log_uniform', low: 1e-4, high: 1e4 },
|
|
364
|
+
eps: { type: 'log_uniform', low: 1e-5, high: 1e-1 }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// --- Inspection ---
|
|
369
|
+
|
|
370
|
+
get nrClass() {
|
|
371
|
+
this.#ensureFitted()
|
|
372
|
+
return getWasm()._wl_linear_get_nr_class(this.#handle)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
get nrFeature() {
|
|
376
|
+
this.#ensureFitted()
|
|
377
|
+
return getWasm()._wl_linear_get_nr_feature(this.#handle)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
get classes() {
|
|
381
|
+
this.#ensureFitted()
|
|
382
|
+
const wasm = getWasm()
|
|
383
|
+
const n = this.nrClass
|
|
384
|
+
const outPtr = wasm._malloc(n * 4)
|
|
385
|
+
wasm._wl_linear_get_labels(this.#handle, outPtr)
|
|
386
|
+
const result = new Int32Array(n)
|
|
387
|
+
for (let i = 0; i < n; i++) {
|
|
388
|
+
result[i] = wasm.getValue(outPtr + i * 4, 'i32')
|
|
389
|
+
}
|
|
390
|
+
wasm._free(outPtr)
|
|
391
|
+
return result
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
get isFitted() {
|
|
395
|
+
return this.#fitted && !this.#freed
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
get capabilities() {
|
|
399
|
+
const solver = resolveSolver(this.#params.solver)
|
|
400
|
+
const isRegressor = SVR_SOLVERS.has(solver)
|
|
401
|
+
const hasProba = LR_SOLVERS.has(solver)
|
|
402
|
+
return {
|
|
403
|
+
classifier: !isRegressor,
|
|
404
|
+
regressor: isRegressor,
|
|
405
|
+
predictProba: hasProba,
|
|
406
|
+
decisionFunction: true,
|
|
407
|
+
sampleWeight: false,
|
|
408
|
+
csr: false,
|
|
409
|
+
earlyStopping: false
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
get probaDim() {
|
|
414
|
+
return this.isFitted ? this.nrClass : 0
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
get decisionDim() {
|
|
418
|
+
if (!this.isFitted) return 0
|
|
419
|
+
const n = this.nrClass
|
|
420
|
+
return n === 2 ? 1 : n
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// --- Private helpers ---
|
|
424
|
+
|
|
425
|
+
#saveRaw() {
|
|
426
|
+
const wasm = getWasm()
|
|
427
|
+
|
|
428
|
+
const outBufPtr = wasm._malloc(4) // char** (pointer to buffer)
|
|
429
|
+
const outLenPtr = wasm._malloc(4) // int*
|
|
430
|
+
|
|
431
|
+
const ret = wasm._wl_linear_save_model(this.#handle, outBufPtr, outLenPtr)
|
|
432
|
+
|
|
433
|
+
if (ret !== 0) {
|
|
434
|
+
wasm._free(outBufPtr)
|
|
435
|
+
wasm._free(outLenPtr)
|
|
436
|
+
throw new Error(`save failed: ${getLastError()}`)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const bufPtr = wasm.getValue(outBufPtr, 'i32')
|
|
440
|
+
const bufLen = wasm.getValue(outLenPtr, 'i32')
|
|
441
|
+
|
|
442
|
+
const result = new Uint8Array(bufLen)
|
|
443
|
+
result.set(wasm.HEAPU8.subarray(bufPtr, bufPtr + bufLen))
|
|
444
|
+
|
|
445
|
+
wasm._wl_linear_free_buffer(bufPtr)
|
|
446
|
+
wasm._free(outBufPtr)
|
|
447
|
+
wasm._free(outLenPtr)
|
|
448
|
+
|
|
449
|
+
return result
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#normalizeX(X) {
|
|
453
|
+
const coerce = this.#warned ? 'auto' : this.#coerce
|
|
454
|
+
const result = normalizeX(X, coerce)
|
|
455
|
+
if (this.#coerce === 'warn' && !this.#warned && Array.isArray(X)) {
|
|
456
|
+
this.#warned = true
|
|
457
|
+
}
|
|
458
|
+
return result
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#ensureFitted(requireFit = true) {
|
|
462
|
+
if (this.#freed) throw new DisposedError('LinearModel has been disposed.')
|
|
463
|
+
if (requireFit && !this.#fitted) throw new NotFittedError('LinearModel is not fitted. Call fit() first.')
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#isRegressor() {
|
|
467
|
+
const solver = resolveSolver(this.#params.solver)
|
|
468
|
+
return SVR_SOLVERS.has(solver)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// --- Register loaders with @wlearn/core ---
|
|
473
|
+
|
|
474
|
+
register('wlearn.liblinear.classifier@1', (m, t, b) => LinearModel._fromBundle(m, t, b))
|
|
475
|
+
register('wlearn.liblinear.regressor@1', (m, t, b) => LinearModel._fromBundle(m, t, b))
|
package/src/wasm.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// WASM loader -- loads the LIBLINEAR WASM module (singleton, lazy init)
|
|
2
|
+
|
|
3
|
+
import { createRequire } from 'module'
|
|
4
|
+
|
|
5
|
+
let wasmModule = null
|
|
6
|
+
let loading = null
|
|
7
|
+
|
|
8
|
+
export async function loadLinear(options = {}) {
|
|
9
|
+
if (wasmModule) return wasmModule
|
|
10
|
+
if (loading) return loading
|
|
11
|
+
|
|
12
|
+
loading = (async () => {
|
|
13
|
+
// SINGLE_FILE=1: .wasm is embedded in the .js file, no locateFile needed
|
|
14
|
+
// Emscripten output is CJS, use createRequire for ESM compatibility
|
|
15
|
+
const require = createRequire(import.meta.url)
|
|
16
|
+
const createLinear = require('../wasm/linear.cjs')
|
|
17
|
+
wasmModule = await createLinear(options)
|
|
18
|
+
return wasmModule
|
|
19
|
+
})()
|
|
20
|
+
|
|
21
|
+
return loading
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getWasm() {
|
|
25
|
+
if (!wasmModule) throw new Error('WASM not loaded -- call loadLinear() first')
|
|
26
|
+
return wasmModule
|
|
27
|
+
}
|
package/wasm/BUILD_INFO
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
upstream: liblinear v2.50
|
|
2
|
+
upstream_commit: 491c9f1188b97ba70847c70a68be363d186ddf9d
|
|
3
|
+
build_date: 2026-02-27T13:01:37Z
|
|
4
|
+
emscripten: emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 5.0.2 (dc80f645ee70178c11666de0c3860d9e064d50e4)
|
|
5
|
+
build_flags: -O2 SINGLE_FILE=1
|
|
6
|
+
wasm_embedded: true
|
package/wasm/linear.cjs
ADDED
|
Binary file
|