goml

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 4, 2026 License: MIT Imports: 10 Imported by: 0

README

go-ml

CI Go Reference

[!CAUTION] This library is fully AI-generated. The implementation, tests, examples and documentation were all written by an AI agent.

Every shipped model is checked against scikit-learn's own outputs in this repository's test suite, but its only real-world validation is against production workloads specific to Heru, Inc. Anything those workloads do not exercise — other estimator types, other hyper-parameters, other scikit-learn versions, other input regimes — is unproven, and exactness against a moving upstream is exactly where that gap is most likely to hurt.

Use it with caution: read the code, validate your own exported model against your own scikit-learn outputs, and treat Fidelity and its limits as claims to verify rather than as guarantees.

Run trained scikit-learn models in pure Go, with output that matches the original estimator to floating-point rounding (observed max difference across the shipped models: 9.4e-16).

A model is exported once from Python into a portable JSON document and then either loaded at startup or — for maximum speed and zero runtime dependencies — compiled directly into your Go binary. There is no Python, no pickle, and no model file at runtime.

scikit-learn estimator ──(tools/sklexport)──▶ go-ml/v1 JSON ──┬──▶ goml.Load(...)         (embed & load)
                                                              └──▶ go-ml-gen ──▶ .go file (compile in)

The framework is generic: each estimator type registers a decoder and becomes usable through the same goml.Load entry point, exactly like image.RegisterFormat or database/sql drivers.

Taste

Once, in Python:

python -m sklexport.export model.pkl -o model.json

Forever after, in Go:

import (
    goml "github.com/heru-opensource/go-ml"
    _ "github.com/heru-opensource/go-ml/ensemble" // registers the forest models
)

// Load once at startup — or //go:embed the JSON, or compile it in with go-ml-gen.
clf, err := goml.LoadClassifierFile("model.json")
if err != nil {
    log.Fatal(err)
}

proba, _ := clf.PredictProba([][]float64{{1.2, 3.4, math.NaN()}}) // NaN = missing feature
labels, _ := clf.Predict([][]float64{{1.2, 3.4, math.NaN()}})
fmt.Println(clf.Classes(), proba, labels)

The same call shape as scikit-learn, none of scikit-learn's per-call overhead — and one static binary to deploy.

Install

go get github.com/heru-opensource/go-ml

Requires Go 1.26 or newer. There are no third-party dependencies: the library is standard library only.

Supported models

scikit-learn estimator Go package Documentation
RandomForestClassifier ensemble docs/randomforestclassifier.md
ExtraTreesClassifier ensemble docs/extratreesclassifier.md

Class weighting (class_weight="balanced", sample_weight) needs nothing special: it is applied when scikit-learn fits the model and is already part of what the export carries.

Each model's own documentation covers its usage, fidelity guarantees, tuning and benchmarks. Anything general — loading, embedding, static compilation, the export format — lives here.

Why

  • Faithful. Each model reproduces scikit-learn's prediction path exactly, including its internal float32 cast of the input and its native missing-value (NaN) routing, so Go and Python agree bit-for-bit. Every model is validated against scikit-learn's own outputs (see validation_test.go).
  • Fast. No per-call validation overhead, no GIL, compiled traversal, and goroutine parallelism where it pays off. See Performance.
  • Static. Models live in your binary. Deployments are a single static executable — no Python runtime and no model file to ship or secure at runtime.
  • Generic. Small interfaces (Model, Classifier, Regressor) plus a type registry make adding models straightforward (see Adding a model).

Usage

The API mirrors scikit-learn and is the same for every classifier — program against the goml.Classifier interface. X is a batch of samples (one inner slice per sample); use math.NaN for a missing feature.

Load a model and predict
import (
    goml "github.com/heru-opensource/go-ml"
    _ "github.com/heru-opensource/go-ml/ensemble" // register the models you use
)

clf, err := goml.LoadClassifierFile("model.json")
if err != nil {
    log.Fatal(err)
}

proba, _ := clf.PredictProba([][]float64{{1.2, 3.4, math.NaN()}})
labels, _ := clf.Predict([][]float64{{1.2, 3.4, math.NaN()}})
fmt.Println(clf.Classes(), proba, labels)
Build inputs by name, not by position

Feature order is part of a model. A vector assembled in the wrong order is made of individually valid numbers, so nothing downstream can catch it — the model predicts confidently from nonsense. When the estimator was fitted on a named frame, scikit-learn records feature_names_in_, the export carries it, and goml.Assembler puts values in the right columns for you:

fmt.Println(clf.FeatureNames()) // [sepal_length sepal_width petal_length petal_width]

a, err := goml.NewAssembler(clf) // ErrNoFeatureNames if the export has none
if err != nil {
    log.Fatal(err)
}

row, err := a.Row(map[string]float64{ // any order; a wrong name is an error
    "petal_length": 1.4,
    "sepal_width":  3.5,
    "sepal_length": 5.1,
    // petal_width omitted → math.NaN, the missing-feature convention
})
proba, _ := clf.PredictProba([][]float64{row})

A retrain that reorders or renames features changes the export, so callers that assemble by name keep working and callers that got it wrong hear about it. Names survive static compilation too — go-ml-gen emits them alongside the model.

Embed the model in your binary
import _ "embed"

//go:embed model.json
var modelJSON []byte

var clf, _ = goml.LoadClassifierBytes(modelJSON)
Compile the model into Go source (fastest, no runtime parsing)
go run github.com/heru-opensource/go-ml/cmd/go-ml-gen \
    -pkg models -var Model -o models/model_gen.go model.json
import "your/module/models"

proba, _ := models.Model.PredictProba(X) // models.Model is a package-level var

Every generated file also defines ModelAvailable() bool. When there is no export yet — bootstrapping, a fork that does not ship the artifact, CI on a machine without it — generate a placeholder that declares the same names:

go run github.com/heru-opensource/go-ml/cmd/go-ml-gen \
    -stub -pkg models -var Model -o models/model_gen.go

The var is nil and ModelAvailable() reports false, so the package compiles and callers guard on it. Regenerating from a real export overwrites the file, flips Available to true, and changes nothing at the call sites — no build tags, and no hand-written nil var per project.

Ship several models as one artifact

A deployed model is often not one estimator: a decision may take two or three of them plus the thresholds they were tuned against. Those thresholds are as much a fitted parameter as any split in a tree, and hand-writing them in Go beside the model is what goes stale — the numbers and the trees get updated by different hands, and nothing fails loudly when they disagree. A bundle is one file holding all of it:

# Python, once
from sklexport.export import export_bundle
doc = export_bundle({"screen": screen_clf, "confirm": confirm_clf},
                    {"screen_confidence": 0.9, "confirm_positive": 0.6})
b, err := goml.LoadBundleFile("cascade.json") // or LoadBundleBytes, or compile it in
screen, err := b.Classifier("screen")
threshold, err := b.Float("screen_confidence") // missing key → error, never a zero

Metadata values are read back typed (Float, Int, String, Bool, or Meta into any type). go-ml carries them and hands them back; what a threshold means stays your logic. go-ml-gen compiles a bundle in exactly as it does a single model. See examples/bundle.

See Examples for runnable programs covering each of these.

Examples

Runnable programs, each narrating its own output and exiting. Between them they cover the ways a model reaches production — pick the one that matches your deployment:

go run ./examples/classify   # compiled in as Go source (go-ml-gen)
go run ./examples/serve      # embedded JSON (//go:embed), served over HTTP
go run ./examples/batch      # loaded from a file, scoring a CSV in bulk
go run ./examples/bundle     # several models + tuned thresholds as one artifact
  • examples/classify — two statically compiled models, a random forest and a balanced extra-trees model, predicted through one goml.Classifier interface. Nothing in the program depends on which estimator it holds.

  • examples/serve — the service shape: the model is embedded in the binary, decoded once at startup, and shared by every handler with no pool and no lock. Shows JSON null as a missing feature, a wrong feature count answered as 400 via errors.Is(err, goml.ErrNumFeatures), and concurrent requests agreeing exactly.

  • examples/batch — offline scoring: a model loaded from a file, a CSV in and a CSV out, an empty field as a missing feature, and the whole file passed to a single PredictProba call so the rows can be spread across goroutines. Takes -model, -csv and -workers, so it doubles as a scoring tool for your own export.

  • examples/bundle — one artifact holding two estimators and the thresholds that make them a cascade: a cheap screen, and a slower confirm model consulted only when the screen is unsure. Every tuned number is read from the bundle at startup, so a missing one stops the program instead of silently reading as zero.

The API documentation carries runnable godoc examples as well, including a whole-file one that implements and registers a new estimator type. They run in CI, so they cannot drift from the code.

Exporting a model

tools/sklexport serializes a fitted estimator to the go-ml/v1 format. The format and how to add an estimator type to it are documented there.

python -m sklexport.export your_model.pkl -o model.json

Fidelity and its limits

What "matches scikit-learn" means here, precisely. Items 1–9 are what the test suite pins; items 10–13 are the boundaries of the claim.

  1. float32 narrowing. scikit-learn casts X to float32 before traversal, so every split compares float64(float32(x)) <= threshold with the threshold left at float64. Values within a float32 ULP of a threshold therefore route identically in both languages.
  2. Missing values. A NaN feature does not compare: it follows the node's learned missing_go_to_left flag. The ±Inf thresholds scikit-learn writes for pure missing-value splits round-trip through the export as string sentinels, because JSON cannot spell them.
  3. Per-tree normalization, then the mean. Each tree contributes the L1-normalized class distribution of the leaf reached; the ensemble's probability is the mean over trees — the arithmetic scikit-learn's predict_proba performs, in the same order.
  4. Tie-breaking. Predict takes the arg-max with ties resolved toward the lowest column index, matching numpy.argmax.
  5. Weighting is fit-time. class_weight="balanced" and sample_weight change the fitted leaves, not the prediction arithmetic, so a weighted model needs no special handling — and the shipped extratrees_balanced model is fitted that way so this is tested rather than assumed.
  6. Determinism under parallelism. The single-goroutine and row-parallel paths are bit-for-bit identical to scikit-learn's single-threaded summation; the tree-parallel path sums partial results in a fixed order, so it is deterministic and agrees to floating-point rounding.
  7. Static compilation preserves everything. Generated Go emits every float64 in a form that parses back to identical bits, and it is validated against the same scikit-learn fixtures as the JSON path.
  8. Concurrency. Loaded models are safe for concurrent use by multiple goroutines.
  9. Feature names travel with the model. When scikit-learn recorded feature_names_in_, the export carries it, Model.FeatureNames returns it in column order, and static compilation preserves it — so the input contract is the model's to state rather than the caller's to remember. An export without names is ordinary: FeatureNames returns nil and everything else is unchanged.
  10. Prediction only. No training, no partial_fit, no preprocessing pipelines. Whatever your Python code does to features before predict_proba, your Go code must do too.
  11. Single-output classifiers with numeric labels. n_outputs_ == 1 only, and class labels come across as float64 (scikit-learn's classes_ cast); string or otherwise non-numeric labels are out of scope. No regressor ships yet, though the interfaces and registry are already generic over them.
  12. go-ml is more permissive than scikit-learn about extreme inputs. scikit-learn's check_array rejects ±Inf, and any value that overflows float32 (|x| > ~3.4e38) becomes Inf in that cast and is rejected too. go-ml validates only the feature count — that per-call validation is precisely the cost being avoided — so such inputs get an answer here and an exception there. Within the finite float32 range, the two agree.
  13. Upstream is not pinned. The fixtures in this repository were produced with scikit-learn 1.9. The exporter reads only the public tree_ arrays, which have been stable for a long time, but nothing here can promise that a future scikit-learn predicts the way today's does. Re-validate when you upgrade.

Validating your own model is the same procedure the repo runs on itself: generate reference outputs from your fitted estimator, then assert against them from Go.

cd tools/sklexport
python export.py your_model.pkl -o /path/to/model.json
python make_fixtures.py your_model.pkl -o /path/to/fixtures/

The fixture holds the input rows plus scikit-learn's exact predict_proba and predict for them, including rows with missing values and rows placed a hair either side of real split thresholds. validation_test.go is the handful of Go that compares the two.

Performance

For a single prediction with the model already loaded — the common online-serving case — go-ml is typically two to four orders of magnitude faster than calling scikit-learn, because it pays none of Python's per-call overhead (input validation, dtype conversion, dispatch). On batches it is several times faster per core and then scales further across goroutines.

Benchmarks are model-specific; see each model's documentation for measured figures (e.g. RandomForestClassifier). The reproducible Python comparison harness lives in benchmark/; the Go side is the go-ml-bench command:

go run ./cmd/go-ml-bench -model testdata/models/forest_bench.json

Both measure prediction only, with the model already loaded — an apples-to-apples comparison.

Concurrency

Loaded models are safe for concurrent use by multiple goroutines. Some models parallelize a single prediction call internally; the details (and how to control the worker count) are in each model's documentation.

Adding a model

Top-level changes stay minimal by design. To add an estimator type:

  1. Implement the model in its package behind goml.Classifier (or Regressor) and call goml.Register("EstimatorName", decoder) from an init function.
  2. Add an exporter in tools/sklexport.
  3. Add a documentation file under docs/ and a row to the Supported models table above.

Everything general — the loader, the embed/compile workflow, the export envelope — already works for the new type.

FAQ

Why not call Python from Go? Because the expensive part of a scikit-learn prediction, one sample at a time, is not the tree traversal — it is check_array, dtype conversion and dispatch. Crossing a process or FFI boundary adds to that rather than removing it. Exporting the fitted model deletes the whole layer, along with the Python runtime in your deployment image.

Why a JSON export instead of reading the pickle? A pickle is executable Python bound to the exact library versions that wrote it; it is neither safe nor stable to read from another language. The export is a plain, versioned document holding the fitted arrays — auditable, diffable, and readable by a Go program that has never heard of Python.

Why are class labels float64? scikit-learn's classes_ is numeric for the models supported here, and one numeric type keeps Predict allocation-free and the interfaces free of any. Label-encoding categorical targets is the normal scikit-learn workflow, and mapping the codes back is your program's business.

Does it train models? No, and it is not meant to. Training belongs where the data science happens; this library only makes a fitted model cheap to serve.

How do I know my Go and Python agree? Do not take it on faith — generate a fixture from your own estimator and compare, as described in Fidelity and its limits. That is exactly what CI does for the models in this repository.

What happens when I upgrade scikit-learn? Re-export, regenerate the fixture, re-run the comparison. An export captures an already-fitted tree, so an upgrade that changes training cannot affect it — but one that changed prediction would, and the fixture is what would catch it.

Roadmap

Deliberately out of scope today, and documented rather than half-built:

  • RegressorsRandomForestRegressor / ExtraTreesRegressor are the obvious next step: goml.Regressor and the registry already accommodate them, and a leaf holds the same shape of payload.
  • Gradient boostingHistGradientBoostingClassifier has a different tree representation (binned thresholds, its own missing-value handling), so it needs its own decoder rather than a reuse of tree.
  • Multi-output models — the export format carries n_outputs, and the loaders reject anything but 1 rather than silently mis-predicting.
  • Non-numeric class labels — would mean a label type parameter or a side table in the envelope; deliberately not guessed at yet.
  • Preprocessing pipelines — scalers and encoders are a far larger surface than trees, and a partial implementation would be worse than none.

Documentation

API docs are standard godoc:

go doc ./...                                              # terminal
go run golang.org/x/tools/cmd/godoc@latest -http=:6060    # browser, like pkg.go.dev

Project layout

Path What
. (goml) Interfaces (Model, Classifier, Regressor), the type registry, and the Load* entry points.
tree/ Decision-tree primitive: scikit-learn-exact traversal (float32 cast, NaN routing).
ensemble/ Tree-ensemble models (RandomForestClassifier, ExtraTreesClassifier) over one shared prediction path.
cmd/go-ml-gen/ Generates Go source from an export (static compilation).
cmd/go-ml-bench/ Benchmarks prediction for any model file.
tools/sklexport/ Python exporter, model trainer, and test-fixture generator.
benchmark/ Standalone scikit-learn benchmark harness (its own venv).
docs/ Per-model documentation.
examples/ Runnable examples: static compilation, an HTTP service, CSV batch scoring, a model bundle.
internal/jsonx/ Tolerant float decoding (handles ±Inf/NaN sentinels).
testdata/ Exported models and scikit-learn reference outputs used by the tests.

Testing

make test      # all tests, incl. bit-exact validation against scikit-learn outputs
make race      # the same suite under the race detector, as CI runs it
make lint      # golangci-lint v2, same configuration as CI
make examples  # run every example program
make regen     # retrain models + rebuild fixtures and generated code (needs the venv)

The models and fixtures under testdata/ are trained from scratch on scikit-learn's Iris dataset and synthetic make_classification datasets, one of them deliberately imbalanced — no external data — so the corpus is fully self-contained and reproducible with make regen.

CI runs the suite with -race on Linux and macOS, repeats it to shake out flakes, executes the godoc examples and every example program, and checks that the committed generated artifacts are exactly what make gen produces today. Python is deliberately not in CI: the models and fixtures are committed artifacts, and regenerating them requires one specific scikit-learn build.

Releasing

A Go module is published by pushing a semver tag — there is no registry to upload to. The module proxy fetches the tag from this repository on demand, and pkg.go.dev indexes it from there.

# 1. land the release notes FIRST — a `## [0.1.0]` heading in CHANGELOG.md is a
#    hard requirement, and the tag cannot be reused if you forget it. Push that
#    to main.
# 2. then tag the commit CI is green on:
git tag v0.1.0
git push origin v0.1.0

Pushing the tag triggers release.yml, which re-runs every CI gate against the tagged commit, checks the tag is one Go can actually consume and that the version is documented, cuts a GitHub Release with generated notes, and asks proxy.golang.org for the version so pkg.go.dev indexes it promptly instead of waiting for someone's first go get.

Tags are effectively immutable. Once the proxy has fetched a version it caches it permanently; moving or deleting the tag does not un-publish anything, and consumers may already have the old bytes in their go.sum. A bad release is fixed by cutting the next patch version, never by retagging. That is why the workflow's pre-flight checks fail loudly rather than trying to paper over anything:

Check Why it is fatal
strict semver (vMAJOR.MINOR.PATCH[-pre]) Go silently ignores tags it cannot parse, so a typo is a release that never appears
major version agrees with the module path v2.0.0 needs the module path to end in /v2, or nobody can import it
no replace directives in go.mod replace does not apply to consumers, so the module would not resolve for them
## [VERSION] heading in CHANGELOG.md every released version must be documented; an undocumented release is not a release

Because the changelog check is a hard gate, write the entry before you tag. If a tag does fail this check, no GitHub Release is written — add the entry on the default branch and cut the next patch version.

Everything here is one module, including the examples and the testdata/ corpus, so a consumer gets a library that is verifiable from the tag alone. The Python tooling under tools/ ships with it but is not on any Go import path.

License

MIT — see LICENSE.

Documentation

Overview

Package goml runs trained machine-learning models in pure Go, with output that matches the original scikit-learn estimator.

Caution: this library is fully AI-generated

The implementation, tests, examples and documentation were all written by an AI agent.

Every shipped model is checked against scikit-learn's own outputs by the test suite in the repository, but the only real-world validation is against production workloads specific to Heru, Inc. Anything those workloads do not exercise — other estimator types, other hyper-parameters, other scikit-learn versions, other input regimes — is unproven, and exactness against a moving upstream is exactly where that gap is most likely to hurt. Use it with caution: read the code, validate your own exported model against your own scikit-learn outputs, and treat the fidelity claims below as claims to verify rather than as guarantees.

Goals

The package is built around three ideas:

  • Static, not dynamic. A model is exported once from Python (see tools/sklexport) into a portable go-ml/v1 JSON document. That document is either loaded at startup or, for maximum speed and zero runtime dependencies, compiled directly into your binary as Go source with the go-ml-gen tool (see cmd/go-ml-gen). There is no Python, no pickle, and no model file at runtime.
  • Generic. Models are values behind small interfaces (Model, Classifier, Regressor). New estimator types register a decoder with Register and are then usable through the same Load entry point, mirroring how image.RegisterFormat or database/sql drivers work. The models that ship today are RandomForestClassifier and ExtraTreesClassifier, both in package github.com/heru-opensource/go-ml/ensemble.
  • Faithful. Each model reproduces scikit-learn's prediction path exactly, down to its internal float32 cast and missing-value handling, so Go and Python agree to within floating-point rounding.

Loading a model

Import the package that implements your model type for its side-effect registration, then load by type from the export envelope:

import (
	goml "github.com/heru-opensource/go-ml"
	_ "github.com/heru-opensource/go-ml/ensemble" // registers the forest models
)

clf, err := goml.LoadClassifierFile("model.json")
if err != nil { ... }
proba, err := clf.PredictProba([][]float64{{1, 2, 3, ...}})

The API mirrors scikit-learn: Classifier.PredictProba returns per-class probabilities in Classifier.Classes order, and Classifier.Predict returns the arg-max class label.

Missing features

Tree models handle missing values natively. Pass math.NaN for an absent feature; it is routed exactly as scikit-learn would route it.

Bundles

A deployed model is often not one estimator: a decision may take two or three of them plus the thresholds they were tuned against, and those thresholds are as much a fitted parameter as any split in a tree. LoadBundle reads a go-ml/bundle-v1 document holding all of it, Bundle.Classifier returns a member by name, and the metadata accessors return the tuned values typed:

b, err := goml.LoadBundleFile("cascade.json")
screen, err := b.Classifier("screen")
threshold, err := b.Float("screen_confidence") // missing key: an error, not a zero

go-ml carries metadata and hands it back; it does not interpret it.

Feature names

The order of a feature vector is part of a model, and a vector assembled in the wrong order is made of individually valid numbers — no validation can catch it. When scikit-learn recorded feature_names_in_ (which it does for an estimator fitted on a named frame), the export carries those names, Model.FeatureNames returns them in column order, and Assembler builds inputs from a name-keyed map:

a, err := goml.NewAssembler(clf)   // ErrNoFeatureNames if the export has none
row, err := a.Row(map[string]float64{"petal_length": 1.4, "sepal_width": 3.5})

An unknown name is an error; a name the model expects but the map omits is a missing feature. A retrain that reorders or renames features then changes the export rather than silently breaking its callers.

Example

Example loads an exported model and predicts with it. The API mirrors scikit-learn: PredictProba returns per-class probabilities in Classes order, and Predict returns the arg-max label.

package main

import (
	"fmt"
	"log"

	goml "github.com/heru-opensource/go-ml"
	_ "github.com/heru-opensource/go-ml/ensemble"
)

// miniExport is a one-tree RandomForestClassifier over a single feature: class
// 0 when feature 0 is <= 0.5, class 1 above it. Real exports are produced from
// a fitted scikit-learn estimator by tools/sklexport; this one is small enough
// to read inline.
const miniExport = `{
  "format": "go-ml/v1",
  "type": "RandomForestClassifier",
  "model": {
    "n_features": 1, "n_outputs": 1, "classes": [0, 1],
    "trees": [{
      "node_count": 3, "value_width": 2,
      "left": [1, -1, -1], "right": [2, -1, -1], "feature": [0, -2, -2],
      "threshold": [0.5, -2, -2], "missing_left": [false, false, false],
      "value": [0.5, 0.5, 1, 0, 0, 1]
    }]
  }
}`

func main() {
	clf, err := goml.LoadClassifierBytes([]byte(miniExport))
	if err != nil {
		log.Fatal(err)
	}

	X := [][]float64{{0.2}, {0.9}}
	proba, err := clf.PredictProba(X)
	if err != nil {
		log.Fatal(err)
	}
	labels, _ := clf.Predict(X)

	fmt.Println(clf.Type(), clf.NFeatures(), clf.Classes())
	fmt.Printf("proba = %.1f\n", proba)
	fmt.Printf("labels = %v\n", labels)
}
Output:
RandomForestClassifier 1 [0 1]
proba = [[1.0 0.0] [0.0 1.0]]
labels = [0 1]
Example (CustomModel)

Example_customModel loads an export of the type registered above. Nothing in the loading code knows about dummyClassifier: goml.Load dispatches on the envelope's "type", exactly as image.Decode dispatches on a magic number.

package main

// This file is one whole-file example: how a new scikit-learn estimator type
// plugs into go-ml. It implements sklearn.dummy.DummyClassifier's "prior"
// strategy — the baseline that ignores its input and always returns the class
// distribution it was fitted on — because that keeps the prediction path down
// to a few lines. A real model does more arithmetic, and nothing else changes.
//
// go-ml does not ship a DummyClassifier; the ensemble package registers its
// forests exactly this way.

import (
	"encoding/json"
	"fmt"
	"log"

	goml "github.com/heru-opensource/go-ml"
)

// dummyClassifier predicts fixed class priors, whatever the input.
type dummyClassifier struct {
	nFeatures    int
	classes      []float64
	featureNames []string
	prior        []float64
}

func (d *dummyClassifier) Type() string       { return "DummyClassifier" }
func (d *dummyClassifier) NFeatures() int     { return d.nFeatures }
func (d *dummyClassifier) Classes() []float64 { return append([]float64(nil), d.classes...) }

// FeatureNames completes goml.Model. Returning nil is allowed and means the
// export carried no names; passing them through, as here, is what lets callers
// build inputs with a goml.Assembler instead of by position.
func (d *dummyClassifier) FeatureNames() []string {
	return append([]string(nil), d.featureNames...)
}

// PredictProba returns the prior for every sample. Validating the feature count
// (and nothing else) is the convention every model here follows.
func (d *dummyClassifier) PredictProba(X [][]float64) ([][]float64, error) {
	out := make([][]float64, len(X))
	for i, x := range X {
		if len(x) != d.nFeatures {
			return nil, fmt.Errorf("%w: sample %d has %d, want %d",
				goml.ErrNumFeatures, i, len(x), d.nFeatures)
		}
		out[i] = append([]float64(nil), d.prior...)
	}
	return out, nil
}

func (d *dummyClassifier) Predict(X [][]float64) ([]float64, error) {
	proba, err := d.PredictProba(X)
	if err != nil {
		return nil, err
	}
	out := make([]float64, len(proba))
	for i, p := range proba {
		best := 0
		for c := range p {
			if p[c] > p[best] {
				best = c
			}
		}
		out[i] = d.classes[best]
	}
	return out, nil
}

// decodeDummy builds the model from the type-specific "model" object of an
// export envelope. The exporter on the Python side (see tools/sklexport) writes
// the matching JSON.
func decodeDummy(raw json.RawMessage) (goml.Model, error) {
	var j struct {
		NFeatures    int       `json:"n_features"`
		Classes      []float64 `json:"classes"`
		FeatureNames []string  `json:"feature_names"`
		Prior        []float64 `json:"prior"`
	}
	if err := json.Unmarshal(raw, &j); err != nil {
		return nil, err
	}
	if len(j.Classes) != len(j.Prior) {
		return nil, fmt.Errorf("dummy: %d classes but %d priors", len(j.Classes), len(j.Prior))
	}
	if n := len(j.FeatureNames); n > 0 && n != j.NFeatures {
		return nil, fmt.Errorf("dummy: %d feature names for %d features", n, j.NFeatures)
	}
	return &dummyClassifier{
		nFeatures:    j.NFeatures,
		classes:      j.Classes,
		featureNames: j.FeatureNames,
		prior:        j.Prior,
	}, nil
}

// Registration is global and permanent, so it belongs in an init function —
// which is why importing a model package for its side effect is all a caller
// has to do.
func init() {
	goml.Register("DummyClassifier", decodeDummy)
}

// Example_customModel loads an export of the type registered above. Nothing in
// the loading code knows about dummyClassifier: goml.Load dispatches on the
// envelope's "type", exactly as image.Decode dispatches on a magic number.
func main() {
	const export = `{
	  "format": "go-ml/v1",
	  "type": "DummyClassifier",
	  "model": {
	    "n_features": 4, "classes": [0, 1, 2], "prior": [0.6, 0.3, 0.1],
	    "feature_names": ["sepal_length", "sepal_width", "petal_length", "petal_width"]
	  }
	}`

	clf, err := goml.LoadClassifierBytes([]byte(export))
	if err != nil {
		log.Fatal(err)
	}

	proba, _ := clf.PredictProba([][]float64{{5.1, 3.5, 1.4, 0.2}})
	labels, _ := clf.Predict([][]float64{{5.1, 3.5, 1.4, 0.2}})

	fmt.Println(clf.Type(), clf.NFeatures(), clf.Classes())
	fmt.Println(clf.FeatureNames())
	fmt.Printf("proba = %.1f  label = %v\n", proba[0], labels[0])
}
Output:
DummyClassifier 4 [0 1 2]
[sepal_length sepal_width petal_length petal_width]
proba = [0.6 0.3 0.1]  label = 0
Example (Errors)

Example_errors shows the two failures worth handling explicitly. Both are sentinel errors, so test them with errors.Is rather than by string.

package main

import (
	"errors"
	"fmt"

	goml "github.com/heru-opensource/go-ml"
	_ "github.com/heru-opensource/go-ml/ensemble"
)

// miniExport is a one-tree RandomForestClassifier over a single feature: class
// 0 when feature 0 is <= 0.5, class 1 above it. Real exports are produced from
// a fitted scikit-learn estimator by tools/sklexport; this one is small enough
// to read inline.
const miniExport = `{
  "format": "go-ml/v1",
  "type": "RandomForestClassifier",
  "model": {
    "n_features": 1, "n_outputs": 1, "classes": [0, 1],
    "trees": [{
      "node_count": 3, "value_width": 2,
      "left": [1, -1, -1], "right": [2, -1, -1], "feature": [0, -2, -2],
      "threshold": [0.5, -2, -2], "missing_left": [false, false, false],
      "value": [0.5, 0.5, 1, 0, 0, 1]
    }]
  }
}`

func main() {
	// An estimator type nobody registered — usually a missing import of the
	// package that implements it.
	_, err := goml.LoadBytes([]byte(`{"format":"go-ml/v1","type":"SomeFutureModel","model":{}}`))
	fmt.Println("unknown type:", errors.Is(err, goml.ErrUnknownType))

	// A sample whose length does not match the model. This is the only
	// per-call validation go-ml does.
	clf, _ := goml.LoadClassifierBytes([]byte(miniExport))
	_, err = clf.PredictProba([][]float64{{1.0, 2.0}})
	fmt.Println("wrong feature count:", errors.Is(err, goml.ErrNumFeatures))
}
Output:
unknown type: true
wrong feature count: true
Example (MissingFeatures)

Example_missingFeatures shows the missing-value convention: an absent feature is math.NaN, and the trees route it the way scikit-learn learned to during fitting rather than erroring or imputing. Here the root's missing direction is right, which is the class-1 leaf.

package main

import (
	"fmt"
	"log"
	"math"

	goml "github.com/heru-opensource/go-ml"
	_ "github.com/heru-opensource/go-ml/ensemble"
)

// miniExport is a one-tree RandomForestClassifier over a single feature: class
// 0 when feature 0 is <= 0.5, class 1 above it. Real exports are produced from
// a fitted scikit-learn estimator by tools/sklexport; this one is small enough
// to read inline.
const miniExport = `{
  "format": "go-ml/v1",
  "type": "RandomForestClassifier",
  "model": {
    "n_features": 1, "n_outputs": 1, "classes": [0, 1],
    "trees": [{
      "node_count": 3, "value_width": 2,
      "left": [1, -1, -1], "right": [2, -1, -1], "feature": [0, -2, -2],
      "threshold": [0.5, -2, -2], "missing_left": [false, false, false],
      "value": [0.5, 0.5, 1, 0, 0, 1]
    }]
  }
}`

func main() {
	clf, err := goml.LoadClassifierBytes([]byte(miniExport))
	if err != nil {
		log.Fatal(err)
	}

	proba, err := clf.PredictProba([][]float64{{math.NaN()}})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%.1f\n", proba[0])
}
Output:
[0.0 1.0]

Index

Examples

Constants

View Source
const BundleFormat = "go-ml/bundle-v1"

BundleFormat is the envelope version LoadBundle reads.

View Source
const Format = "go-ml/v1"

Format is the export envelope version this package reads.

Variables

View Source
var (
	// ErrUnknownModel is returned by [Bundle.Model] and [Bundle.Classifier] for
	// a name the bundle does not contain.
	ErrUnknownModel = errors.New("goml: bundle has no such model")
	// ErrUnknownMeta is returned by the [Bundle] metadata accessors for a key
	// the bundle does not carry.
	ErrUnknownMeta = errors.New("goml: bundle has no such metadata key")
)

Errors returned by the bundle loaders and accessors. Use errors.Is to test.

View Source
var (
	// ErrFormat is returned when the export envelope has an unsupported format.
	ErrFormat = errors.New("goml: unsupported export format")
	// ErrUnknownType is returned by Load when no decoder is registered for the
	// model's type. Importing the package that implements the model (for its
	// side-effect Register call) fixes this.
	ErrUnknownType = errors.New("goml: unknown model type")
	// ErrNotClassifier is returned by the classifier loaders when the loaded
	// model does not implement Classifier.
	ErrNotClassifier = errors.New("goml: model is not a Classifier")
	// ErrNumFeatures is returned by a model's predict methods when an input
	// sample has the wrong number of features.
	ErrNumFeatures = errors.New("goml: wrong number of features")
)

Errors returned by the loading functions. Use errors.Is to test for them.

View Source
var ErrNoFeatureNames = errors.New("goml: model carries no feature names")

ErrNoFeatureNames is returned by NewAssembler when the model does not know its feature names — scikit-learn records them only for an estimator fitted on a named frame. Use errors.Is to test for it.

View Source
var ErrUnknownFeature = errors.New("goml: unknown feature")

ErrUnknownFeature is returned by Assembler.Row for a name the model does not have. Use errors.Is to test for it.

Functions

func Register

func Register(typeName string, dec Decoder)

Register installs a decoder for a model type (the value of the envelope's "type" field, e.g. "RandomForestClassifier"). It is normally called from a model package's init function. Register panics if dec is nil or if typeName is already registered, mirroring image.RegisterFormat and sql.Register.

func RegisteredTypes

func RegisteredTypes() []string

RegisteredTypes returns the sorted list of model types that have a decoder.

Types

type Assembler added in v0.2.0

type Assembler struct {
	// contains filtered or unexported fields
}

An Assembler builds feature vectors in a model's own column order.

It exists because order is part of a model: a vector assembled in the wrong order is made entirely of individually valid numbers, so no amount of validation downstream can catch it — the model simply predicts confidently from nonsense. Passing names instead of positions moves that failure from silent to impossible, and it survives a retrain that reorders or renames features, because the names travel with the export.

An Assembler is read-only after construction and safe for concurrent use.

func NewAssembler added in v0.2.0

func NewAssembler(m Model) (*Assembler, error)

NewAssembler returns an Assembler for m's feature names, or ErrNoFeatureNames if the export carried none. It also rejects duplicate names, which would make a name ambiguous.

Example

ExampleNewAssembler builds inputs by name instead of by position.

Feature order is part of a model, and a vector in the wrong order is made of individually valid numbers — nothing downstream can catch it. When the export carries names (scikit-learn records them for an estimator fitted on a named frame), assembling by name makes that mistake impossible, and a retrain that reorders columns changes the export rather than silently breaking callers.

package main

import (
	"errors"
	"fmt"
	"log"

	goml "github.com/heru-opensource/go-ml"
	_ "github.com/heru-opensource/go-ml/ensemble"
)

func main() {
	clf, err := goml.LoadClassifierFile("testdata/models/iris.json")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(clf.FeatureNames())

	a, err := goml.NewAssembler(clf)
	if err != nil {
		log.Fatal(err) // the export carries no names
	}

	// Order here is deliberately not the model's, and petal_width is absent —
	// an omitted feature is a missing one (NaN), which the trees route natively.
	row, err := a.Row(map[string]float64{
		"petal_length": 1.4,
		"sepal_width":  3.5,
		"sepal_length": 5.1,
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(a.Missing(map[string]float64{"petal_length": 1.4, "sepal_width": 3.5, "sepal_length": 5.1}))

	label, _ := clf.Predict([][]float64{row})
	fmt.Printf("class %g\n", label[0])

	// A name the model does not have is an error, not a silently dropped value.
	_, err = a.Row(map[string]float64{"petal_len": 1.4})
	fmt.Println(errors.Is(err, goml.ErrUnknownFeature))
}
Output:
[sepal_length sepal_width petal_length petal_width]
[petal_width]
class 0
true

func (*Assembler) Missing added in v0.2.0

func (a *Assembler) Missing(values map[string]float64) []string

Missing returns the names the model expects that values does not supply, in column order. Assembler.Row treats these as missing features; call this first when a missing input should be rejected instead, or logged.

func (*Assembler) Names added in v0.2.0

func (a *Assembler) Names() []string

Names returns a copy of the feature names, in column order.

func (*Assembler) Row added in v0.2.0

func (a *Assembler) Row(values map[string]float64) ([]float64, error)

Row builds one feature vector from named values.

A name the model does not have is an error wrapping ErrUnknownFeature — a typo or a stale caller is exactly what this type exists to catch. A name the model does have but values omits is math.NaN, the package's missing-feature convention, which tree models route natively.

func (*Assembler) Rows added in v0.2.0

func (a *Assembler) Rows(values []map[string]float64) ([][]float64, error)

Rows is Row for a batch, and reports which sample failed.

type Bundle added in v0.2.0

type Bundle struct {
	// contains filtered or unexported fields
}

A Bundle is several named models shipped as one artifact, together with the scalars that were tuned alongside them.

It exists because a deployed model is often not one estimator. A decision may need two or three of them plus the thresholds they were tuned against — and those thresholds are as much a fitted parameter as any split in a tree. Keeping them in hand-written Go beside the model is what goes stale: the numbers and the trees are updated by different hands, at different times, and nothing fails until the predictions are quietly wrong. A bundle makes them one file, versioned and deployed together.

What a bundle deliberately does not do is interpret its metadata. go-ml carries the numbers and hands them back typed; what a threshold means is the caller's own logic, and a missing key is a loud error rather than a zero.

A Bundle is read-only after loading and safe for concurrent use.

func LoadBundle added in v0.2.0

func LoadBundle(r io.Reader) (*Bundle, error)

LoadBundle reads a go-ml/bundle-v1 document from r and decodes every model in it, dispatching each on its own "type" exactly as Load does.

func LoadBundleBytes added in v0.2.0

func LoadBundleBytes(data []byte) (*Bundle, error)

LoadBundleBytes is LoadBundle on an in-memory document, for pairing with //go:embed.

Example

ExampleLoadBundleBytes loads several models shipped as one artifact, together with the scalars tuned alongside them.

A deployed model is often not one estimator: a decision may take two of them plus the thresholds they were tuned against, and those thresholds are as much a fitted parameter as any split in a tree. Keeping them in hand-written code beside the model is what goes stale — so a bundle carries them, and a key that is not there is an error rather than a zero.

b, err := goml.LoadBundleBytes([]byte(miniBundle))
if err != nil {
	log.Fatal(err)
}
fmt.Println("models:  ", b.Names())
fmt.Println("metadata:", b.MetaKeys())

screen, err := b.Classifier("screen")
if err != nil {
	log.Fatal(err)
}
threshold, err := b.Float("threshold")
if err != nil {
	log.Fatal(err)
}

proba, _ := screen.PredictProba([][]float64{{0.9}})
fmt.Printf("p=%.2f >= %.2f: %v\n", proba[0][1], threshold, proba[0][1] >= threshold)

_, err = b.Float("threshold_v2")
fmt.Println("missing key is an error:", errors.Is(err, goml.ErrUnknownMeta))
Output:
models:   [confirm screen]
metadata: [cutoffs enabled max_stages threshold tuned_for unbounded]
p=1.00 >= 0.83: true
missing key is an error: true

func LoadBundleFile added in v0.2.0

func LoadBundleFile(path string) (*Bundle, error)

LoadBundleFile loads a bundle from a file.

func NewBundle added in v0.2.0

func NewBundle(models map[string]Model, metadata map[string]json.RawMessage) (*Bundle, error)

NewBundle assembles a bundle from already-built models and raw JSON metadata values. This is the constructor statically generated code calls (see cmd/go-ml-gen); most callers load a bundle instead.

func (*Bundle) Bool added in v0.2.0

func (b *Bundle) Bool(key string) (bool, error)

Bool returns a boolean metadata value.

func (*Bundle) Classifier added in v0.2.0

func (b *Bundle) Classifier(name string) (Classifier, error)

Classifier is Bundle.Model followed by a Classifier assertion.

func (*Bundle) Float added in v0.2.0

func (b *Bundle) Float(key string) (float64, error)

Float returns a numeric metadata value. The non-finite sentinels the export format uses ("Infinity", "NaN") decode here too.

func (*Bundle) Int added in v0.2.0

func (b *Bundle) Int(key string) (int, error)

Int returns an integer metadata value, rejecting a number with a fractional part rather than truncating it.

func (*Bundle) Meta added in v0.2.0

func (b *Bundle) Meta(key string, v any) error

Meta decodes a metadata value into v, for anything the typed accessors do not cover — a list of thresholds, say, or a small object.

func (*Bundle) MetaKeys added in v0.2.0

func (b *Bundle) MetaKeys() []string

MetaKeys returns the metadata keys, sorted.

func (*Bundle) Model added in v0.2.0

func (b *Bundle) Model(name string) (Model, error)

Model returns the named model, or an error wrapping ErrUnknownModel listing what the bundle does hold. Type-assert the result for a concrete model's own methods.

func (*Bundle) Names added in v0.2.0

func (b *Bundle) Names() []string

Names returns the model names, sorted.

func (*Bundle) String added in v0.2.0

func (b *Bundle) String(key string) (string, error)

String returns a string metadata value.

type Classifier

type Classifier interface {
	Model

	// Classes returns the class labels, in the column order used by
	// PredictProba. The returned slice is a copy and may be modified freely.
	Classes() []float64

	// PredictProba returns, for each input sample, a vector of class
	// probabilities aligned with Classes.
	PredictProba(X [][]float64) ([][]float64, error)

	// Predict returns, for each input sample, the label of the most probable
	// class (ties resolved toward the lowest column index, as in scikit-learn).
	Predict(X [][]float64) ([]float64, error)
}

Classifier predicts class membership, mirroring scikit-learn's classifier API. In every method X is a batch of samples (one inner slice per sample, each of length Model.NFeatures); a missing feature is encoded as math.NaN.

func LoadClassifier

func LoadClassifier(r io.Reader) (Classifier, error)

LoadClassifier is Load followed by an assertion that the model classifies.

func LoadClassifierBytes

func LoadClassifierBytes(data []byte) (Classifier, error)

LoadClassifierBytes is LoadBytes followed by a Classifier assertion.

func LoadClassifierFile

func LoadClassifierFile(path string) (Classifier, error)

LoadClassifierFile is LoadFile followed by a Classifier assertion.

Example

ExampleLoadClassifierFile reads an export from disk — the startup-time alternative to embedding it. The model here is the repository's own Iris forest; the two samples are a typical setosa and a typical virginica.

package main

import (
	"fmt"
	"log"

	goml "github.com/heru-opensource/go-ml"
	_ "github.com/heru-opensource/go-ml/ensemble"
)

func main() {
	clf, err := goml.LoadClassifierFile("testdata/models/iris.json")
	if err != nil {
		log.Fatal(err)
	}

	proba, _ := clf.PredictProba([][]float64{
		{5.1, 3.5, 1.4, 0.2},
		{6.7, 3.0, 5.2, 2.3},
	})
	labels, _ := clf.Predict([][]float64{
		{5.1, 3.5, 1.4, 0.2},
		{6.7, 3.0, 5.2, 2.3},
	})

	for i := range labels {
		fmt.Printf("class %g  proba %.3f\n", labels[i], proba[i])
	}
}
Output:
class 0  proba [1.000 0.000 0.000]
class 2  proba [0.000 0.003 0.998]

type Decoder

type Decoder func(model json.RawMessage) (Model, error)

A Decoder builds a Model from the type-specific "model" object of an export envelope. Model implementations register one with Register.

type Model

type Model interface {
	// Type returns the scikit-learn estimator class name, e.g.
	// "RandomForestClassifier". It matches the "type" field of the export.
	Type() string
	// NFeatures returns the number of input features each sample must have.
	NFeatures() int
	// FeatureNames returns the input feature names in the column order the
	// model expects, or nil when the export carries none — scikit-learn only
	// records them (as feature_names_in_) for an estimator fitted on a named
	// frame, so nil is ordinary and not an error. When they are present, build
	// inputs with an [Assembler] rather than by hand: a feature vector assembled
	// in the wrong order is the one mistake this package cannot catch for you,
	// because every value is individually valid. The returned slice is a copy.
	FeatureNames() []string
}

Model is the behavior common to every model: it knows its scikit-learn estimator type name and how many input features it expects. Concrete models additionally implement a task interface such as Classifier or Regressor.

func Load

func Load(r io.Reader) (Model, error)

Load reads a go-ml/v1 export from r and constructs the model, dispatching on the envelope's "type" to the decoder registered for it.

func LoadBytes

func LoadBytes(data []byte) (Model, error)

LoadBytes is Load on an in-memory export. It is the basis of statically embedded models: pair it with //go:embed to link the export into the binary.

func LoadFile

func LoadFile(path string) (Model, error)

LoadFile loads a model from a file containing a go-ml/v1 export.

type Regressor

type Regressor interface {
	Model

	// Predict returns one predicted target value per input sample.
	Predict(X [][]float64) ([]float64, error)
}

Regressor predicts continuous targets. It is defined so the generic Load entry point and registry can serve regression models as they are added; no regressor ships in this package yet.

Directories

Path Synopsis
cmd
go-ml-bench command
Command go-ml-bench measures prediction performance of a go-ml model, as a compiled binary, so the numbers are directly comparable to a scikit-learn timing of the same model (see tools/sklexport/bench_compare.py).
Command go-ml-bench measures prediction performance of a go-ml model, as a compiled binary, so the numbers are directly comparable to a scikit-learn timing of the same model (see tools/sklexport/bench_compare.py).
go-ml-gen command
Command go-ml-gen compiles a go-ml/v1 model export into Go source, so the model is linked statically into your binary with no runtime file or parsing.
Command go-ml-gen compiles a go-ml/v1 model export into Go source, so the model is linked statically into your binary with no runtime file or parsing.
Package ensemble implements tree-ensemble models: static, parallel reimplementations of the prediction path of scikit-learn's forests.
Package ensemble implements tree-ensemble models: static, parallel reimplementations of the prediction path of scikit-learn's forests.
examples
batch command
Command batch is a runnable example of offline scoring: read a CSV, hand the whole thing to the model in one call, write a CSV back.
Command batch is a runnable example of offline scoring: read a CSV, hand the whole thing to the model in one call, write a CSV back.
bundle command
Command bundle is a runnable example of a model that is not one estimator.
Command bundle is a runnable example of a model that is not one estimator.
classify command
Command classify is a runnable example of using go-ml models that have been compiled statically into the binary with go-ml-gen — there is no Python, no pickle, and no model file at runtime.
Command classify is a runnable example of using go-ml models that have been compiled statically into the binary with go-ml-gen — there is no Python, no pickle, and no model file at runtime.
serve command
Command serve is a runnable example of go-ml in the shape a service uses it: the model is embedded in the binary with //go:embed, decoded once at startup, and then served concurrently — no Python, no model file, nothing to fetch at runtime.
Command serve is a runnable example of go-ml in the shape a service uses it: the model is embedded in the binary with //go:embed, decoded once at startup, and then served concurrently — no Python, no model file, nothing to fetch at runtime.
internal
jsonx
Package jsonx holds small JSON helpers shared across go-ml packages.
Package jsonx holds small JSON helpers shared across go-ml packages.
Package tree implements binary decision trees whose evaluation is bit-for-bit compatible with scikit-learn's sklearn.tree._tree.Tree.
Package tree implements binary decision trees whose evaluation is bit-for-bit compatible with scikit-learn's sklearn.tree._tree.Tree.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL