| |
| |
| |
|
|
| const TOKEN_RE = /[\p{L}\p{N}_]{2,}/gu; |
|
|
| function parseModel(buffer) { |
| const bytes = new Uint8Array(buffer); |
| const magic = new TextDecoder().decode(bytes.subarray(0, 9)); |
| if (magic !== "MOODRING1") throw new Error("bad model file"); |
| const jsonLen = new DataView(buffer).getUint32(9, true); |
| const meta = JSON.parse(new TextDecoder().decode(bytes.subarray(13, 13 + jsonLen))); |
| const n = meta.n_features; |
| let off = 13 + jsonLen; |
| const idf = new Float32Array(buffer.slice(off, off + 4 * n)); |
| off += 4 * n; |
| const coef = new Float32Array(buffer.slice(off, off + 4 * n * meta.emotions.length)); |
| const vocab = new Map(meta.terms.map((t, i) => [t, i])); |
| return { meta, idf, coef, vocab, n }; |
| } |
|
|
| function tokenize(text) { |
| return (text.toLowerCase().match(TOKEN_RE) || []); |
| } |
|
|
| function vectorize(model, text) { |
| const tokens = tokenize(text); |
| const grams = tokens.slice(); |
| for (let i = 0; i + 1 < tokens.length; i++) grams.push(tokens[i] + " " + tokens[i + 1]); |
| const counts = new Map(); |
| for (const g of grams) { |
| const idx = model.vocab.get(g); |
| if (idx !== undefined) counts.set(idx, (counts.get(idx) || 0) + 1); |
| } |
| let normSq = 0; |
| const entries = []; |
| for (const [idx, tf] of counts) { |
| const w = (1 + Math.log(tf)) * model.idf[idx]; |
| entries.push([idx, w]); |
| normSq += w * w; |
| } |
| const norm = Math.sqrt(normSq) || 1; |
| return entries.map(([idx, w]) => [idx, w / norm]); |
| } |
|
|
| function scores(model, text) { |
| const x = vectorize(model, text); |
| const { emotions, intercept } = model.meta; |
| const out = {}; |
| for (let j = 0; j < emotions.length; j++) { |
| let margin = intercept[j]; |
| const row = j * model.n; |
| for (const [idx, w] of x) margin += model.coef[row + idx] * w; |
| out[emotions[j]] = 1 / (1 + Math.exp(-margin)); |
| } |
| return out; |
| } |
|
|
| function predict(model, text) { |
| const s = scores(model, text); |
| const { emotions, thresholds } = model.meta; |
| let labels = emotions.filter((e, j) => s[e] >= thresholds[j]); |
| const fallback = labels.length === 0; |
| if (fallback) { |
| labels = [emotions.reduce((a, b) => (s[a] >= s[b] ? a : b))]; |
| } |
| const rounded = {}; |
| for (const e of emotions) rounded[e] = Math.round(s[e] * 10000) / 10000; |
| const thr = {}; |
| emotions.forEach((e, j) => (thr[e] = Math.round(thresholds[j] * 100) / 100)); |
| return { labels, fallback, scores: rounded, thresholds: thr }; |
| } |
|
|
| |
| (function () { |
| const realFetch = window.fetch.bind(window); |
| let modelPromise = null, testPromise = null; |
| const getModel = () => (modelPromise ||= realFetch("./model.bin") |
| .then((r) => { if (!r.ok) throw new Error("model download failed"); return r.arrayBuffer(); }) |
| .then(parseModel)); |
| const getTest = () => (testPromise ||= realFetch("https://raw.githubusercontent.com/google-research/google-research/master/goemotions/data/test.tsv") |
| .then((r) => { if (!r.ok) throw new Error("sample fetch failed"); return r.text(); }) |
| .then((t) => t.trim().split("\n").map((line) => line.split("\t")))); |
| window.fetch = async function (url, opts) { |
| const u = typeof url === "string" ? url : url.url; |
| if (u.startsWith("/predict")) { |
| const model = await getModel(); |
| const { text } = JSON.parse(opts.body); |
| return Response.json(predict(model, text)); |
| } |
| if (u.startsWith("/sample")) { |
| const [rows, model] = await Promise.all([getTest(), getModel()]); |
| const raw = parseInt(new URLSearchParams(u.split("?")[1]).get("index") || "0", 10); |
| const index = Math.max(0, Math.min(rows.length - 1, raw)); |
| const [text, labels] = rows[index]; |
| const gold = labels.split(",").map((s) => model.meta.emotions[parseInt(s, 10)]); |
| return Response.json({ index, total: rows.length, text, gold }); |
| } |
| return realFetch(url, opts); |
| }; |
| })(); |
|
|