santoku-learn
View on LuaRocksmachine 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()
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()
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
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
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
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
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
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