BERT embeddings and a trainable head — Design

A frozen, pretrained BERT encoder run through API.Onnx, producing sentence embeddings, and a classification head trained in Objeck on top of them. Built for a university cybersecurity-ML lab ("ML Chapter 3 — Building a BERT-Based Malware Classifier"), and useful to anyone who wants text embeddings from Objeck.

Why, and what this is not

The lab fine-tunes BERT on API-call sequences and runs three experiments:

  1. Class imbalance. Training sets with a positive ratio (POSRATIO) of 0.001, 0.01, 0.10 and 0.50, comparing precision, recall, F1 and false negatives, with the test set's class distribution held fixed.
  2. Sequence length. MAX_SEQ_LENGTH of 32, 64, 128, 256.
  3. Learning rate. 1e-5, 2e-5, 5e-5, with a plot of the training loss.

Objeck has no transformer training: no autograd, no attention kernels, no optimizer over a 110M-parameter graph. Building that is a project of its own and is out of scope.

What is in scope is the honest, useful subset: the encoder frozen, the head trained.

 text ──WordPieceTokenizer──▶ ids, mask, types ──API.Onnx (frozen BERT)──▶ last_hidden_state
                                                                              │ pool: CLS or mean
                                                                              ▼
                                       Float[n, hidden] embeddings ──EmbeddingClassifier──▶ P(malware)
                                                                     (trained here: Adam/SGD, BCE)

All three experiments run on this variant. It is not the lab's fine-tuning, and every document and the example say so:

Lab (fine-tuning)This (frozen encoder)
Trained parametersall ~110M (encoder + head)the head only (hidden × 1 up to a few thousand)
Embedding of a sequencechanges as training proceedsfixed; computed once per MAX_SEQ_LENGTH
Learning-rate experimentthe encoder's learning rate (1e-5 … 5e-5 suit a pretrained transformer)the head's learning rate. The lab's values are far too small for a freshly initialised head with Adam over a few epochs; the example runs the lab's three values and a head-appropriate sweep (1e-3, 1e-2) so the loss curves show what the rate actually does
Imbalance and sequence-length experimentsas in the labas in the lab; the head is retrained per setting
Expected accuracyhigher: the encoder adapts to API-call textlower or equal: BERT's pretraining saw English, not API traces

What fine-tuning would add, for the record: back-propagation through 12 transformer layers (attention, layer norm, GELU, the embedding tables), an optimizer state of 2–3× the model's size, and mixed precision to make it affordable. ONNX Runtime has a training API (onnxruntime-training) that could be bound later; nothing here precludes it.

What an exported BERT looks like

A Hugging Face export (optimum-cli export onnx --model bert-base-uncased, or torch.onnx.export with dynamic_axes) has:

TensorElement typeShapeNotes
input_idsINT64[batch, sequence]vocabulary ids
attention_maskINT64[batch, sequence]1 for a real token, 0 for padding
token_type_idsINT64[batch, sequence]segment: 0 for the first text, 1 for the second. Absent from DistilBERT exports
last_hidden_stateFP32[batch, sequence, hidden]hidden = 768 for base, 384 for MiniLM
pooler_outputFP32[batch, hidden]tanh(dense(CLS)). Optional: absent from DistilBERT and from exports of AutoModel heads that drop it

Session->GetModelInfo() reports each with its element type name ("INT64", "FP32") and its shape, with -1 for a dynamic axis (batch, sequence). An export made without dynamic_axes reports fixed numbers instead — [1, 128] is common.

Tensors cross the native boundary as Float[], and the native side converts each input to the element type the model declares (#1047): the caller builds Tensor->New("input_ids", shape, ids) with Int[] ids and never names INT64. Tensor->AsFloats refuses a value past 2^53, far above any vocabulary id.

API

Three libraries, each where its dependencies already are.

System.NLP (nlp.obl): tokenizers — no model, no native code

tokenizer := WordPieceTokenizer->New("vocab.txt");            # lowercase + strip accents (uncased BERT)
cased := WordPieceTokenizer->New("vocab.txt", false, false);  # cased BERT

pieces := tokenizer->Tokenize("unaffable");                   # ["un", "##aff", "##able"]
enc := tokenizer->Encode("He's unaffable.", 16);              # TokenEncoding
enc->GetIds();             # [CLS] he ' s un ##aff ##able . [SEP] [PAD]...  (16 ids)
enc->GetAttentionMask();   # 1 x 9, then 0 x 7
enc->GetTokenTypeIds();    # all 0
pair := tokenizer->EncodePair("question", "context", 32);   # types 0 ... 0 1 ... 1
text := tokenizer->Decode(enc->GetIds());                     # "he ' s unaffable ."
apis := ApiSequenceTokenizer->New();
apis->Fit(training_sequences);                  # vocabulary from training data
enc := apis->Encode("CreateFileW,ReadFile,CloseHandle", 64);
words := apis->Split("CreateFileW ReadFile");   # ["CreateFileW", "ReadFile"]
text := apis->ToText("CreateFileW,ReadFile", 62); # "CreateFileW ReadFile" -- for WordPiece

As built (stage 1).

System.NLP.Onnx (new nlp_onnx.obl): the embedder

embedder := BertEmbedder->New("model.onnx", "vocab.txt", 128);
if(<>embedder->IsOpen()) {
  EndPoint->GetLastError()->PrintLine();
  return;
};
embedder->SetPooling(BertEmbedder->Pooling->MEAN);   # or CLS, or POOLER when the model has one
embedder->SetBatchSize(16);
X := embedder->Embed(texts);               # Float[texts->Size(), hidden]
embedder->Close();

Why a library of its own. The compiler resolves every external class a linked library references, eagerly — linking onnx without json fails with "Unable to resolve external library class: 'Data.JSON.JsonElement'" even for a program that never touches JSON. So a BertEmbedder in onnx.obl that names WordPieceTokenizer would make every ONNX program add -lib nlp, and one in nlp.obl would make every NLP program add ONNX Runtime. A bridge library is the precedent query_vision.obl set for the same reason. A program using it links:

obc -src lab.obs -lib models,opencv,cipher,json,onnx,nlp,nlp_onnx

or -lib @vision, whose group now includes it.

As built (stage 2).

| max_len | batch | per text | peak working set | |---|---|---|---| | 128 | 8 | 1.6 ms | 182 MB | | 256 | 1 | 4.9 ms | 120 MB | | 256 | 8 | 5.5 ms | 235 MB | | 256 | 32 | 2.1 ms | 458 MB |

Encoding the 512 traces with EncodeBatch took a further 0.7 s at 128 and 1.1 s at 256. A real BERT's encoder adds its own compute on top, which no model here measures. Peak memory grows with batch × max_len × hidden, as the table shows; the batch-8 row at 256 was slower than both batch 1 and batch 32 in repeated runs, so 32 is the better setting at that length when memory allows.

System.ML (ml.obl): EmbeddingClassifier

head := EmbeddingClassifier->New([64], 0.001, 20, 32, 42);   # hidden sizes, rate, epochs, batch, seed
head->SetOptimizer(EmbeddingClassifier->Optimizer->ADAM);     # or SGD
head->SetClassWeights(1.0, 99.0);                             # negative, positive
head->Fit(X_train, y_train);                                  # Float[,], Bool[]
loss := head->GetLossHistory();                               # Float[epochs]: mean training loss per epoch
p := head->PredictProba(X_test);                              # Float[]
yhat := head->PredictClass(X_test, 0.5);                      # Bool[]

As built (stage 3).

The lab example: programs/examples/ml/bert_malware_lab.obs

obr bert_malware_lab.obe --model model.onnx --vocab vocab.txt --data traces.csv
obr bert_malware_lab.obe --synthetic            # no external data

As built (stage 4).

Test plan

Correctness is the point. Every test is in programs/regression, exits non-zero on failure, and runs at -opt s3 and -opt s0, under obr, obr --jit=off and with OBJECK_JIT_THRESHOLD=1, with identical output.

The test model. A real BERT is hundreds of megabytes and the regression tree ships no model. What the tests need is BERT's signature, with numbers simple enough to recompute. programs/regression/data/bert/gen_tiny_bert.py (needs the onnx package; checks its output with onnxruntime when present) writes three models with the export signature above and hidden size 8:

last_hidden_state[b,s,j] = word[id,j] + type[tt,j] + pos[s,j] + 0.25 * attention_mask[b,s]
pooler_output[b,j]       = tanh(sum_k last_hidden_state[b,0,k] * Wp[k,j] + bp[j])

Every table is a closed form of its indices with values that are multiples of 1/32, exact in FP32, so a test recomputes every output itself, without numpy and without reading the model. The mask term wires attention_mask into the output and keeps padded positions non-zero, so a wrong mask, or a mean pool that forgets to exclude padding, gives a different answer.

FileInputsOutputsStands in for
tiny_bert.onnxids, mask, types [-1,-1]last_hidden_state [-1,-1,8], pooler_output [-1,8]BERT, dynamic axes
tiny_bert_nopool.onnxids, masklast_hidden_stateDistilBERT
tiny_bert_fixed.onnxids, mask, types [1,16]both, fixedan export without dynamic_axes

vocab.txt beside them (64 tokens: the specials, the BERT paper's un ##aff ##able, and pieces that split Windows API names) is the tests' vocabulary. onnx_tiny_bert_model.obs checks the assets alone, through the generic Session, on every platform's ONNX Runtime (1.19 Linux, 1.22 Windows, 1.30 macOS) before anything is built on them.

Stage tests.

Stages

Each is its own pull request against master, merged before the next.

  1. This document; the tiny-model generator, the three models and the vocabulary; onnx_tiny_bert_model.obs.
  2. WordPieceTokenizer, TokenEncoding, ApiSequenceTokenizer in nlp.obs; the parity fixture and tests; nlp.obl rebuilt; the editor index.
  3. nlp_onnx.obs with BertEmbedder; every library list; tests; memory and throughput.
  4. EmbeddingClassifier in System.ML; tests.
  5. The lab example and its documentation.