Repository navigation
feat: add token classification and span extraction (mlxcel tag, mlxcel extract) - #2456
Conversation
Implementation Review SummaryIntent
Findings Addressed
Remaining Items (LOW, not fixed)
Verification
Re-run at head |
Work in progress for the token-classification and span-extraction surface: span decoding, the windowed TokenClassifier pipeline, the SpanExtractorModel contract, detection, and the BERT / XLM-RoBERTa token head. CLI wiring, docs and the remaining tests follow. Refs #2383
Adds the tag and extract subcommands to the mlxcel binary, the detection, extract-contract, BERT token head, CLI and Rampart real-checkpoint tests, and the docs/token-classification.md page with supported-models, README and changelog entries. Refs #2383
The first multi-window fixture case repeated one template 24 times and held an exact O / B-GIVEN_NAME tie on one "maria" token in the mlx-vlm CPU run (top-2 gap 0.0000), which the CUDA f16 run broke the other way: 105 of 106 spans matched and the 106th differed only there. The generator now refuses any case whose smallest top-1 / top-2 log-probability gap is under 0.05 and records that gap per case. The long case is now 706 tokens with the window boundary inside "Cyberdyne" (first piece O, second B-STREET_NAME), so word grouping across windows is pinned as well. The three sentences are byte-identical between mlx-vlm b8bf04c6 and the current 73d366ec oracle; the parity test now names the first differing span. Refs #2383
3574a0a to
6d308b1
Compare
Security and performance review (head 6d308b1)No CRITICAL or HIGH findings, so nothing was changed. I audited MEDIUM
LOW
|
…eads The security pass on #2456 found two load-bearing gaps. redact rebuilt the whole character vector per span, so a long input with many spans cost text length times span count; decode_spans output is sorted and disjoint, so it now takes one forward pass and keeps the upstream right-to-left splice only for other span lists. mlxcel tag fills every window to the declared context, so BertTokenClassifier now refuses a position table shorter than max_position_embeddings at load, and the tagger refuses a token id outside the word-embedding table instead of reading past it. The docs now say that only redacted_text is redacted; text and spans[].text keep the original values. Refs #2383
Summary
Adds the shared per-token output surface from epic #2391 and lands it end to end with the BERT / XLM-RoBERTa token-classification head:
mlxcel tagtags and redacts entities with Rampart and matches mlx-vlm span for span, andmlxcel extractcarries the span-extraction contract that GLiNER2.5 (#2390) will implement.What changed
src/token_classification/:TokenClassifierModel,DecodeModeand the windowedTokenClassifier<M>pipeline (mod.rs); a one-to-one port of upstream's label, span, word-grouping, placeholder and redaction helpers with Unicode-scalar offsets (spans.rs);SpanExtractorModel,ExtractOptions,EntityResultandClassifyTaskwith upstream's JSON shapes (extract.rs); config-only detection withTokenModelKindand a detector table, one variant plus one arm per family (loader.rs); the BERT pipeline model (bert.rs).BertTokenClassifierinsrc/models/bert_heads.rs: the encoder plus a per-positionclassifierprojection, loaded likeBertSequenceClassifier.mlxcel tagandmlxcel extractwired into themlxcelbinary (src/main.rs,src/commands/tag.rs,src/commands/extract.rs). A checkpoint of the other shape fails naming the right subcommand.docs/token-classification.md, a token-classification section indocs/supported-models.md, README, docs index and changelog. The repo has noCLAUDE.md, so the reference-table row from the issue's scope was skipped.Contract notes for #2387 / #2390 (also in the trait docs):
log_probsreceives one wrapped window and returns one row per id; the pipeline strips the special rows and applies an f32 log-softmax, so returning logits is fine.decode_modes()[0]is the default,--argmaxforcesArgmax, and a mode not listed is refused. Theid2labelvs classifier width check runs at load against the tensor, since detection sees onlyconfig.json.Routing is unchanged:
get_model_typestill detects Rampart as the BERT embedder (itsmodel_typeis encoder-only), somlxcel embedbehaves as before;tag/extractuse their own detection. XLM-RoBERTa uses the addressable position count (max_position_embeddings - pad - 1) as its context, where upstream's rawmax_position_embeddingswould index past the table.Review hardening:
redacttakes one forward pass over the sorted, disjoint spansdecode_spansproduces (upstream's right-to-left splice is kept for any other span list), the token head refuses a position table shorter thanmax_position_embeddingsat load, the tagger refuses a token id outside the word-embedding table, and the docs note thattext/spans[].textare not redacted.Test plan
All runs on GB10 (CUDA,
--profile test-fast --features cuda), test binaries undergpu-lock.mlxcellib tests, filtertoken_classification: 43 passed (span ports, windowing, detection, extract contract, Rampart real-checkpoint and parity), withMLX_ENABLE_TF32=0and again with TF32 on.bert: 56 passed, including the newBertTokenClassifierhead tests; embedding and rerank BERT tests unchanged.mlxcelbin tests, filtersextractandtag: 6 passed (flag parsing, stubSpanExtractorModeldispatch, help listing).mlxcel tag -m models/mlx/rampart-mlx-fp16 --keep CITY,STATE,ZIP_CODE "I'm Sarah Connor, ..."printsI'm <GIVEN_NAME> <SURNAME>, <BUILDING_NUMBER> <STREET_NAME>, Los Angeles CA 90012, <EMAIL>, the model card's output;rampart-mlx-4bit(8-bit embeddings, 4-bit linears) prints the same 8 spans with the same offsets.tests/fixtures/token_classification/rampart_mlx_vlm_spans.json, from mlx-vlm 0.7.8 on mlx CPU (git 73d366ec; the three sentences are identical under b8bf04c6): three sentences (README example,617-555-0142merge, multi-byte German address) plus a 706-token two-window case with the boundary inside a word. Labels, offsets, text and redaction match exactly. The generator rejects any case with a top-1/top-2 gap under 0.05; an earlier repeated-template long case had an exact tie on one token that CUDA broke the other way.Closes #2383