OpenMed Android Span Parity Protocol:用共享 Fixture 钉死端上 NER 与 Python 参考实现的逐字节一致性
【免费下载链接】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
OpenMed 的 Android ONNX Runtime Mobile 导出必须与 Python 参考路径在**分词(tokenization)与 span 解码(span decoding)**上保持完全一致,这是本地优先(local-first)医疗 AI 在端上交付临床 NER 与 HIPAA PII 脱敏时的正确性底线。本文围绕 docs/android-parity.md 定义的 Span Parity Protocol,完整讲解如何用openmed.onnx.convert导出 Android 配置文件、用generate_parity_fixtures.py生成并提交 parity JSON、理解其严格容差契约与隐私规则,并深入到 Python 与 Kotlin 两侧的源码实现,说明一条 token ID、字符偏移、标签与 span 边界全部以exact模式对齐的可验证链路。读完本文,你将掌握 OpenMed Android 端 NER 一致性验证的完整工作流与底层原理。
一、什么是 Android Span Parity Protocol
Android 端 OpenMedKit 通过 ONNX Runtime Mobile 执行 token 分类(token classification)模型完成 PII 识别,但端上推理要真正可依赖,必须保证:同一段文本在 Android 端与 Python 参考实现上产出相同的 tokenizer ID 序列、相同的字符偏移、相同的 argmax 标签和相同的解码 span。这就是 Span Parity Protocol 的使命——它不是一个口号,而是一份被提交进仓库的机器可读契约。
该契约由固定格式的 parity fixture 钉住:
android/openmedkit/src/test/resources/parity/android_span_parity.jsonfixture 针对合成临床文本(synthetic clinical text)记录 Python 参考路径的完整输出,Android 侧 JVM 单元测试加载该 JSON,逐字段比对端上 tokenizer 与 ONNX session 的推理结果。任何一端的行为漂移(例如 tokenizer 版本变化、onnxruntime 算子行为差异、解码器实现不同)都会让 parity 测试失败,从而在发布前暴露问题。
从源码结构看,这份契约由三个组件协同维护:
- 导出侧:openmed/onnx/android_profile.py 定义了 Android 导出 profile 的图契约;
- 生成侧:scripts/android/generate_parity_fixtures.py 以 Python 参考实现运行推理并产出 fixture;
- 消费侧:android/openmedkit 模块的 JVM 测试加载 fixture 并断言端上行为一致。
二、第一步:用 Android Profile 导出 ONNX 模型
生成 parity fixture 前,必须先导出一个满足 Android ONNX profile 的 token-classification checkpoint。文档给出的命令如下:
.venv/bin/python -m openmed.onnx.convert \ --model dslim/bert-base-NER \ --output dist/example-android-onnx \ --profile android--profile android会走openmed/onnx/android_profile.py中定义的 profile 约束。从 android_profile.py 源码可以看到该 profile 的具体契约:
| 契约项 | 值 | 来源 |
|---|---|---|
| profile 名称 | "android"(ANDROID_PROFILE_NAME) | openmed/onnx/android_profile.py |
| ONNX 格式标识 | "onnx-android"(ANDROID_ONNX_FORMAT) | 同上 |
| 固定 opset | 18(ANDROID_ONNX_OPSET) | 同上 |
| 必选输入 | input_ids、attention_mask(ANDROID_REQUIRED_INPUTS) | 同上 |
| 可选输入 | token_type_ids(ANDROID_OPTIONAL_INPUTS) | 同上 |
| 唯一输出 | logits(ANDROID_OUTPUTS) | 同上 |
| 输入 dtype | int64(ANDROID_INPUT_DTYPE) | 同上 |
| logits dtype | float32(ANDROID_LOGITS_DTYPE) | 同上 |
| 预期执行提供方 | NNAPI、XNNPACK(ANDROID_EXECUTION_PROVIDERS) | 同上 |
profile 验证(validate_android_profile)会执行几类硬性检查:
- opset 必须等于 18,否则抛
ValueError; - 张量契约:输入必须恰好是
input_ids、attention_mask(可选再加token_type_ids),顺序固定,不能出现意外输入;输出只能是logits; - 轴语义:
batch与sequence轴必须是动态(dynamic)且命名正确,labels轴必须是静态(static)——这样单个产物既能服务于可变 batch 推理,又能让后续 ONNX Runtime Mobile 的.ort转换保持图结构可预测; - 算子白名单:图内所有算子必须落在
_MOBILE_SAFE_OPS集合内(如LayerNormalization、Gelu、MatMul、Softmax等),不在 NNAPI/XNNPACK 安全集内的算子会产生警告并被记录进AndroidProfileValidation.warnings。
profile 还提供export_android_fp16辅助函数,将权重转换为 fp16 变体(model_fp16.onnx,默认保留 IO 类型)并在转换后再次验证图。这对应 Android 端轻量化部署的常见诉求。
三、第二步:生成并提交 parity fixture
导出完成后,用生成器产出 parity JSON:
.venv/bin/python scripts/android/generate_parity_fixtures.py \ --export-dir dist/example-android-onnx \ --output android/openmedkit/src/test/resources/parity/android_span_parity.json生成器(scripts/android/generate_parity_fixtures.py)的执行流程如下(build_fixture_payload与_run_reference):
- 加载 Android 导出产物:读取导出目录中的
openmed-onnx.jsonmanifest,确认其包含onnx-android格式产物,并读取id2label.json标签映射(_load_android_export); - 验证 ONNX 图:默认调用
validate_android_profile校验 Android profile 契约(可用--skip-model-validation跳过); - 运行 Python 参考推理:用
AutoTokenizer.from_pretrained(..., use_fast=True)对每条合成文本做max_length截断、return_offsets_mapping=True的分词,再用 onnxruntimeCPUExecutionProvider跑 session,取出logits输出; - 确定性解码:对每个 token 取 logits 行的argmax(并列时取最小 label id)(
_argmax_lowest_id),计算 softmax 分数,得到 token 记录; - 合并 span:按 BIOES 标签流式合并 token 为 span(
_decode_spans),对 span 头尾做空白裁剪,标注canonical_label、policy_label、entity_type、text_hash等字段; - 隐私校验与落盘:
validate_fixture_payload校验结构与隐私约束,write_fixture以sort_keys=True的确定性 JSON 写出。
CLI 参数汇总(main):
| 参数 | 作用 | 默认值 |
|---|---|---|
--export-dir(必填) | openmed.onnx.convert --profile android产出的目录 | 无 |
--output | 输出的 fixture JSON 路径 | android/openmedkit/src/test/resources/parity/android_span_parity.json |
--cases | 可选 JSON 列表[{id, text}],自定义合成用例 | 内置DEFAULT_SYNTHETIC_CASES |
--skip-model-validation | 跳过 Android profile 图验证直接推理 | False |
仓库已提交的 fixture(android_span_parity.json)内置两条合成用例:
synth-note-001:SYNTH_PATIENT_ALPHA visited SYNTH_CLINIC_BETA on SYNTH_DATE_2099Q4D18.,预期产出PERSON(DIRECT_IDENTIFIER)、ORGANIZATION(QUASI_IDENTIFIER)、DATE(QUASI_IDENTIFIER)三个 span;synth-note-002:Route results to SYNTH_PERSON_BRAVO through SYNTH_PHONE_TOKEN_42.,预期产出PERSON、PHONE(DIRECT_IDENTIFIER)两个 span。
fixture 头部记录了模型元数据(source_model_id、model_path、label_map_path、tokenizer_path、max_sequence_length)以及生成器与文档的相对路径,便于溯源。
四、Fixture 契约:严格到零容忍的字段对比
Android parity 测试必须逐字段比对以下内容(来自 docs/android-parity.md):
cases[].text:与合成输入文本完全一致;cases[].tokens[].id:tokenizer ID 序列完全一致;cases[].tokens[].offset:[start, end)字符偏移完全一致;cases[].spans[].canonical_label:规范化标签完全一致;cases[].spans[].start/cases[].spans[].end:span 边界完全一致。
提交进 fixture 的容差契约(与仓库中android_span_parity.json的contract字段逐字一致):
{ "token_ids": "exact", "char_offsets": "exact", "span_labels": "exact", "span_boundaries": { "mode": "exact", "tolerance_chars": 0 }, "logit_ties": "lowest_label_id" }契约的语义非常明确:
- 零字符容差:
tolerance_chars: 0,span_boundaries.mode为exact。如果 Android 解码器产出了相同标签但边界偏移了一个字符,parity 测试必须失败; - 确定性的并列处理:
logit_ties: lowest_label_id,即 logits 出现并列时统一取 label id 最小的那个。Python 侧_argmax_lowest_id的实现是max(range(len(row)), key=lambda index: (float(row[index]), -index));Android 侧 OnnxTokenClassifier.kt 的decodeToken使用logit > maxLogit的严格大于比较,同样保证首个最大值的索引即最小 label id。两侧行为在此处被显式钉死。
文档特别强调:边界容差被显式记录在 fixture 中,是为了让未来的任何变更都以可评审的方式出现,而不是在测试中静默漂移。若未来确实需要放宽边界要求,必须显式修改契约并经过评审,而不是让测试悄悄放过偏差。
五、隐私规则:合成输入与表面文本隔离
Parity 输入必须保持合成(synthetic),fixture 的隐私约束由 Python 侧validate_fixture_payload强制(generate_parity_fixtures.py):
- 每个 case 必须标记
synthetic: true与phi_free: true; - 文本必须包含
SYNTH_占位符(例如SYNTH_PATIENT_ALPHA),拒绝纯自然文本; - 文本命中以下 PHI 形态即被拒绝:
- SSN 形态
\d{3}-\d{2}-\d{4}; - 北美电话号码形态;
- 邮箱形态;
- 常见的
John Doe/Jane Doe姓名形态。
- SSN 形态
此外还有一层更精细的设计:span 记录不包含表面文本(surface text)。_validate_spans明确拒绝 span 中出现text、word、surface等字段。Android 测试如果需要 span 的原文表面,应当用预期偏移去切片cases[].text,并通过text_hash做确定性完整性校验——fixture 中每个 span 都带hmac-sha256:...形式的text_hash,校验函数会用hmac_text_hash(text[start:end], PARITY_HASH_SECRET)重新计算并比对。这样即使 fixture 被读取端误用,也不会在产物中直接沉淀真实患者文本。
六、Android 端资源布局与 JVM 测试消费方式
fixture 作为 Android 模块的普通测试资源存放:
android/openmedkit/src/test/resources/parity/android_span_parity.json资源是纯 JSON,没有 Android 专属的二进制编码,因此 JVM 单元测试可以直接用类加载器读取(javaClass.classLoader.getResourceAsStream(...))、解析cases、对每条text运行 Android tokenizer 与 ONNX session,再与契约字段逐一比对。
从 android/openmedkit/src/test/kotlin/com/openmed/openmedkit/parity 的测试结构看,parity 验证被拆成了几个互补的层面:
- SpanEquivalenceTest:读取
synthetic_clinical_note.txt与expected_spans.json,用OpenMed().extractPII(note)跑端上推理,断言每个实体的label、start、end与期望完全一致,并验证entity.text与按偏移切片的原文一致; - OffsetContractParityTest(Robolectric,
@Config(sdk = [33])):重点验证Unicode scalar 偏移契约——Android 侧(AndroidIcuTextSegmentationAdapter)与 JVM 侧(Icu4jTextSegmentationAdapter)在 emoji、CJK 扩展 B 区等增补平面字符上的偏移换算完全一致,拒绝代理对(surrogate pair)被拆开,并覆盖实体对齐、解码、redaction 与 policy 全链路; - ApiParityTest:验证 Android API 面(
analyzeText、extractPII、extractPIIChunked,EntityPrediction的label/text/confidence/start/end字段等)与 Swift 端公开 API 对齐。
Android 端解码器如何呼应契约
端上推理入口在 OnnxTokenClassifier.kt:
- 输入张量名与 profile 契约一致(
input_ids、attention_mask,若 session 存在token_type_ids则补零张量),输出读取logits; decodeToken使用与 Python 等价的确定性 argmax(严格大于),并对 logits 做 softmax(减最大值后指数归一)得到分数;- 模型与
id2label.json均可从文件、字节数组加载,支持intraOpThreadCount配置与Closeable资源释放。
token 预测合并为实体 span 的逻辑在 TokenClassificationDecoder.kt,支持 BIO/BIOES 标签流:B开始、I延续、E结束、S单实体、O非实体;repairEntitySpans负责把 span 对齐到字形边界(grapheme boundary)、扩展词形字符并裁剪首尾空白。多 token 置信度聚合策略由 AggregationStrategy.kt 提供FIRST/AVERAGE/MAX三种选择。这些行为与 Python 参考实现(_decode_tokens/_decode_spans)在语义上对齐,再被 fixture 的exact契约统一检验。
七、把 Parity 纳入端上发布流程
综合来看,Android span parity 的完整工作流是:
- 用
openmed.onnx.convert --profile android导出满足 opset 18、动态 batch/sequence 轴、白名单算子的 token-classification ONNX 模型(必要时用export_android_fp16生成 fp16 变体); - 用
generate_parity_fixtures.py在合成文本上运行 Python 参考推理,生成确定性 JSON 并提交到android/openmedkit/src/test/resources/parity/; - Android 端 JVM/Robolectric 测试加载 fixture,逐字段断言 token ID、字符偏移、canonical 标签与 span 边界为
exact; - 任何一端行为变化(tokenizer、onnxruntime、解码器)都会在 CI 中被零容差的契约拦截,需要显式评审后才能变更契约。
这套协议与仓库中其他 Android 相关文档(如 docs/android-integration.md、docs/export-onnx-android.md、docs/android-tokenization.md、docs/android-quickstart.md)共同构成 OpenMed 端上能力的质量保障体系:先有导出侧的 profile 约束,再有参考侧的 fixture 钉桩,最后有消费侧的逐字节断言——三层闭环确保本地优先的临床 NER 与 PII 脱敏在 Android 端与 Python 参考实现之间不产生静默漂移。
【免费下载链接】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),仅供参考