OpenMed Android 端侧 Tokenization 决策与实现:WordPiece/BPE 偏移映射如何支撑本地 Token 分类推理 📅 发布时间:2026/9/17 16:06:34 👁 浏览次数: 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 分类推理必须先解决 tokenizationOpenMedKit 的 Android 端采用与 Python 参考管线一致的 ONNX 模型进行 token 分类输入一段临床文本输出每个 token 的实体标签如人名、日期、病历号再聚合成完整的实体区间。这一链路里 tokenizer 处在最前端原始文本 → tokenizer 产生input_ids、attention_mask必要时含token_type_ids这些张量送入 ONNX Runtime 会话输出logits解码器把每个 token 的标签预测按字符偏移聚合回原始文本区间。可见tokenizer 不仅负责分词还负责给出每个 token 在原始字符串中的精确字符区间。没有这个偏移表模型的标签预测就无法映射回原文也就无法生成可用于高亮、脱敏、FHIR 导出的OpenMedSpan。OpenMedKit 的 Android 实现Runtime.kt中BackendOnnxTokenClassifier.predict的调用顺序印证了这条链路override suspend fun predict(text: String): ListTokenClassificationPrediction { 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 返回的Encoding的charTokenSpansRuntime.kt——这正是偏移由 tokenizer 原生提供这一决策在代码中的落点。约束本地优先的硬性前提与 OpenMedKit 其余部分一致Android 端 tokenization 必须满足以下约束原文 Constraints 一节全部被采纳为设计红线仅端侧执行On-device onlytokenization 完全运行在手机上模型与 tokenizer 资产下载完成后不再有任何网络访问不存在远程 tokenization 路径。日志中不得出现 PHItokenizer 不得记录原始输入文本、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.0DJL 封装 Rusttokenizerscrate项目自有代码宽松MITonnxruntime-extensions偏移映射Rust tokenizerEncoding原生字符偏移高保真必须手工重实现正确性由作者承担有限图内 tokenizer 无法干净地暴露用于 span 恢复的字符偏移二进制 / 资产体积每个 ABI 增加一个原生.so到 APKtokenizer 资产为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.jsonWordPiece/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:tokenizersHuggingFaceTokenizer.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.pyrequire_id2labelprofile in {ANDROID_PROFILE_NAME, OPENVINO_PROFILE_NAME}, require_tokenizer_jsonprofile ANDROID_PROFILE_NAME,若导出目录缺少tokenizer.jsonAndroid profile 会直接抛错if require_tokenizer_json and not (output_dir / tokenizer.json).is_file(): raise RuntimeError( fAndroid 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: MapInt, 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_ids、attention_mask一并传入 ONNX 会话。2. ONNX 推理与特殊 token 过滤OnnxTokenClassifier.kt 定义了标准输入名常量input_ids、attention_mask、token_type_ids输出logitsinternal 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运行前会校验inputIds、attentionMask、offsets三者长度一致且每个 offset 满足0 startOffset endOffset。推理时若图包含token_type_ids输入则自动补零张量。解码阶段decodePredictions对每个 token 做 argmax softmax数值稳定形式先减 max logit 再 exp得到标签与置信度并用offset.isSpecialToken跳过特殊 token——这正是tokenizer 偏移表同时负责滤除特殊 token的机制。3. 偏移契约Unicode 标量 vs UTF-16UnicodeOffsetContract.kt 是跨运行时偏移契约的实现公开的实体坐标永远使用 Unicode 标量code point偏移而非 Kotlin 原生 UTF-16 索引。它提供scalarLength、scalarToUtf16Index、utf16ToScalarOffset、utf16Span、substring、replaceScalarSpan等转换工具并且utf16ToScalarOffset会拒绝落在代理对surrogate pair中间的索引——因为那不能构成合法的 OpenMed 实体边界。调用方只在需要调用 JVM substring/replace 的边界点做一次转换。4. BIO/BIOES 标签聚合成实体 spandecode/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默认chunkTokenLimit256、tokenOverlap32切窗推理并把偏移统一回映到原始全文。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.kt、OffsetContractParityTest.kt、SpanEquivalenceTest.kt另有NoNetworkInferenceTest.kt与NoPhiLoggingTest.kt分别钉住无网络与无 PHI 日志两条约束。实践要点小结选型结论可直接复用在需要精确字符偏移 Python 奇偶的 Android token 分类场景优先选择ai.djl.huggingface:tokenizers配ai.djl.android:tokenizer-native接受每 ABI 一个.so与 min SDK 26 的成本。资产三件套不可缺tokenizer.json必需、tokenizer_config.json、id2label.json由openmed.onnx.convert --profile android导出任务强制产出打包或随模型下载后用OpenMedBackend(modelDirectory)指向该目录即可。偏移一律用 Unicode 标量端侧 span 坐标遵循 UnicodeOffsetContract.kt仅在 JVM 边界转换杜绝 UTF-16 索引污染。奇偶由 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),仅供参考