OpenMed Android 端侧 Tokenization 决策与实现:WordPiece/BPE 偏移映射如何支撑本地 Token 分类推理
2026/9/17 16:06:14 网站建设 项目流程

OpenMed Android 端侧 Tokenization 决策与实现:WordPiece/BPE 偏移映射如何支撑本地 Token 分类推理

【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed

导读

本文记录 OpenMedKit Android 模块在端侧(on-device)执行 token 分类(token-classification)推理时的 tokenization 技术选型与落地实现。核心问题在于:Android 上运行临床 NER 与 HIPAA PII 脱敏模型时,需要将文本切成 WordPiece/BPE token 送入 ONNX 图,并依赖 token→字符(char-level)偏移映射把模型预测回映为原始文本上的字符区间,进而生成OpenMedSpan记录。读完本文,你将掌握 OpenMedKit 为何选择 DJL HuggingFace tokenizers AAR、三个候选方案(DJL AAR / 纯 Kotlin WordPiece / ORT-Extensions 图内 BertTokenizer)的取舍依据、需要随模型打包的三类 tokenizer 资产,以及源码中偏移契约、BIOES 解码、隐私日志与跨平台 parity 校验的具体实现路径。

背景:为什么 Android token 分类推理必须先解决 tokenization

OpenMedKit 的 Android 端采用与 Python 参考管线一致的 ONNX 模型进行 token 分类:输入一段临床文本,输出每个 token 的实体标签(如人名、日期、病历号),再聚合成完整的实体区间。这一链路里 tokenizer 处在最前端:

  1. 原始文本 → tokenizer 产生input_idsattention_mask(必要时含token_type_ids);
  2. 这些张量送入 ONNX Runtime 会话,输出logits
  3. 解码器把每个 token 的标签预测按字符偏移聚合回原始文本区间。

可见tokenizer 不仅负责"分词",还负责给出每个 token 在原始字符串中的精确字符区间。没有这个偏移表,模型的标签预测就无法映射回原文,也就无法生成可用于高亮、脱敏、FHIR 导出的OpenMedSpan

OpenMedKit 的 Android 实现(Runtime.kt)中,BackendOnnxTokenClassifier.predict的调用顺序印证了这条链路:

override suspend fun predict(text: String): List<TokenClassificationPrediction> { require(text.isNotEmpty()) { "text must not be empty" } val encoding = encode(text) val offsets = encoding.toTokenOffsets() val predictions = classifier.run( inputIds = encoding.ids.map(Long::toInt).toIntArray(), attentionMask = encoding.attentionMask.map(Long::toInt).toIntArray(), offsets = offsets, ) return aggregateTokenPredictions(text, predictions) }

其中encoding.toTokenOffsets()直接取自 tokenizer 返回的EncodingcharTokenSpans(Runtime.kt)——这正是"偏移由 tokenizer 原生提供"这一决策在代码中的落点。

约束:本地优先的硬性前提

与 OpenMedKit 其余部分一致,Android 端 tokenization 必须满足以下约束(原文 "Constraints" 一节,全部被采纳为设计红线):

  • 仅端侧执行(On-device only):tokenization 完全运行在手机上,模型与 tokenizer 资产下载完成后不再有任何网络访问,不存在远程 tokenization 路径。
  • 日志中不得出现 PHI:tokenizer 不得记录原始输入文本、token 字符串或字符偏移。脱敏输入是临床文本,绝不能进入应用或依赖日志。
  • 仅允许宽松许可证:tokenizer 依赖必须是宽松许可证(Apache-2.0/MIT 风格),不接受 GPL/LGPL 或专有 tokenizer。
  • Span 偏移保真(Span-offset fidelity):字符级偏移映射必须足够精确,保证每个被检测实体的字符区间都能被精确还原。
  • Python 奇偶一致(Python parity):端侧 tokenization 必须与 OpenMed Python 管线的 tokenization 一致,使跨平台的 span 偏移与标签保持一致。

三个候选方案对比

原文给出了一张完整的方案对比表,这里完整保留并补充关键解读:

准则DJL HuggingFace tokenizers AAR纯 Kotlin WordPieceORT-Extensions 图内 BertTokenizer
许可证Apache-2.0(DJL 封装 + Rusttokenizerscrate)项目自有代码(宽松)MIT(onnxruntime-extensions
偏移映射Rust tokenizerEncoding原生字符偏移,高保真必须手工重实现,正确性由作者承担有限;图内 tokenizer 无法干净地暴露用于 span 恢复的字符偏移
二进制 / 资产体积每个 ABI 增加一个原生.so到 APK;tokenizer 资产为tokenizer.json最小——无原生库;tokenizer 资产为tokenizer.json/vocab.txttokenization 烘焙进.ort图,无独立 tokenizer 运行时,图更大
最低 SDK26(与 OpenMedKit 模块一致)无限制取决于 ONNX Runtime + Extensions 的构建
Python 奇偶精确——与 OpenMed Python fast tokenizer 消费同一个tokenizer.json近似——必须复刻 normalization、pre-tokenization 与偏移仅 WordPiece/BERT,无 BPE;图内编码规则可能与 Python 漂移

方案一:DJL HuggingFace tokenizers AAR(被采纳为主策略)

ai.djl.huggingface:tokenizers封装了支撑 Hugging Face Python fast tokenizer 的同一套 Rusttokenizers库。它加载tokenizer.json,返回的Encoding中字符偏移表把每个 token 映射回源字符串。由于它与 OpenMed Python 管线消费完全相同的tokenizer.json,WordPiece/BPE 行为、normalization 和偏移构造性地一致——这是最强的奇偶保证。代价是 APK 中每个 ABI 增加一个原生库,且最低 SDK 为 26。

从版本目录看,OpenMedKit Android 实际引用的依赖为:

# android/gradle/libs.versions.toml djl-tokenizers = { module = "ai.djl.huggingface:tokenizers", version.ref = "djl-tokenizers" } djl-tokenizer-native-android = { module = "ai.djl.android:tokenizer-native", version.ref = "djl-tokenizers" }

其中djl-tokenizers版本为0.33.0djl-tokenizers = "0.33.0"),并配套ai.djl.android:tokenizer-native提供 Android ABI 原生实现。

方案二:纯 Kotlin WordPiece(兜底策略)

手写 WordPiece tokenizer 体积最小、无原生与 copyleft 依赖。代价是实现负担与奇偶风险:normalization、pre-tokenization、偏移映射规则必须与 Python fast tokenizer逐项精确复刻,任何漂移都会静默破坏 span 恢复;同时不额外改造就无法覆盖 BPE 类 checkpoint。原文明确:"Implementing it is a separate Android-module task",即实现它属于独立的 Android 模块任务。

方案三:ORT-Extensions 图内 BertTokenizer(不采纳)

ONNX Runtime Extensions 可以把BertTokenizer烘焙进.ort图,模型直接接收原始字符串,无需单独分发 tokenizer 运行时。但其致命缺陷是:图内 tokenizer无法干净地暴露字符级偏移——而这恰是 span 恢复的必需输入;且仅限 WordPiece/BERT(无 BPE),冻结在图中的编码规则还可能随时间与 Python tokenizer 漂移。因此 ORT-Extensions 方案被明确否决("not adopted")。

决策:主策略 + 兜底策略

原文 "Decision" 一节的结论被android/openmedkit模块的现役实现完整落地:

  • 主策略:DJL HuggingFace tokenizers AAR。理由:Apache-2.0(宽松、无 GPL)、原生高保真字符偏移支撑 span 恢复、最低 SDK 26 与模块地板一致、与 Python tokenization 精确奇偶(消费同一份tokenizer.json)。span 偏移保真与 Python 奇偶是决定性因素,压过了每 ABI 原生库的体积成本。现役实现为ai.djl.huggingface:tokenizers+HuggingFaceTokenizer.newInstance(...)+ Runtime.kt 中的编码偏移映射。
  • 兜底策略:纯 Kotlin WordPiece。若未来某目标部署无法接受 AAR 的原生体积,则回退到纯 Kotlin WordPiece——移除原生依赖,代价是大量实现工作与相对 Python tokenizer 的奇偶验证负担。
  • ORT-Extensions 图内 tokenization 不采纳,主要原因是其弱字符偏移支持与 span 恢复需求不兼容。

需要随模型打包的 Tokenizer 资产

主策略消费的是模型目录中由导出任务产出的 Hugging Face fast-tokenizer 资产(原文 "Tokenizer Assets To Bundle" 一节):

  • tokenizer.json——fast-tokenizer 定义(词表、merges、normalization、pre-tokenization 规则),DJL tokenizer 必需;
  • tokenizer_config.json——特殊 token 与配置元数据;
  • id2label.json——标签映射表,用于把 token 分类 logits 解码成实体标签。

这些资产由 Android 导出任务与 ONNX 图一并产出:入口位于 openmed/onnx/convert.py。从源码看,Android 导出 profile 对资产有硬性要求(convert.py):

require_id2label=profile in {ANDROID_PROFILE_NAME, OPENVINO_PROFILE_NAME}, require_tokenizer_json=profile == ANDROID_PROFILE_NAME,

若导出目录缺少tokenizer.json,Android profile 会直接抛错:

if require_tokenizer_json and not (output_dir / "tokenizer.json").is_file(): raise RuntimeError( f"Android ONNX export requires tokenizer.json for {model_id}" )

id2label.json的写出逻辑同样在导出任务中(把config.json中的id2label映射序列化到独立 JSON 文件),并在缺少id2label元数据时抛ValueError("Android ONNX profile requires config.json id2label metadata")。资产导出到模型目录后,"打包进 APK 或随模型下载"属于独立任务。

Android 端如何消费这些资产

OpenMedBackend.kt 是"纯本地优先"的配置载体——调用方提供一个设备本地模型目录即可,该类型不执行任何网络访问:

data class OpenMedBackend( val modelDirectory: File, val modelFile: File = File(modelDirectory, "model.onnx"), val tokenizerJson: File = File(modelDirectory, "tokenizer.json"), val tokenizerConfig: File? = File(modelDirectory, "tokenizer_config.json"), val id2LabelFile: File = File(modelDirectory, "id2label.json"), val id2Label: Map<Int, String> = emptyMap(), )

tokenizer 的加载在 Runtime.kt 中完成,与文档描述完全一致:

private fun loadTokenizer(backend: OpenMedBackend): HuggingFaceTokenizer { require(backend.tokenizerJson.isFile) { "tokenizer.json does not exist: ${backend.tokenizerJson.path}" } return HuggingFaceTokenizer.newInstance(backend.modelDirectory.toPath()) }

源码级纵深:从 token 偏移到 OpenMedSpan 的完整链路

1. tokenizer 编码与偏移提取

Encoding.toTokenOffsets()把 DJL 返回的charTokenSpans转成TokenOffset列表,nullspan(如特殊 token)归一为(0,0)(Runtime.kt)。随后这些 offsets 与input_idsattention_mask一并传入 ONNX 会话。

2. ONNX 推理与特殊 token 过滤

OnnxTokenClassifier.kt 定义了标准输入名常量(input_idsattention_masktoken_type_ids,输出logits):

internal const val INPUT_IDS_NAME = "input_ids" internal const val ATTENTION_MASK_NAME = "attention_mask" internal const val TOKEN_TYPE_IDS_NAME = "token_type_ids" internal const val LOGITS_NAME = "logits"

运行前会校验inputIdsattentionMaskoffsets三者长度一致,且每个 offset 满足0 <= startOffset <= endOffset。推理时若图包含token_type_ids输入则自动补零张量。解码阶段(decodePredictions)对每个 token 做 argmax + softmax(数值稳定形式:先减 max logit 再 exp)得到标签与置信度,并用offset.isSpecialToken跳过特殊 token——这正是"tokenizer 偏移表同时负责滤除特殊 token"的机制。

3. 偏移契约:Unicode 标量 vs UTF-16

UnicodeOffsetContract.kt 是跨运行时偏移契约的实现:公开的实体坐标永远使用 Unicode 标量(code point)偏移,而非 Kotlin 原生 UTF-16 索引。它提供scalarLengthscalarToUtf16Indexutf16ToScalarOffsetutf16SpansubstringreplaceScalarSpan等转换工具,并且utf16ToScalarOffset会拒绝落在代理对(surrogate pair)中间的索引——因为那不能构成合法的 OpenMed 实体边界。调用方只在需要调用 JVM substring/replace 的边界点做一次转换。

4. BIO/BIOES 标签聚合成实体 span

decode/TokenClassificationDecoder.kt 把逐 token 预测按 BIOES(也兼容 BIO/无前缀)边界规则聚合成实体:

  • B-开启实体、I-延续、E-结束、S-单 token 实体、O无关 token;
  • 聚合时用 token 的startOffset/endOffset合并出实体整体区间,并用AggregationStrategyFIRST/MAX/AVERAGE,默认AVERAGE)计算实体置信度;
  • 最终repairEntitySpans通过 ICU 分段器(IcuTextSegmenter.snapScalarSpan)把 span 吸附到字素边界,并向两侧扩展吸收词性字符、去除首尾空白,保证输出的EntityPrediction区间完整、可精确回映原文。

5. 生成 OpenMedSpan 记录

OpenMedSpan.kt 是"OpenMed 规范 span 记录(OM-027 4.3 节)的端侧视图":start/end是半开区间 Unicode 标量偏移,与 SwiftEntityPrediction、PythonOpenMedSpan约定一致,绝不是 Kotlin UTF-16 索引schemaVersion固定为 1,与 PythonCURRENT_SCHEMA_VERSION对齐。检测出的实体经由 OpenMedKit.kt 的analyzeText/extractPii/deidentify等门面方法输出,长文本还可通过extractPiiChunked(默认chunkTokenLimit=256tokenOverlap=32)切窗推理并把偏移统一回映到原始全文。

6. 隐私:PHI 不出日志

"No PHI in logs" 约束由 util/SafeLog.kt 落实:OpenMedKit 唯一的推理日志边界SafeLog只接受类型化、PHI-free 的记录(label + 起止偏移 + span 文本的 SHA-256 哈希),默认 sink 为null——即库默认不产生任何日志或遥测,只有内部宿主显式安装 sink 才会写事件。这从机制上保证原始临床文本、token 字符串与偏移不会泄露到日志。

跨平台 Parity:用 fixture 钉死偏移与标签

原文 "References" 指向的 Android Span Parity Protocol 是偏移保真决策的验证面:Android ONNX Runtime Mobile 导出必须与 Python 参考管线保持相同的 tokenization 与 span 解码行为,parity fixture 位于android/openmedkit/src/test/resources/parity/android_span_parity.json,其中提交的容差契约是严格的全量精确匹配:

{ "token_ids": "exact", "char_offsets": "exact", "span_labels": "exact", "span_boundaries": { "mode": "exact", "tolerance_chars": 0 }, "logit_ties": "lowest_label_id" }

即使解码器输出相同标签但边界偏移 1 个字符,parity 测试也会失败——这正是"offset fidelity 与 Python parity 是决定性因素"这一决策的测试级背书。fixture 使用SYNTH_占位符与phi_free: true标记保证输入为合成数据,span 记录不含表面文本,仅以text_hash做完整性校验。相关测试位于 parity 测试目录(ApiParityTest.ktOffsetContractParityTest.ktSpanEquivalenceTest.kt),另有NoNetworkInferenceTest.ktNoPhiLoggingTest.kt分别钉住"无网络"与"无 PHI 日志"两条约束。

实践要点小结

  1. 选型结论可直接复用:在需要"精确字符偏移 + Python 奇偶"的 Android token 分类场景,优先选择ai.djl.huggingface:tokenizers(配ai.djl.android:tokenizer-native),接受每 ABI 一个.so与 min SDK 26 的成本。
  2. 资产三件套不可缺tokenizer.json(必需)、tokenizer_config.jsonid2label.jsonopenmed.onnx.convert --profile android导出任务强制产出;打包或随模型下载后,用OpenMedBackend(modelDirectory)指向该目录即可。
  3. 偏移一律用 Unicode 标量:端侧 span 坐标遵循 UnicodeOffsetContract.kt,仅在 JVM 边界转换,杜绝 UTF-16 索引污染。
  4. 奇偶由 fixture 钉死:修改任何 tokenizer 或解码行为前,跑通android_span_parity.json的 exact 容差校验,避免边界漂移静默引入。

相关文档

  • Android ONNX Export——导出矩阵与产出 tokenizer/label 资产的导出任务
  • Android Span Parity——跨平台 span 保证与 parity fixture 契约
  • Swift-Kotlin API Parity——跨平台 API 对齐
  • Model Manifest——模型目录与可复现性元数据
  • Android Integration 与 Android Quickstart——端侧接入与快速开始

【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询