santoku
santoku

santoku

A Lua framework for building applications end to end

Lua 5.1 from the build system to the server to the browser. Every example on this site runs in your browser. Edit one and run it.


santoku targets Lua 5.1, because 5.1 is the version its platforms actually share. LuaJIT implements 5.1 and OpenResty embeds LuaJIT, so server code, client code and the build harness all run the same language. Compiled to WebAssembly, the 5.1 core is smaller to embed, and its single number type sidesteps the integer and float split introduced in 5.3, which keeps numeric values crossing the JavaScript boundary simple. Support for newer versions is meant to come from compatibility shims layered over the 5.1 core rather than version branches in the tree; contributions and design ideas for that layer are welcome.

santoku-learn

View on LuaRocks

machine learning toolkit: n-gram tokenization, gazetteer matching, booleanization, spectral embeddings, ridge regression, and calibrated decisions

santoku-learn is the machine learning toolkit of the framework: C modules built on santoku-matrix types (csr sparse features, mtx dense codes, spans) covering n-gram tokenization, Aho-Corasick gazetteer matching, booleanization, spectral Nystrom embeddings, ridge regression, and calibrated decisions, with optimize.krr tying the whole modelling path together. santoku.learn.retrieval ranks bm25 candidates and reranks them with dense codes, and optimize.lexical and optimize.retrieval tune its parameters. Binary-code search uses santoku-matrix directly: spectral codes, mtx:itq, and Hamming top-k. The front half of that pipeline runs live on this page: tokenizer, aho, booleanizer, and decide ship in the browser bundle as WASM builds, so their examples are runnable right here. The back half (spectral, ridge, optimize, retrieval, and the file-fed dataset, util, and bundle helpers) is shown for reading: it depends on BLAS/LAPACK or on training corpora read from disk. The full pipelines those examples distill live in the rock's regression suites (eurlex, newsgroups, imdb, mnist, housing, adult, scifact, and the CoNLL pair), under test/spec/santoku/learn/regress in its source tarball.

santoku.learn.tokenizer (raw)

One-shot n-gram hashing: text straight to sparse count features, no vocabulary to fit, stable column ids for the same n-gram across documents.

local tokenizer = require("santoku.learn.tokenizer") local off, tok, val = tokenizer.tokenize_raw({ texts = { "hello", "hello world" }, n_samples = 2, ngram_min = 3, ngram_max = 3, normalize = true, }) print("docs:", off:size() - 1) print("ngrams in doc 1:", off:get(1) - off:get(0)) print("ngrams in doc 2:", off:get(2) - off:get(1)) print("total ngram count:", val:sum()) local _, tok2 = tokenizer.tokenize_raw({ texts = { "abc", "abc" }, n_samples = 2, ngram_min = 3, ngram_max = 3, }) print("shared column id:", tok2:get(0) == tok2:get(1)) local _, tok3, val3 = tokenizer.tokenize_raw({ texts = { "ababab" }, n_samples = 1, ngram_min = 2, ngram_max = 2, }) print("distinct bigrams:", tok3:size()) print("most frequent count:", val3:max()) return tok:size()

santoku.learn.tokenizer (fitted)

Fit a vocabulary on training text, then tokenize new text against the frozen vocabulary: unseen n-grams do not grow it, so train and serve agree on columns.

local tokenizer = require("santoku.learn.tokenizer") local tok = tokenizer.create({ ngram_min = 3, ngram_max = 3, normalize = true }) local X = tok:fit({ texts = { "hello", "world" } }) print("training rows:", (X:shape())) print("nnz:", X:nnz()) print("vocab size:", tok:n_tokens()) local X2 = tok:tokenize({ texts = { "brand new text" } }) print("new rows:", (X2:shape())) print("vocab still:", tok:n_tokens()) return tok:n_tokens()

tokenizer.extract: regex to spans

Run a compiled regex program over a batch of texts and get token spans back as three ivecs (per-doc offsets, starts, ends), ready to wrap in a spans set. A pattern that fails to match cleanly raises "extract: pattern match failed (re status N)" instead of dropping tokens.

local tokenizer = require("santoku.learn.tokenizer") local re = require("santoku.re") local spans = require("santoku.spans") local arr = require("santoku.array") local texts = { "The quick brown fox", "jumps over 42 dogs" } local prog = re.prog("[A-Za-z0-9]+") local off, s, e = tokenizer.extract({ n = 2, texts = texts, pattern = prog }) for d = 1, 2 do local words = {} for j = off:get(d - 1), off:get(d) - 1 do words[#words + 1] = texts[d]:sub(s:get(j) + 1, e:get(j)) end print("doc " .. d .. ":", arr.concat(words, "|")) end local T = spans.create({ offsets = off, s = s, e = e }) return T:col("s"):size()

word n-grams over extracted tokens

Feed extracted word spans into a flat-mode tokenizer: n-grams range over the word sequence instead of raw bytes, and a frozen tokenize reproduces the fit exactly.

local tokenizer = require("santoku.learn.tokenizer") local re = require("santoku.re") local spans = require("santoku.spans") local texts = { "the quick brown fox", "the lazy dog sleeps" } local prog = re.prog("[A-Za-z0-9]+") local woff, ws, we = tokenizer.extract({ n = 2, texts = texts, pattern = prog }) local T = spans.create({ offsets = woff, s = ws, e = we }) local w = tokenizer.create({ ngram_min = 1, ngram_max = 2, mode = "flat", terminals = true }) local X = w:fit({ texts = texts, tokens = T }) print("rows:", (X:shape())) print("word ngram vocab:", w:n_tokens()) local Y = w:tokenize({ texts = texts, tokens = T }) print("frozen equals fit:", X:eq(Y)) return w:n_tokens()

tokenizer.normalize: fold text before word spans

tokenizer.normalize(texts) returns new strings, lowercased, with accents folded and whitespace collapsed. Word spans index into the text they were extracted from, so normalize first, extract from the normalized strings, and fit on those same strings. Accented and plain spellings then share columns.

local tokenizer = require("santoku.learn.tokenizer") local re = require("santoku.re") local spans = require("santoku.spans") local norm = tokenizer.normalize({ "Café NOIR", "cafe noir" }) print("normalized:", norm[1], norm[2]) local prog = re.prog("[A-Za-z0-9]+") local off, s, e = tokenizer.extract({ n = 2, texts = norm, pattern = prog }) local T = spans.create({ offsets = off, s = s, e = e }) local w = tokenizer.create({ ngram_min = 1, ngram_max = 2, mode = "flat", terminals = true }) w:fit({ texts = norm, tokens = T }) print("word ngram vocab:", w:n_tokens()) return w:n_tokens()

santoku.learn.aho: matching

Aho-Corasick multi-pattern matching: one pass over the text finds every pattern, returned as a spans set with id, start, and end columns.

local aho = require("santoku.learn.aho") local ac = aho.create({ patterns = { "foo", "bar", "baz" } }) local text = "foo bar baz" local S = ac:predict({ texts = { text } }) local ids, starts, ends = S:col("id"), S:col("s"), S:col("e") print("matches:", ids:size()) for i = 0, ids:size() - 1 do local s, e = starts:get(i), ends:get(i) print(text:sub(s + 1, e), "pattern " .. ids:get(i), s, e) end return ids:size()

aho:tag: formatted output

tag rewrites matches through a format string: %match and %id for the raw hit, %name for the display name, and %hmatch and %hname for HTML-escaped variants, safe to inject into markup.

local aho = require("santoku.learn.aho") local ac = aho.create({ patterns = { "foo", "bar" } }) print(ac:tag({ texts = { "foo and bar" }, fmt = "[%match:%id]" })[1]) local cities = aho.create({ patterns = { "nyc", "la" }, names = { "New York City", "Los Angeles" }, }) print(cities:tag({ texts = { "visit nyc or la" }, fmt = '<span title="%hname">%hmatch</span>', })[1]) local amp = aho.create({ patterns = { "a&b" } }) print(amp:tag({ texts = { "see a&b here" }, fmt = "<b>%hmatch</b>" })[1]) return true

aho unicode normalization

With normalize = true, matching folds case and strips diacritics on both sides while spans and tag output preserve the original bytes: cafe finds café, hello finds HELLO.

local aho = require("santoku.learn.aho") local ac = aho.create({ patterns = { "cafe" }, normalize = true }) print(ac:tag({ texts = { "at café now" }, fmt = "[%match]" })[1]) local up = aho.create({ patterns = { "hello" }, normalize = true }) print(up:tag({ texts = { "say HELLO there" }, fmt = "[%match]" })[1]) local S = ac:predict({ texts = { "café" } }) print("byte span:", S:col("s"):get(0), S:col("e"):get(0)) return S:col("id"):size()

aho boundaries: longest match, token alignment

longest = true keeps only the longest overlapping hit, and a tokens spans set restricts matches to token boundaries, so cat stops matching inside concatenate.

local aho = require("santoku.learn.aho") local spans = require("santoku.spans") local ivec = require("santoku.ivec") local ny = aho.create({ patterns = { "new", "new york" }, names = { "New", "New York" } }) local S = ny:predict({ texts = { "new york city" } }) print("all matches:", S:col("id"):size()) S = ny:predict({ texts = { "new york city" }, longest = true }) print("longest only:", S:col("id"):size()) print(ny:tag({ texts = { "new york city" }, fmt = "[%name]", longest = true })[1]) local cat = aho.create({ patterns = { "cat" } }) local T = spans.create({ offsets = ivec.create({ 0, 3 }), s = ivec.create({ 0, 4, 16 }), e = ivec.create({ 3, 15, 19 }) }) local S2 = cat:predict({ texts = { "cat concatenate cat" } }) print("raw matches:", S2:col("id"):size()) S2 = cat:predict({ texts = { "cat concatenate cat" }, tokens = T }) print("token aligned:", S2:col("id"):size()) return S2:col("id"):size()

aho exclude regions

Pass byte ranges as a pvec to mask regions out of matching; a second return reports per-region whether the automaton recognized the excluded text, which is how a suggester skips already-linked entities.

local aho = require("santoku.learn.aho") local pvec = require("santoku.pvec") local ac = aho.create({ patterns = { "foo", "bar", "baz" } }) local exc = pvec.create() exc:push(0, 3) local S = ac:predict({ texts = { "foo bar baz" }, exclude = exc }) print("surviving ids:", S:col("id"):get(0), S:col("id"):get(1)) print(ac:tag({ texts = { "foo bar baz" }, fmt = "[%match]", exclude = exc })[1]) local ac2 = aho.create({ patterns = { "foo" } }) local exc2 = pvec.create() exc2:push(0, 3) exc2:push(4, 11) local S2, hits = ac2:predict({ texts = { "foo hello world" }, exclude = exc2 }) print("matches:", S2:col("id"):size()) print("region recognized:", hits:get(0), hits:get(1)) return hits:size()

santoku.learn.booleanizer

Observe mixed categorical and continuous values, finalize, then encode rows to bit features (a csr) plus dense features (a matrix).

local booleanizer = require("santoku.learn.booleanizer") local bzr = booleanizer.create() bzr:observe("title", "The Great Gatsby") bzr:observe("title", "1984") bzr:observe("author", "F. Scott Fitzgerald") bzr:observe("author", "George Orwell") bzr:observe("year", 1925) bzr:observe("year", 1949) bzr:observe("rating", 4.5) bzr:observe("rating", 4.8) bzr:finalize() local n_bits, n_dense = bzr:features() print("bit features:", n_bits) print("dense features:", n_dense) local cols = { "title", "author", "year", "rating" } local rows = { { title = "The Great Gatsby", author = "F. Scott Fitzgerald", year = 1925, rating = 4.5 }, { title = "1984", author = "George Orwell", year = 1949, rating = 4.8 }, } local bits, dense = bzr:encode({ samples = rows, cols = cols }) print("bit nnz:", bits:neighbors():size()) print("dense shape:", dense:shape()) return n_bits + n_dense

santoku.learn.decide (single-label)

The decision layer: a single-label decider picks the argmax class from a flat row-major score vector.

local decide = require("santoku.learn.decide") local fvec = require("santoku.fvec") local scores = fvec.create({ 5, 0, 0, 0.5, 0, 0.49, 0, 1, 2, }) local g = decide.create({ n_labels = 3, single = true }) local pred = g:predict({ scores = scores, n_samples = 3 }) for i = 0, pred:size() - 1 do print("sample " .. i .. " label:", pred:get(i)) end return pred:size()

decide:score: macro F1 and accuracy

Score predictions against expected labels held in a csr (csr.from_classes builds one from a class ivec); separable scores come back with macro F1 and accuracy of 1.

local decide = require("santoku.learn.decide") local fvec = require("santoku.fvec") local csr = require("santoku.csr") local ivec = require("santoku.ivec") local scores = fvec.create({ 6, 0, 0, 0, 6, 0, 0, 0, 6, 6, 0, 0, }) local E = csr.from_classes(ivec.create({ 0, 1, 2, 0 }), 3) local g = decide.create({ n_labels = 3, single = true }) local macro, m = g:score({ scores = scores, n_samples = 4, expected = E }) print("macro f1:", macro) print("accuracy:", m.accuracy) return m.macro_f1

decide (multilabel): calibrate a threshold

Multilabel mode sweeps a score threshold that maximizes micro F1 on validation predictions (a csr of label scores per sample), then predict counts the accepted labels per row at that threshold.

local decide = require("santoku.learn.decide") local csr = require("santoku.csr") local ivec = require("santoku.ivec") local fvec = require("santoku.fvec") local P = csr.create({ offsets = ivec.create({ 0, 2, 4 }), neighbors = ivec.create({ 0, 2, 1, 0 }), values = fvec.create({ 0.9, 0.2, 0.8, 0.1 }), n_cols = 3, }) local E = csr.create({ offsets = ivec.create({ 0, 1, 2 }), neighbors = ivec.create({ 0, 1 }), n_cols = 3, }) local g = decide.create({ n_labels = 3 }) local f1, prec, rec = g:calibrate({ pred = P, expected = E, n_samples = 2 }) print("f1:", f1, "precision:", prec, "recall:", rec) print("threshold:", g:offset()) local ks = g:predict({ pred = P, n_samples = 2 }) print("accepted per sample:", ks:get(0), ks:get(1)) return f1

decide (span mode): non-maximum suppression

Span mode turns scored candidate spans into a non-overlapping labelling: per candidate it takes the argmax class and its margin, drops reject-class candidates, then a weighted interval DP keeps the best non-overlapping set, so New York City beats the overlapping New York and the weak candidate over the word in is rejected outright.

local decide = require("santoku.learn.decide") local spans = require("santoku.spans") local ivec = require("santoku.ivec") local fvec = require("santoku.fvec") local text = "Alice met Bob in New York City" local C = spans.create({ offsets = ivec.create({ 0, 5 }), s = ivec.create({ 0, 10, 14, 17, 17 }), e = ivec.create({ 5, 13, 16, 25, 30 }), }) local scores = fvec.create({ 2.0, 0.1, 0.1, 1.5, 0.2, 0.2, 0.1, 0.1, 0.9, 0.2, 1.2, 0.1, 0.1, 2.0, 0.1, }) local g = decide.create({ n_labels = 3, span = true }) local poff, ps, pe, pty = g:predict({ scores = scores, cand = C, n_samples = 1 }) local names = { "person", "location" } print("kept spans:", ps:size()) for j = 0, ps:size() - 1 do print(text:sub(ps:get(j) + 1, pe:get(j)), names[pty:get(j) + 1]) end return ps:size()

aho at scale: prepare, mmap, persist

The two-phase build for large gazetteers: prepare counts states, the goto table is mmapped at n_states * 256 entries, and the built automaton persists to disk for serve-time load.

local aho = require("santoku.learn.aho") local svec = require("santoku.svec") local ivec = require("santoku.ivec") local patterns = { "jane doe", "acme press", "acme" } local names = { "Jane Doe", "Acme Press", "Acme Press" } local priorities = ivec.create({ 2, 2, 1 }) local builder = aho.prepare({ patterns = patterns, names = names, priorities = priorities, normalize = true, }) local n_states = builder:n_states() local goto_buf = svec.mmap_create("suggest.aho.goto", n_states * 256) local ac = builder:build(goto_buf) ac:persist("suggest.aho") local served = aho.load("suggest.aho") local S = served:predict({ texts = { "signed with acme press" }, longest = true }) return S:col("id"):size()
Illustrative: runs outside the browser.

santoku.learn.spectral: embed and retrieve with binary codes

util.tokenize_blocks fits one tokenizer per block spec and returns the fitted tokenizers with one n-gram csr per block; passing { toks = toks } tokenizes queries against the same vocabulary. spectral.encode and enc:encode take the features as blocks = { { x = csr } }. Embed with that Nystrom spectral encoder, fit an ITQ rotation (santoku-matrix mtx:itq), and search the sign bits by exhaustive Hamming top-k. Exact float topk over the codes is the reference. Queries reuse the encoder, the centering means, and the rotation.

local spectral = require("santoku.learn.spectral") local util = require("santoku.learn.util") local ds = require("santoku.learn.dataset") local texts = ds.read_imdb("test/res/imdb.50k", 500).problems local spec = { { ngram_min = 4, ngram_max = 4, mode = "flat" } } local toks, X = util.tokenize_blocks(spec, texts) local _, enc = spectral.encode({ blocks = { { x = X[1] } }, n_landmarks = 256 }) local C = enc:encode({ blocks = { { x = X[1] } } }) C:normalize() local mu = C:center() C:normalize() local W = C:itq({ iterations = 30 }) local _, n_bits = W:shape() local B = C:multiply(W):sign() print("docs, dims:", C:shape()) print("exact nnz:", C:topk(C, 10):nnz()) print("hamming nnz:", B:bits_topk(B, n_bits, 10):nnz()) local _, XQ = util.tokenize_blocks(spec, { texts[1] }, { toks = toks }) local Q = enc:encode({ blocks = { { x = XQ[1] } } }) Q:normalize() Q:center(mu) Q:normalize() local hit = B:bits_topk(Q:multiply(W):sign(), n_bits, 1) print("query finds doc:", hit:neighbors():get(0)) return hit:nnz()
Illustrative: runs outside the browser.

santoku.learn.retrieval: bm25 candidates, reranked by codes

dataset.read_beir(dir, split) reads a BEIR directory into corpus_texts, query_texts, and qrels, a csr of graded judgments with one row per query. retrieval.lexical normalizes both sides and counts word unigrams (ngram_max widens that). bm25_ranker(X, Q) returns rank(k1, b, depth), which reweights a copy of the corpus on each call and returns the top depth documents per query as a csr. It scores like santoku.sqlite.fts's santoku_bm25 at the same k1 and b. rerank keeps the candidate set and reorders it: it max-normalizes the lexical scores, scores each candidate by the dot product of its query and document codes, and blends the two with weight alpha on the codes. alpha 0 keeps the lexical order. The codes can be any dense rows: spectral codes here, llama embeddings on the learn-llama page. To serve bm25 from a database, use santoku.sqlite.fts (the sqlite tab's fts cards).

local ds = require("santoku.learn.dataset") local retrieval = require("santoku.learn.retrieval") local spectral = require("santoku.learn.spectral") local util = require("santoku.learn.util") local d = ds.read_beir("test/res/scifact", "test") local X, Q = retrieval.lexical({ corpus_texts = d.corpus_texts, query_texts = d.query_texts }) local rank = retrieval.bm25_ranker(X, Q) local R = rank(1.2, 0.75, 100) local _, lexical_ndcg = R:ndcg(d.qrels, 10) print("bm25 ndcg@10:", lexical_ndcg) local spec = { { ngram_min = 4, ngram_max = 4, mode = "flat" } } local toks, XD = util.tokenize_blocks(spec, d.corpus_texts) local _, XQ = util.tokenize_blocks(spec, d.query_texts, { toks = toks }) local _, enc = spectral.encode({ blocks = { { x = XD[1] } }, n_landmarks = 256 }) local D = enc:encode({ blocks = { { x = XD[1] } } }) D:normalize() local mu = D:center() D:normalize() local Qc = enc:encode({ blocks = { { x = XQ[1] } } }) Qc:normalize() Qc:center(mu) Qc:normalize() local R1 = retrieval.rerank({ candidates = R, query_codes = Qc, doc_codes = D, alpha = 0.96 }) local _, reranked_ndcg = R1:ndcg(d.qrels, 10) print("reranked ndcg@10:", reranked_ndcg) return reranked_ndcg
Illustrative: runs outside the browser.

optimize.lexical and optimize.retrieval: tune bm25 and alpha

Both take a datasets list and return the best parameters plus a table of each dataset's baseline ndcg@10. optimize.lexical searches k1, b, and ngram (1 or 2) for rank; its sets carry corpus_texts, query_texts, and qrels, and depth defaults to 100. optimize.retrieval searches the rerank alpha; its sets carry candidates, qrels, query_codes, and doc_codes, built here as in the card above. search_trials defaults to 0, which returns the defaults without a search: k1 1.2, b 0.75, ngram 1, alpha 0.96. Above 0 a CMA-ES search scores each trial by the mean ratio of ndcg@10 to each dataset's baseline.

local ds = require("santoku.learn.dataset") local optimize = require("santoku.learn.optimize") local retrieval = require("santoku.learn.retrieval") local spectral = require("santoku.learn.spectral") local util = require("santoku.learn.util") local d = ds.read_beir("test/res/scifact", "test") local lex = optimize.lexical({ datasets = { { name = "scifact", corpus_texts = d.corpus_texts, query_texts = d.query_texts, qrels = d.qrels, } }, search_trials = 40, }) print("k1, b, ngram:", lex.k1, lex.b, lex.ngram) local X, Q = retrieval.lexical({ corpus_texts = d.corpus_texts, query_texts = d.query_texts, ngram_max = lex.ngram, }) local R = retrieval.bm25_ranker(X, Q)(lex.k1, lex.b, 100) local spec = { { ngram_min = 4, ngram_max = 4, mode = "flat" } } local toks, XD = util.tokenize_blocks(spec, d.corpus_texts) local _, XQ = util.tokenize_blocks(spec, d.query_texts, { toks = toks }) local _, enc = spectral.encode({ blocks = { { x = XD[1] } }, n_landmarks = 256 }) local D = enc:encode({ blocks = { { x = XD[1] } } }) D:normalize() local mu = D:center() D:normalize() local Qc = enc:encode({ blocks = { { x = XQ[1] } } }) Qc:normalize() Qc:center(mu) Qc:normalize() local best = optimize.retrieval({ datasets = { { name = "scifact", candidates = R, qrels = d.qrels, query_codes = Qc, doc_codes = D, } }, }) print("alpha:", best.alpha) return best.alpha
Illustrative: runs outside the browser.

santoku.learn.optimize.krr: the supervised entry point

The supervised path on the IMDB sentiment corpus: tokenize byte and word blocks, encode, solve ridge, score through a calibrated decider, then persist the whole deployment as one bundle and reload it. nu picks the kernel: 0, 1 and 2 are Matern 1/2, 3/2 and 5/2, 3 is RBF, and 4 is cosine, the default for spectral.encode. nu = 4 fixes cosine; nu = { def = 4 } starts there and lets the search choose among all five along with gamma. A block whose x is a dense mtx is mean-centered by the encoder; csr blocks aren't. Encoders save in format 34 and deciders in format 4; files from santoku-learn 5.x don't load. A span head's reject class comes from the decider: -1 (dropped) for a one-column head, else the last column.

local optimize = require("santoku.learn.optimize") local ds = require("santoku.learn.dataset") local util = require("santoku.learn.util") local bundle = require("santoku.learn.bundle") local dataset = ds.read_imdb("test/res/imdb.50k") local train, test_set = ds.split_imdb(dataset, 0.5) local W = util.word_spans(train.problems, train.n) local Wt = util.word_spans(test_set.problems, test_set.n) local blocks = { { ngram_min = 1, ngram_max = 5, mode = "flat" }, { ngram_min = 1, ngram_max = 3, mode = "words" }, } local toks, pool_blocks = util.tokenize_blocks(blocks, train.problems, { tokens = W }) local _, test_blocks = util.tokenize_blocks(blocks, test_set.problems, { toks = toks, tokens = Wt }) local enc, ridge, deploy, best, decider = optimize.krr({ pool_blocks = pool_blocks, pool_labels = train.labels, n_labels = 1, relevance = { "bns", "bns" }, n_landmarks = 1024 * 8, nu = { def = 4 }, lambda = { def = 0.025 }, k = 1, decode_offset = { def = 0.49393576 }, search_trials = 0, folds = 5, }) local P = util.predict_tiled({ deploy = deploy, ridge = ridge, blocks = test_blocks, n = test_set.n, k = 1, }) local _, m = decider:score({ pred = P, expected = test_set.labels, n_samples = test_set.n, }) print(util.fmt_metrics(m)) bundle.persist({ dir = "imdb.bundle", tokenizers = toks, encoder = enc, ridge = ridge, decider = decider, }) local b = bundle.load("imdb.bundle") local _, Xb = util.tokenize_blocks(blocks, test_set.problems, { toks = b.tokenizers, tokens = Wt }) local Pb = util.predict_tiled({ deploy = b.encode, ridge = b.ridge, blocks = Xb, n = test_set.n, k = 1 }) local _, mb = b.decider:score({ pred = Pb, expected = test_set.labels, n_samples = test_set.n }) print(util.fmt_metrics(mb)) return best.lambda
Illustrative: runs outside the browser.

santoku.learn.ner: span NER with gazetteer candidates

Condensed from the CoNLL-2003 regression spec: a surface gazetteer proposes typed candidate spans, a character gazetteer contributes a feature block, optimize.krr trains the span head, and the decider scores span F1 through the same NMS it deploys with.

local ds = require("santoku.learn.dataset") local ner = require("santoku.learn.ner") local util = require("santoku.learn.util") local optimize = require("santoku.learn.optimize") local csr = require("santoku.csr") local spans = require("santoku.spans") local ivec = require("santoku.ivec") local train, dev, test_set = ds.read_conll2003("test/res/conll2003") local ac, pat_type = util.surface_gaz({ train, dev, test_set }, 4, false) local pool = ds.merge_conll2003(train, dev) local T = util.shape_spans(pool.texts, pool.n) local S = ac:predict({ texts = pool.texts, longest = true, tokens = T }) local ty = ivec.create(S:col("id"):size()):copy(pat_type, S:col("id")) local C = spans.create({ offsets = S:offsets(), s = S:col("s"), e = S:col("e"), ty = ty }) local Y = csr.from_mask(C:match_labels(pool.gold)) local blocks = { { ngram_min = 1, ngram_max = 5, normalize = false, regions = true }, } local toks, X = util.tokenize_blocks(blocks, pool.texts, { focus = C, tokens = T }) local gaz = ner.build_char_gaz({ texts = pool.texts, gold = pool.gold, n_types = 4, ngram_min = 1, ngram_max = 5, }) X[#X + 1] = gaz:block(pool.texts, C, C:type_labels(pool.gold, 4)) local enc, ridge, deploy, best, decider = optimize.krr({ pool_blocks = X, pool_labels = Y, pool_n = C:col("s"):size(), n_labels = 1, cand = C, gold = pool.gold, n_landmarks = 1024 * 8, decode_offset = { def = -0.56368208 }, search_trials = 0, folds = 5, }) local _, scores = util.predict_tiled({ deploy = deploy, ridge = ridge, blocks = X, n = C:col("s"):size(), scores = true, n_labels = 1, }) local f1, m = decider:score({ scores = scores, cand = C, gold = pool.gold, n_samples = pool.n, }) print(util.fmt_metrics(m)) return f1
Illustrative: runs outside the browser.

regression: santoku.learn.evaluator

The same path does regression: pass targets instead of labels, mix standardized continuous columns with booleanized bits as blocks, and score with regress_accuracy (normalized MAE and friends).

local ds = require("santoku.learn.dataset") local eval = require("santoku.learn.evaluator") local optimize = require("santoku.learn.optimize") local util = require("santoku.learn.util") local dataset = ds.read_california_housing("test/res/california-housing.csv", {}) local train, test_set = ds.split_california_housing(dataset, 0.8) local mean = train.continuous:center() test_set.continuous:center(mean) local Xc = train.continuous:to_sparse():i32() local sp = Xc:standardize() local Xt = test_set.continuous:to_sparse():i32() Xt:scale_cols(sp) local bits = train.bits:i32() local bits_std = bits:standardize() local bits_t = test_set.bits:i32() bits_t:scale_cols(bits_std) local enc, ridge, deploy = optimize.krr({ pool_blocks = { Xc, bits }, pool_targets = train.targets, n_targets = 1, pool_n = train.n, n_landmarks = 1024 * 8, nu = { def = 0 }, gamma = { def = 0.94 }, lambda = { def = 0.0002 }, search_trials = 0, folds = 5, }) local _, scores = util.predict_tiled({ deploy = deploy, ridge = ridge, blocks = { Xt, bits_t }, n = test_set.n, scores = true, n_labels = 1, }) local m = eval.regress_accuracy(scores, test_set.targets) print("test accuracy:", 1 - m.nmae) return m.nmae
Illustrative: runs outside the browser.

tabular classification: dataset.read_adult

dataset.read_adult(dir) fits a booleanizer on the UCI Adult training rows and returns the train and test splits (n, bits, dense, labels) plus a meta table with the booleanizer, n_bits, and n_dense. Center and standardize the dense columns, standardize the bits, and pass the dense block with group_offsets, one group per column, so scales pins one weight per column group and one for the bits block.

local ds = require("santoku.learn.dataset") local optimize = require("santoku.learn.optimize") local util = require("santoku.learn.util") local mtx = require("santoku.mtx") local train, test_set, meta = ds.read_adult("test/res/adult") local function dense_block (d, mean) local X = mtx.create({ data = d.dense:data():to_fvec(), n_rows = d.n, n_cols = meta.n_dense }) if mean then X:center(mean) else mean = X:center() end return X:to_sparse():i32(), mean end local Xc, mean = dense_block(train) local Xt = dense_block(test_set, mean) Xt:scale_cols(Xc:standardize()) local bits = train.bits:i32() local bits_t = test_set.bits:i32() bits_t:scale_cols(bits:standardize()) local go = {} for g = 0, meta.n_dense do go[g + 1] = g end local _, ridge, deploy, _, decider = optimize.krr({ pool_blocks = { { x = Xc, group_offsets = go }, bits }, pool_labels = train.labels, pool_class = train.labels:neighbors(), n_labels = 2, n_landmarks = 1024 * 8, nu = { def = 0 }, gamma = { def = 0.33884277 }, lambda = { def = 0.0044132295 }, scales = { def = { 0.80902091, 0.006489361, 465.91994, 0.31863918, 0.31888871, 5.3659771, 0.74979041 } }, k = 1, search_trials = 0, folds = 5, }) local _, scores = util.predict_tiled({ deploy = deploy, ridge = ridge, blocks = { { x = Xt, group_offsets = go }, bits_t }, n = test_set.n, scores = true, n_labels = 2, }) local _, m = decider:score({ scores = scores, n_samples = test_set.n, expected = test_set.labels }) print(util.fmt_metrics(m)) return m.accuracy
Illustrative: runs outside the browser.