@wlearn/libsvm 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 +17 -0
- package/LICENSE +29 -0
- package/README.md +167 -0
- package/package.json +41 -0
- package/src/index.js +17 -0
- package/src/model.js +481 -0
- package/src/wasm.js +27 -0
- package/wasm/BUILD_INFO +6 -0
- package/wasm/svm.cjs +0 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
- Initial release
|
|
6
|
+
- LIBSVM v3.37 compiled to WASM via Emscripten
|
|
7
|
+
- Unified sklearn-style API: `create()`, `fit()`, `predict()`, `score()`, `save()`, `dispose()`
|
|
8
|
+
- Kernel SVM: C-SVC, nu-SVC, one-class SVM, epsilon-SVR, nu-SVR
|
|
9
|
+
- Kernels: linear, polynomial, RBF, sigmoid
|
|
10
|
+
- Buffer-based model I/O (no filesystem dependency)
|
|
11
|
+
- Accepts both typed matrices and number[][] with configurable coercion
|
|
12
|
+
- `predictProba()` for probability estimates
|
|
13
|
+
- `decisionFunction()` for decision values
|
|
14
|
+
- `getParams()`/`setParams()` for AutoML integration
|
|
15
|
+
- `defaultSearchSpace()` for hyperparameter search
|
|
16
|
+
- `FinalizationRegistry` safety net for leak detection
|
|
17
|
+
- BSD-3-Clause license (same as upstream LIBSVM)
|
package/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Copyright (c) 2000-2025 Chih-Chung Chang and Chih-Jen Lin
|
|
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,167 @@
|
|
|
1
|
+
# @wlearn/libsvm
|
|
2
|
+
|
|
3
|
+
LIBSVM v3.37 compiled to WebAssembly. Kernel SVM classification, regression, and novelty detection in browsers and Node.js.
|
|
4
|
+
|
|
5
|
+
Based on [LIBSVM v3.37](https://www.csie.ntu.edu.tw/~cjlin/libsvm/) (BSD-3-Clause). Zero dependencies. ESM.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @wlearn/libsvm
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { SVMModel } from '@wlearn/libsvm'
|
|
17
|
+
|
|
18
|
+
const model = await SVMModel.create({
|
|
19
|
+
svmType: 'C_SVC',
|
|
20
|
+
kernel: 'RBF',
|
|
21
|
+
C: 1.0,
|
|
22
|
+
gamma: 0.5
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Train -- accepts number[][] or { data: Float64Array, rows, cols }
|
|
26
|
+
model.fit(
|
|
27
|
+
[[1, 2], [3, 4], [5, 6], [7, 8]],
|
|
28
|
+
[0, 0, 1, 1]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// Predict
|
|
32
|
+
const preds = model.predict([[2, 3], [6, 7]]) // Float64Array
|
|
33
|
+
|
|
34
|
+
// Score
|
|
35
|
+
const accuracy = model.score([[2, 3], [6, 7]], [0, 1])
|
|
36
|
+
|
|
37
|
+
// Save / load
|
|
38
|
+
const buf = model.save() // Uint8Array
|
|
39
|
+
const model2 = await SVMModel.load(buf)
|
|
40
|
+
|
|
41
|
+
// Clean up -- required, WASM memory is not garbage collected
|
|
42
|
+
model.dispose()
|
|
43
|
+
model2.dispose()
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Typed matrix input (fast path)
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
const X = {
|
|
50
|
+
data: new Float64Array([1, 2, 3, 4, 5, 6, 7, 8]),
|
|
51
|
+
rows: 4,
|
|
52
|
+
cols: 2
|
|
53
|
+
}
|
|
54
|
+
model.fit(X, new Float64Array([0, 0, 1, 1]))
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Input coercion policy
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
const model = await SVMModel.create({ coerce: 'auto' }) // convert silently (default)
|
|
61
|
+
const model = await SVMModel.create({ coerce: 'warn' }) // warn once per instance
|
|
62
|
+
const model = await SVMModel.create({ coerce: 'error' }) // throw on non-typed input
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## API
|
|
66
|
+
|
|
67
|
+
### `SVMModel.create(params?)`
|
|
68
|
+
|
|
69
|
+
Async factory. Loads WASM module, returns ready-to-use model.
|
|
70
|
+
|
|
71
|
+
Parameters:
|
|
72
|
+
- `svmType` -- `'C_SVC'` | `'NU_SVC'` | `'ONE_CLASS'` | `'EPSILON_SVR'` | `'NU_SVR'` (default: `'C_SVC'`)
|
|
73
|
+
- `kernel` -- `'LINEAR'` | `'POLY'` | `'RBF'` | `'SIGMOID'` (default: `'RBF'`)
|
|
74
|
+
- `C` -- regularization (default: `1.0`)
|
|
75
|
+
- `gamma` -- kernel coefficient, <= 0 means 1/n_features (default: `0`)
|
|
76
|
+
- `degree` -- polynomial degree (default: `3`)
|
|
77
|
+
- `coef0` -- independent term in kernel (default: `0`)
|
|
78
|
+
- `nu` -- for NU_SVC/NU_SVR/ONE_CLASS (default: `0.5`)
|
|
79
|
+
- `eps` -- stopping tolerance (default: `0.001`)
|
|
80
|
+
- `p` -- epsilon in SVR loss (default: `0.1`)
|
|
81
|
+
- `shrinking` -- use shrinking heuristic (default: `1`)
|
|
82
|
+
- `probability` -- enable probability estimates (default: `0`)
|
|
83
|
+
- `cacheSize` -- kernel cache in MB (default: `100`)
|
|
84
|
+
- `coerce` -- `'auto'` | `'warn'` | `'error'` (default: `'auto'`)
|
|
85
|
+
|
|
86
|
+
### `model.fit(X, y)`
|
|
87
|
+
|
|
88
|
+
Train on data. Returns `this`.
|
|
89
|
+
|
|
90
|
+
### `model.predict(X)`
|
|
91
|
+
|
|
92
|
+
Returns `Float64Array` of predicted labels.
|
|
93
|
+
|
|
94
|
+
### `model.predictProba(X)`
|
|
95
|
+
|
|
96
|
+
Returns `Float64Array` of shape `nrow * nclass` (row-major probabilities).
|
|
97
|
+
Requires `probability: 1` in constructor params.
|
|
98
|
+
|
|
99
|
+
### `model.decisionFunction(X)`
|
|
100
|
+
|
|
101
|
+
Returns `Float64Array` of decision values.
|
|
102
|
+
- Binary classification: `nrow` values
|
|
103
|
+
- Multi-class: `nrow * nr_class*(nr_class-1)/2` pairwise margins
|
|
104
|
+
|
|
105
|
+
### `model.score(X, y)`
|
|
106
|
+
|
|
107
|
+
Returns accuracy (classification) or R-squared (regression).
|
|
108
|
+
|
|
109
|
+
### `model.save()` / `SVMModel.load(buffer)`
|
|
110
|
+
|
|
111
|
+
Save to / load from `Uint8Array` (native LIBSVM format).
|
|
112
|
+
|
|
113
|
+
### `model.dispose()`
|
|
114
|
+
|
|
115
|
+
Free WASM memory. Required. Idempotent.
|
|
116
|
+
|
|
117
|
+
### `model.getParams()` / `model.setParams(p)`
|
|
118
|
+
|
|
119
|
+
Get/set hyperparameters.
|
|
120
|
+
|
|
121
|
+
### `SVMModel.defaultSearchSpace()`
|
|
122
|
+
|
|
123
|
+
Returns default hyperparameter search space for AutoML.
|
|
124
|
+
|
|
125
|
+
## SVM types
|
|
126
|
+
|
|
127
|
+
| Name | Code | Task |
|
|
128
|
+
|------|------|------|
|
|
129
|
+
| C_SVC | 0 | C-support vector classification |
|
|
130
|
+
| NU_SVC | 1 | nu-support vector classification |
|
|
131
|
+
| ONE_CLASS | 2 | One-class SVM (novelty detection) |
|
|
132
|
+
| EPSILON_SVR | 3 | epsilon-support vector regression |
|
|
133
|
+
| NU_SVR | 4 | nu-support vector regression |
|
|
134
|
+
|
|
135
|
+
## Kernels
|
|
136
|
+
|
|
137
|
+
| Name | Code | Formula |
|
|
138
|
+
|------|------|---------|
|
|
139
|
+
| LINEAR | 0 | u'*v |
|
|
140
|
+
| POLY | 1 | (gamma*u'*v + coef0)^degree |
|
|
141
|
+
| RBF | 2 | exp(-gamma*\|u-v\|^2) |
|
|
142
|
+
| SIGMOID | 3 | tanh(gamma*u'*v + coef0) |
|
|
143
|
+
|
|
144
|
+
## Resource management
|
|
145
|
+
|
|
146
|
+
WASM heap memory is not garbage collected. Call `.dispose()` on every model when done.
|
|
147
|
+
|
|
148
|
+
## Build from source
|
|
149
|
+
|
|
150
|
+
Requires [Emscripten](https://emscripten.org/) (emsdk) activated.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
git clone --recurse-submodules https://github.com/wlearn-org/libsvm-wasm
|
|
154
|
+
cd libsvm-wasm
|
|
155
|
+
npm run build
|
|
156
|
+
npm test
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
If you already cloned without `--recurse-submodules`:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
git submodule update --init
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
BSD-3-Clause (same as upstream LIBSVM)
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wlearn/libsvm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "LIBSVM v3.37 compiled to WebAssembly -- SVM classification, regression, and novelty detection 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
|
+
"keywords": [
|
|
27
|
+
"libsvm",
|
|
28
|
+
"svm",
|
|
29
|
+
"kernel",
|
|
30
|
+
"machine-learning",
|
|
31
|
+
"wasm",
|
|
32
|
+
"webassembly",
|
|
33
|
+
"wlearn"
|
|
34
|
+
],
|
|
35
|
+
"author": "Anton Zemlyansky",
|
|
36
|
+
"license": "BSD-3-Clause",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@wlearn/types": "0.1.0",
|
|
39
|
+
"@wlearn/core": "0.1.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { loadSVM, getWasm } from './wasm.js'
|
|
2
|
+
export { SVMModel, SVMType, Kernel } from './model.js'
|
|
3
|
+
|
|
4
|
+
// Convenience: create, fit, return fitted model
|
|
5
|
+
export async function train(params, X, y) {
|
|
6
|
+
const model = await (await import('./model.js')).SVMModel.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 (await import('./model.js')).SVMModel.load(bundleBytes)
|
|
14
|
+
const result = model.predict(X)
|
|
15
|
+
model.dispose()
|
|
16
|
+
return result
|
|
17
|
+
}
|
package/src/model.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { getWasm, loadSVM } 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/libsvm: Model was not disposed -- calling free() automatically. This is a bug in your code.')
|
|
14
|
+
freeFn(ptr[0])
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
: null
|
|
18
|
+
|
|
19
|
+
// --- SVM type and kernel constants ---
|
|
20
|
+
|
|
21
|
+
export const SVMType = {
|
|
22
|
+
C_SVC: 0,
|
|
23
|
+
NU_SVC: 1,
|
|
24
|
+
ONE_CLASS: 2,
|
|
25
|
+
EPSILON_SVR: 3,
|
|
26
|
+
NU_SVR: 4
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const Kernel = {
|
|
30
|
+
LINEAR: 0,
|
|
31
|
+
POLY: 1,
|
|
32
|
+
RBF: 2,
|
|
33
|
+
SIGMOID: 3
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SVR_TYPES = new Set([SVMType.EPSILON_SVR, SVMType.NU_SVR])
|
|
37
|
+
|
|
38
|
+
// --- Input resolution ---
|
|
39
|
+
|
|
40
|
+
function resolveSVMType(s) {
|
|
41
|
+
if (typeof s === 'number') return s
|
|
42
|
+
if (typeof s === 'string' && s in SVMType) return SVMType[s]
|
|
43
|
+
return SVMType.C_SVC
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveKernel(k) {
|
|
47
|
+
if (typeof k === 'number') return k
|
|
48
|
+
if (typeof k === 'string' && k in Kernel) return Kernel[k]
|
|
49
|
+
return Kernel.RBF
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getLastError() {
|
|
53
|
+
const wasm = getWasm()
|
|
54
|
+
return wasm.ccall('wl_svm_get_last_error', 'string', [], [])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Internal sentinel for load path ---
|
|
58
|
+
const LOAD_SENTINEL = Symbol('load')
|
|
59
|
+
|
|
60
|
+
// --- SVMModel ---
|
|
61
|
+
|
|
62
|
+
export class SVMModel {
|
|
63
|
+
#handle = null
|
|
64
|
+
#freed = false
|
|
65
|
+
#ptrRef = null
|
|
66
|
+
#params = {}
|
|
67
|
+
#coerce = 'auto'
|
|
68
|
+
#warned = false
|
|
69
|
+
#fitted = false
|
|
70
|
+
#ncol = 0 // track feature count for gamma default
|
|
71
|
+
|
|
72
|
+
constructor(handle, params, coerce) {
|
|
73
|
+
if (handle === LOAD_SENTINEL) {
|
|
74
|
+
this.#handle = params
|
|
75
|
+
this.#params = coerce || {}
|
|
76
|
+
this.#coerce = this.#params.coerce || 'auto'
|
|
77
|
+
this.#fitted = true
|
|
78
|
+
} else {
|
|
79
|
+
this.#handle = null
|
|
80
|
+
this.#params = handle || {}
|
|
81
|
+
this.#coerce = this.#params.coerce || 'auto'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.#freed = false
|
|
85
|
+
if (this.#handle) {
|
|
86
|
+
this.#ptrRef = [this.#handle]
|
|
87
|
+
if (leakRegistry) {
|
|
88
|
+
leakRegistry.register(this, {
|
|
89
|
+
ptr: this.#ptrRef,
|
|
90
|
+
freeFn: (h) => getWasm()._wl_svm_free_model(h)
|
|
91
|
+
}, this)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static async create(params = {}) {
|
|
97
|
+
await loadSVM()
|
|
98
|
+
return new SVMModel(params)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Estimator interface ---
|
|
102
|
+
|
|
103
|
+
fit(X, y) {
|
|
104
|
+
this.#ensureFitted(false)
|
|
105
|
+
const wasm = getWasm()
|
|
106
|
+
|
|
107
|
+
// Dispose previous model if refitting
|
|
108
|
+
if (this.#handle) {
|
|
109
|
+
wasm._wl_svm_free_model(this.#handle)
|
|
110
|
+
this.#handle = null
|
|
111
|
+
if (this.#ptrRef) this.#ptrRef[0] = null
|
|
112
|
+
if (leakRegistry) leakRegistry.unregister(this)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { data: xData, rows, cols } = this.#normalizeX(X)
|
|
116
|
+
const yNorm = normalizeY(y)
|
|
117
|
+
// WASM boundary requires Float64Array
|
|
118
|
+
const yData = yNorm instanceof Float64Array ? yNorm : new Float64Array(yNorm)
|
|
119
|
+
this.#ncol = cols
|
|
120
|
+
|
|
121
|
+
if (yData.length !== rows) {
|
|
122
|
+
throw new Error(`y length (${yData.length}) does not match X rows (${rows})`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Allocate on WASM heap
|
|
126
|
+
const xPtr = wasm._malloc(xData.length * 8)
|
|
127
|
+
wasm.HEAPF64.set(xData, xPtr / 8)
|
|
128
|
+
|
|
129
|
+
const yPtr = wasm._malloc(yData.length * 8)
|
|
130
|
+
wasm.HEAPF64.set(yData, yPtr / 8)
|
|
131
|
+
|
|
132
|
+
const svmType = resolveSVMType(this.#params.svmType)
|
|
133
|
+
const kernel = resolveKernel(this.#params.kernel)
|
|
134
|
+
const degree = this.#params.degree ?? 3
|
|
135
|
+
const gamma = this.#params.gamma ?? 0 // 0 means 1/n_features in C wrapper
|
|
136
|
+
const coef0 = this.#params.coef0 ?? 0
|
|
137
|
+
const C = this.#params.C ?? 1.0
|
|
138
|
+
const nu = this.#params.nu ?? 0.5
|
|
139
|
+
const eps = this.#params.eps ?? 0.001
|
|
140
|
+
const p = this.#params.p ?? 0.1
|
|
141
|
+
const shrinking = this.#params.shrinking ?? 1
|
|
142
|
+
const probability = this.#params.probability ?? 0
|
|
143
|
+
const cacheSize = this.#params.cacheSize ?? 100
|
|
144
|
+
|
|
145
|
+
const modelPtr = wasm._wl_svm_train(
|
|
146
|
+
xPtr, rows, cols, yPtr,
|
|
147
|
+
svmType, kernel, degree, gamma, coef0,
|
|
148
|
+
C, nu, eps, p,
|
|
149
|
+
shrinking, probability, cacheSize
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
wasm._free(xPtr)
|
|
153
|
+
wasm._free(yPtr)
|
|
154
|
+
|
|
155
|
+
if (!modelPtr) {
|
|
156
|
+
throw new Error(`Training failed: ${getLastError()}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.#handle = modelPtr
|
|
160
|
+
this.#fitted = true
|
|
161
|
+
|
|
162
|
+
this.#ptrRef = [this.#handle]
|
|
163
|
+
if (leakRegistry) {
|
|
164
|
+
leakRegistry.register(this, {
|
|
165
|
+
ptr: this.#ptrRef,
|
|
166
|
+
freeFn: (h) => getWasm()._wl_svm_free_model(h)
|
|
167
|
+
}, this)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return this
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
predict(X) {
|
|
174
|
+
this.#ensureFitted()
|
|
175
|
+
const wasm = getWasm()
|
|
176
|
+
const { data: xData, rows, cols } = this.#normalizeX(X)
|
|
177
|
+
|
|
178
|
+
const xPtr = wasm._malloc(xData.length * 8)
|
|
179
|
+
wasm.HEAPF64.set(xData, xPtr / 8)
|
|
180
|
+
|
|
181
|
+
const outPtr = wasm._malloc(rows * 8)
|
|
182
|
+
|
|
183
|
+
const ret = wasm._wl_svm_predict(this.#handle, xPtr, rows, cols, outPtr)
|
|
184
|
+
|
|
185
|
+
if (ret !== 0) {
|
|
186
|
+
wasm._free(xPtr)
|
|
187
|
+
wasm._free(outPtr)
|
|
188
|
+
throw new Error(`Predict failed: ${getLastError()}`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const result = new Float64Array(rows)
|
|
192
|
+
for (let i = 0; i < rows; i++) {
|
|
193
|
+
result[i] = wasm.HEAPF64[outPtr / 8 + i]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
wasm._free(xPtr)
|
|
197
|
+
wasm._free(outPtr)
|
|
198
|
+
return result
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
predictProba(X) {
|
|
202
|
+
this.#ensureFitted()
|
|
203
|
+
const wasm = getWasm()
|
|
204
|
+
const { data: xData, rows, cols } = this.#normalizeX(X)
|
|
205
|
+
const nrClass = this.nrClass
|
|
206
|
+
|
|
207
|
+
const xPtr = wasm._malloc(xData.length * 8)
|
|
208
|
+
wasm.HEAPF64.set(xData, xPtr / 8)
|
|
209
|
+
|
|
210
|
+
const outPtr = wasm._malloc(rows * nrClass * 8)
|
|
211
|
+
|
|
212
|
+
const ret = wasm._wl_svm_predict_probability(this.#handle, xPtr, rows, cols, outPtr)
|
|
213
|
+
|
|
214
|
+
if (ret !== 0) {
|
|
215
|
+
wasm._free(xPtr)
|
|
216
|
+
wasm._free(outPtr)
|
|
217
|
+
throw new Error(`predictProba failed: ${getLastError()}`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const total = rows * nrClass
|
|
221
|
+
const result = new Float64Array(total)
|
|
222
|
+
for (let i = 0; i < total; i++) {
|
|
223
|
+
result[i] = wasm.HEAPF64[outPtr / 8 + i]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
wasm._free(xPtr)
|
|
227
|
+
wasm._free(outPtr)
|
|
228
|
+
return result
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
decisionFunction(X) {
|
|
232
|
+
this.#ensureFitted()
|
|
233
|
+
const wasm = getWasm()
|
|
234
|
+
const { data: xData, rows, cols } = this.#normalizeX(X)
|
|
235
|
+
|
|
236
|
+
const xPtr = wasm._malloc(xData.length * 8)
|
|
237
|
+
wasm.HEAPF64.set(xData, xPtr / 8)
|
|
238
|
+
|
|
239
|
+
const dimPtr = wasm._malloc(4)
|
|
240
|
+
const nrClass = this.nrClass
|
|
241
|
+
const maxDim = nrClass * (nrClass - 1) / 2 || 1
|
|
242
|
+
const outPtr = wasm._malloc(rows * maxDim * 8)
|
|
243
|
+
|
|
244
|
+
const ret = wasm._wl_svm_predict_values(
|
|
245
|
+
this.#handle, xPtr, rows, cols, outPtr, dimPtr
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if (ret !== 0) {
|
|
249
|
+
wasm._free(xPtr)
|
|
250
|
+
wasm._free(outPtr)
|
|
251
|
+
wasm._free(dimPtr)
|
|
252
|
+
throw new Error(`decisionFunction failed: ${getLastError()}`)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const dim = wasm.getValue(dimPtr, 'i32')
|
|
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
|
+
wasm._free(dimPtr)
|
|
265
|
+
return result
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
score(X, y) {
|
|
269
|
+
const preds = this.predict(X)
|
|
270
|
+
const yArr = normalizeY(y)
|
|
271
|
+
|
|
272
|
+
if (this.#isRegressor()) {
|
|
273
|
+
// R-squared
|
|
274
|
+
let ssRes = 0, ssTot = 0, 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 svmType = resolveSVMType(this.#params.svmType)
|
|
298
|
+
const typeId = SVR_TYPES.has(svmType)
|
|
299
|
+
? 'wlearn.libsvm.regressor@1'
|
|
300
|
+
: 'wlearn.libsvm.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 SVMModel._fromBundle(manifest, toc, blobs)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
static async _fromBundle(manifest, toc, blobs) {
|
|
313
|
+
await loadSVM()
|
|
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_svm_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 SVMModel(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_svm_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
|
+
svmType: { type: 'categorical', values: ['C_SVC', 'NU_SVC'] },
|
|
363
|
+
kernel: { type: 'categorical', values: ['RBF', 'LINEAR', 'POLY'] },
|
|
364
|
+
C: { type: 'log_uniform', low: 1e-3, high: 1e3 },
|
|
365
|
+
gamma: { type: 'log_uniform', low: 1e-5, high: 1e1 },
|
|
366
|
+
degree: { type: 'int_uniform', low: 2, high: 5, condition: { kernel: 'POLY' } },
|
|
367
|
+
nu: { type: 'uniform', low: 0.01, high: 0.99, condition: { svmType: 'NU_SVC' } }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --- Inspection ---
|
|
372
|
+
|
|
373
|
+
get nrClass() {
|
|
374
|
+
this.#ensureFitted()
|
|
375
|
+
return getWasm()._wl_svm_get_nr_class(this.#handle)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
get svCount() {
|
|
379
|
+
this.#ensureFitted()
|
|
380
|
+
return getWasm()._wl_svm_get_sv_count(this.#handle)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
get classes() {
|
|
384
|
+
this.#ensureFitted()
|
|
385
|
+
const wasm = getWasm()
|
|
386
|
+
const n = this.nrClass
|
|
387
|
+
const outPtr = wasm._malloc(n * 4)
|
|
388
|
+
wasm._wl_svm_get_labels(this.#handle, outPtr)
|
|
389
|
+
const result = new Int32Array(n)
|
|
390
|
+
for (let i = 0; i < n; i++) {
|
|
391
|
+
result[i] = wasm.getValue(outPtr + i * 4, 'i32')
|
|
392
|
+
}
|
|
393
|
+
wasm._free(outPtr)
|
|
394
|
+
return result
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
get isFitted() {
|
|
398
|
+
return this.#fitted && !this.#freed
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
get capabilities() {
|
|
402
|
+
const svmType = resolveSVMType(this.#params.svmType)
|
|
403
|
+
const isRegressor = SVR_TYPES.has(svmType)
|
|
404
|
+
const probability = this.#params.probability ?? 0
|
|
405
|
+
return {
|
|
406
|
+
classifier: !isRegressor,
|
|
407
|
+
regressor: isRegressor,
|
|
408
|
+
predictProba: !isRegressor && probability === 1,
|
|
409
|
+
decisionFunction: true,
|
|
410
|
+
oneClass: svmType === SVMType.ONE_CLASS,
|
|
411
|
+
sampleWeight: false,
|
|
412
|
+
csr: false,
|
|
413
|
+
earlyStopping: false
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
get probaDim() {
|
|
418
|
+
return this.isFitted ? this.nrClass : 0
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
get decisionDim() {
|
|
422
|
+
if (!this.isFitted) return 0
|
|
423
|
+
const svmType = resolveSVMType(this.#params.svmType)
|
|
424
|
+
if (svmType === SVMType.ONE_CLASS || SVR_TYPES.has(svmType)) return 1
|
|
425
|
+
const n = this.nrClass
|
|
426
|
+
return n === 2 ? 1 : n * (n - 1) / 2
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --- Private helpers ---
|
|
430
|
+
|
|
431
|
+
#saveRaw() {
|
|
432
|
+
const wasm = getWasm()
|
|
433
|
+
|
|
434
|
+
const outBufPtr = wasm._malloc(4)
|
|
435
|
+
const outLenPtr = wasm._malloc(4)
|
|
436
|
+
|
|
437
|
+
const ret = wasm._wl_svm_save_model(this.#handle, outBufPtr, outLenPtr)
|
|
438
|
+
|
|
439
|
+
if (ret !== 0) {
|
|
440
|
+
wasm._free(outBufPtr)
|
|
441
|
+
wasm._free(outLenPtr)
|
|
442
|
+
throw new Error(`save failed: ${getLastError()}`)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const bufPtr = wasm.getValue(outBufPtr, 'i32')
|
|
446
|
+
const bufLen = wasm.getValue(outLenPtr, 'i32')
|
|
447
|
+
|
|
448
|
+
const result = new Uint8Array(bufLen)
|
|
449
|
+
result.set(wasm.HEAPU8.subarray(bufPtr, bufPtr + bufLen))
|
|
450
|
+
|
|
451
|
+
wasm._wl_svm_free_buffer(bufPtr)
|
|
452
|
+
wasm._free(outBufPtr)
|
|
453
|
+
wasm._free(outLenPtr)
|
|
454
|
+
|
|
455
|
+
return result
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#normalizeX(X) {
|
|
459
|
+
const coerce = this.#warned ? 'auto' : this.#coerce
|
|
460
|
+
const result = normalizeX(X, coerce)
|
|
461
|
+
if (this.#coerce === 'warn' && !this.#warned && Array.isArray(X)) {
|
|
462
|
+
this.#warned = true
|
|
463
|
+
}
|
|
464
|
+
return result
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
#ensureFitted(requireFit = true) {
|
|
468
|
+
if (this.#freed) throw new DisposedError('SVMModel has been disposed.')
|
|
469
|
+
if (requireFit && !this.#fitted) throw new NotFittedError('SVMModel is not fitted. Call fit() first.')
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
#isRegressor() {
|
|
473
|
+
const svmType = resolveSVMType(this.#params.svmType)
|
|
474
|
+
return SVR_TYPES.has(svmType)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// --- Register loaders with @wlearn/core ---
|
|
479
|
+
|
|
480
|
+
register('wlearn.libsvm.classifier@1', (m, t, b) => SVMModel._fromBundle(m, t, b))
|
|
481
|
+
register('wlearn.libsvm.regressor@1', (m, t, b) => SVMModel._fromBundle(m, t, b))
|
package/src/wasm.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// WASM loader -- loads the LIBSVM 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 loadSVM(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 createSVM = require('../wasm/svm.cjs')
|
|
17
|
+
wasmModule = await createSVM(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 loadSVM() first')
|
|
26
|
+
return wasmModule
|
|
27
|
+
}
|
package/wasm/BUILD_INFO
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
upstream: libsvm v3.37
|
|
2
|
+
upstream_commit: 6b907139084abf2da4d6d3cb10dc3b7eaffa2fbb
|
|
3
|
+
build_date: 2026-02-27T13:03:12Z
|
|
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/svm.cjs
ADDED
|
Binary file
|