OpenMed Android Span Parity Protocol:用共享 Fixture 钉死端上 NER 与 Python 参考实现的逐字节一致性
2026/9/17 12:44:54 网站建设 项目流程

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.json

fixture 针对合成临床文本(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_NAMEopenmed/onnx/android_profile.py
ONNX 格式标识"onnx-android"ANDROID_ONNX_FORMAT同上
固定 opset18ANDROID_ONNX_OPSET同上
必选输入input_idsattention_maskANDROID_REQUIRED_INPUTS同上
可选输入token_type_idsANDROID_OPTIONAL_INPUTS同上
唯一输出logitsANDROID_OUTPUTS同上
输入 dtypeint64ANDROID_INPUT_DTYPE同上
logits dtypefloat32ANDROID_LOGITS_DTYPE同上
预期执行提供方NNAPIXNNPACKANDROID_EXECUTION_PROVIDERS同上

profile 验证(validate_android_profile)会执行几类硬性检查:

  1. opset 必须等于 18,否则抛ValueError
  2. 张量契约:输入必须恰好是input_idsattention_mask(可选再加token_type_ids),顺序固定,不能出现意外输入;输出只能是logits
  3. 轴语义batchsequence轴必须是动态(dynamic)且命名正确,labels轴必须是静态(static)——这样单个产物既能服务于可变 batch 推理,又能让后续 ONNX Runtime Mobile 的.ort转换保持图结构可预测;
  4. 算子白名单:图内所有算子必须落在_MOBILE_SAFE_OPS集合内(如LayerNormalizationGeluMatMulSoftmax等),不在 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):

  1. 加载 Android 导出产物:读取导出目录中的openmed-onnx.jsonmanifest,确认其包含onnx-android格式产物,并读取id2label.json标签映射(_load_android_export);
  2. 验证 ONNX 图:默认调用validate_android_profile校验 Android profile 契约(可用--skip-model-validation跳过);
  3. 运行 Python 参考推理:用AutoTokenizer.from_pretrained(..., use_fast=True)对每条合成文本做max_length截断、return_offsets_mapping=True的分词,再用 onnxruntimeCPUExecutionProvider跑 session,取出logits输出;
  4. 确定性解码:对每个 token 取 logits 行的argmax(并列时取最小 label id)_argmax_lowest_id),计算 softmax 分数,得到 token 记录;
  5. 合并 span:按 BIOES 标签流式合并 token 为 span(_decode_spans),对 span 头尾做空白裁剪,标注canonical_labelpolicy_labelentity_typetext_hash等字段;
  6. 隐私校验与落盘validate_fixture_payload校验结构与隐私约束,write_fixturesort_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-001SYNTH_PATIENT_ALPHA visited SYNTH_CLINIC_BETA on SYNTH_DATE_2099Q4D18.,预期产出PERSON(DIRECT_IDENTIFIER)、ORGANIZATION(QUASI_IDENTIFIER)、DATE(QUASI_IDENTIFIER)三个 span;
  • synth-note-002Route results to SYNTH_PERSON_BRAVO through SYNTH_PHONE_TOKEN_42.,预期产出PERSONPHONE(DIRECT_IDENTIFIER)两个 span。

fixture 头部记录了模型元数据(source_model_idmodel_pathlabel_map_pathtokenizer_pathmax_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.jsoncontract字段逐字一致):

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

契约的语义非常明确:

  • 零字符容差tolerance_chars: 0span_boundaries.modeexact。如果 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: truephi_free: true
  • 文本必须包含SYNTH_占位符(例如SYNTH_PATIENT_ALPHA),拒绝纯自然文本;
  • 文本命中以下 PHI 形态即被拒绝:
    • SSN 形态\d{3}-\d{2}-\d{4}
    • 北美电话号码形态;
    • 邮箱形态;
    • 常见的John Doe/Jane Doe姓名形态。

此外还有一层更精细的设计:span 记录不包含表面文本(surface text)。_validate_spans明确拒绝 span 中出现textwordsurface等字段。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.txtexpected_spans.json,用OpenMed().extractPII(note)跑端上推理,断言每个实体的labelstartend与期望完全一致,并验证entity.text与按偏移切片的原文一致;
  • OffsetContractParityTest(Robolectric,@Config(sdk = [33])):重点验证Unicode scalar 偏移契约——Android 侧(AndroidIcuTextSegmentationAdapter)与 JVM 侧(Icu4jTextSegmentationAdapter)在 emoji、CJK 扩展 B 区等增补平面字符上的偏移换算完全一致,拒绝代理对(surrogate pair)被拆开,并覆盖实体对齐、解码、redaction 与 policy 全链路;
  • ApiParityTest:验证 Android API 面(analyzeTextextractPIIextractPIIChunkedEntityPredictionlabel/text/confidence/start/end字段等)与 Swift 端公开 API 对齐。

Android 端解码器如何呼应契约

端上推理入口在 OnnxTokenClassifier.kt:

  • 输入张量名与 profile 契约一致(input_idsattention_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 的完整工作流是:

  1. openmed.onnx.convert --profile android导出满足 opset 18、动态 batch/sequence 轴、白名单算子的 token-classification ONNX 模型(必要时用export_android_fp16生成 fp16 变体);
  2. generate_parity_fixtures.py在合成文本上运行 Python 参考推理,生成确定性 JSON 并提交到android/openmedkit/src/test/resources/parity/
  3. Android 端 JVM/Robolectric 测试加载 fixture,逐字段断言 token ID、字符偏移、canonical 标签与 span 边界为exact
  4. 任何一端行为变化(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),仅供参考

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

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

立即咨询